car-ffi-common 0.32.1

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! JSON wrapper for tool-receipt hallucination detection (arXiv 2603.10060).
//!
//! Stateless helper, binding-only — see
//! `docs/proposals/tool-receipt-verification.md`. Cross-checks the model's
//! claims about tool use against the runtime's receipts of what actually
//! executed, flagging fabricated tool references, count misstatements, and
//! false-absence claims.

use car_eventlog::tool_receipts::{verify_tool_claims_windowed, ToolClaim, ToolReceipt};

/// Verify model tool-use claims against runtime receipts. `claims_json` is a JSON
/// array of `ToolClaim` (`{ kind: "invoked" | "count" | "absence", tool, call_id?,
/// count?, text? }`); `receipts_json` is a JSON array of `ToolReceipt`
/// (`{ tool, call_id?, ok?, result_count? }`). `window_complete` declares whether
/// the receipts cover the full window the claims are about (pass `false` when
/// they were projected from a retention-trimmed log): with an incomplete window,
/// a claim with no matching receipt is reported in `ungroundable` ("window
/// evicted") instead of `fabricated_tool_reference` (review A6). Returns the
/// `ReceiptReport` JSON `{ grounded, hallucinations: [{ kind, tool, claim_text?,
/// explanation }], ungroundable?: [{ tool, claim_text?, explanation }] }`, where
/// `kind` is `fabricated_tool_reference` | `count_misstatement` |
/// `false_absence`.
pub fn verify(
    claims_json: &str,
    receipts_json: &str,
    window_complete: bool,
) -> Result<String, String> {
    let claims: Vec<ToolClaim> = crate::from_json("claims", claims_json)?;
    let receipts: Vec<ToolReceipt> = crate::from_json("receipts", receipts_json)?;
    let report = verify_tool_claims_windowed(&claims, &receipts, window_complete);
    crate::to_json(&report)
}

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

    #[test]
    fn fabricated_round_trips() {
        let claims = r#"[{"kind":"invoked","tool":"search"}]"#;
        let out = verify(claims, "[]", true).unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["grounded"], false);
        assert_eq!(v["hallucinations"][0]["kind"], "fabricated_tool_reference");
    }

    #[test]
    fn count_misstatement_round_trips() {
        let claims = r#"[{"kind":"count","tool":"search","count":12}]"#;
        let receipts = r#"[{"tool":"search","ok":true,"result_count":3}]"#;
        let out = verify(claims, receipts, true).unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["hallucinations"][0]["kind"], "count_misstatement");
    }

    #[test]
    fn grounded_round_trips() {
        let claims = r#"[{"kind":"absence","tool":"search"}]"#;
        let receipts = r#"[{"tool":"search","ok":true,"result_count":0}]"#;
        let out = verify(claims, receipts, true).unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["grounded"], true);
    }

    #[test]
    fn evicted_window_round_trips_ungroundable() {
        // window_complete=false: a receiptless claim is "window evicted",
        // never fabricated (review A6).
        let claims = r#"[{"kind":"invoked","tool":"search"}]"#;
        let out = verify(claims, "[]", false).unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["grounded"], true);
        assert!(v["hallucinations"].as_array().unwrap().is_empty());
        assert_eq!(v["ungroundable"][0]["tool"], "search");
    }

    #[test]
    fn invalid_json_errors() {
        assert!(verify("nope", "[]", true).is_err());
    }
}