car-ffi-common 0.32.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! JSON wrapper for utility-aware memory retrieval (U-Mem, arXiv 2602.22406).
//!
//! Stateless helper, binding-only — see
//! `docs/proposals/autonomous-memory-agents.md`. Ranks retrieval candidates by
//! blending semantic relevance with a learned per-memory utility posterior,
//! using the deterministic UCB variant (reproducible; no RNG in the hot path).

use car_memgine::utility::{rank_ucb, Candidate, UtilityPosterior};
use serde::Deserialize;

/// One retrieval candidate as supplied over FFI: an id, its semantic
/// `relevance` in `[0,1]`, and the observed outcome counts that form its utility
/// posterior (`Beta(success+1, fail+1)`).
#[derive(Deserialize)]
struct CandidateInput {
    id: String,
    relevance: f64,
    #[serde(default)]
    success: u64,
    #[serde(default)]
    fail: u64,
}

/// Rank memory-retrieval candidates by utility-aware UCB (U-Mem SA-CTS,
/// deterministic variant). `candidates_json` is a JSON array of `{ id,
/// relevance, success, fail }`. `exploration` is the cold-start/uncertainty
/// bonus weight; `utility_weight` (`0..1`) blends utility vs. raw relevance
/// (`0` = pure relevance, unchanged). Returns the ranked JSON array
/// `[{ id, score, relevance, utility }]`, highest score first.
pub fn rank(
    candidates_json: &str,
    exploration: f64,
    utility_weight: f64,
) -> Result<String, String> {
    let inputs: Vec<CandidateInput> = crate::from_json("candidates", candidates_json)?;
    let candidates: Vec<Candidate> = inputs
        .into_iter()
        .map(|c| Candidate {
            id: c.id,
            relevance: c.relevance,
            posterior: UtilityPosterior::from_counts(c.success, c.fail),
        })
        .collect();
    let ranked = rank_ucb(&candidates, exploration, utility_weight);
    crate::to_json(&ranked)
}

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

    #[test]
    fn ranks_proven_over_relevant_when_weighted() {
        let cands = r#"[
            {"id":"a","relevance":0.5,"success":50,"fail":0},
            {"id":"b","relevance":0.7,"success":0,"fail":50}
        ]"#;
        let out = rank(cands, 0.0, 0.8).unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["id"], "a"); // proven-useful beats more-relevant-useless
    }

    #[test]
    fn weight_zero_keeps_relevance_order() {
        let cands = r#"[
            {"id":"a","relevance":0.4,"success":50,"fail":0},
            {"id":"b","relevance":0.9,"success":0,"fail":50}
        ]"#;
        let out = rank(cands, 0.0, 0.0).unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["id"], "b");
    }

    #[test]
    fn missing_counts_default_to_cold_start() {
        let out = rank(r#"[{"id":"x","relevance":0.5}]"#, 1.0, 0.5).unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["id"], "x");
    }
}