car-memgine 0.48.0

Memgine — graph-based memory engine for Common Agent Runtime
//! Bridge: conversation/episode credit → the inference `OutcomeTracker`.
//!
//! This is the seam that lets the router learn from real conversation outcomes.
//! It maps each episode-level [`TurnCredit`](crate::episode::TurnCredit) to the
//! tracker's `InferredOutcome` and feeds it through the ONE correct door —
//! `record_inferred_outcome` — which:
//!
//! - is **one-shot** (it pops `pending` by `trace_id`), so re-feeding an
//!   already-resolved turn on a growing conversation is a silent no-op —
//!   idempotency for free, *as long as we never bypass this method*; and
//! - **reverses the mechanical success** the tracker books at call completion:
//!   a turn that completed with output is already counted a success, and only
//!   this path knows to undo it when a `Rejected` (our `Failure`) arrives.
//!
//! Mapping:
//! - [`Credit::Success`] → `InferredOutcome::Accepted { confidence }`
//! - [`Credit::Failure`] → `InferredOutcome::Rejected { confidence }`
//! - `trace_id == None` (observer turns) → skipped; never bind a routing
//!   decision on an unattributed turn.
//!
//! On the confidence "teeth" question: the tracker books success/fail as integer
//! counts (`is_success`), and the Thompson sampler's *posterior* reads those
//! counts — so the episode discount/boost does NOT change the binary count. It
//! does move `ema_quality`, which feeds the Thompson *prior*. So confidence has
//! teeth early (prior) and the hard binary outcome dominates as observations
//! accumulate — meaning, to be honest about it, an *uncertain* inferred failure
//! eventually penalizes a model as much as a *certain* one, once the prior
//! washes out. That's acceptable for coarse inferred labels; if low-confidence
//! labels prove common, fractional alpha/beta or an inferred-vs-explicit weight
//! is the later move. Fractional counts are a deliberate future option, not here.
//!
//! Trace populations are disjoint from the reasoning action-sequence resolver
//! (`infer_outcomes_from_action_sequence`): chat turns get their own
//! `generate_tracked` trace ids, reasoning actions get theirs, so the two
//! resolvers never contend for the same `pending` entry.
//!
//! Caveat: the tracker sweeps `pending` after a 300s TTL (a swept entry with
//! output becomes a mechanical success). A conversation reaction that arrives
//! >5 min after the assistant turn will find the trace already swept, so its
//! > `Failure` is lost (the mechanical success stands). Fine for interactive chat;
//! > noted for long-idle sessions.

use car_inference::{InferredOutcome, OutcomeTracker};

use crate::episode::{assign_credit, Credit, TurnCredit};
use crate::outcome_signal::ConversationTurn;

/// Map one credit to a `(trace_id, InferredOutcome)` the tracker accepts.
/// Returns `None` for observer turns (no `trace_id`) — never recorded.
///
/// Private on purpose: callers must go through [`record_episode_credits`], the
/// one door that preserves the one-shot + mechanical-success-reversal
/// guarantees. Constructing the pair and feeding the tracker directly would
/// bypass them.
fn credit_to_inferred(credit: &TurnCredit) -> Option<(String, InferredOutcome)> {
    let trace_id = credit.trace_id.clone()?;
    let outcome = match credit.credit {
        Credit::Success => InferredOutcome::Accepted {
            confidence: credit.confidence,
        },
        Credit::Failure => InferredOutcome::Rejected {
            confidence: credit.confidence,
        },
    };
    Some((trace_id, outcome))
}

/// Outcome of feeding a batch of credits. `submitted` counts credits with a
/// `trace_id`; `resolved` counts those whose trace was still pending (so the
/// resolution actually landed). `submitted - resolved` is *lost signal* — a
/// correction that arrived after the trace was already resolved or swept. The
/// gap is the metric to watch before trusting this for routing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CreditTally {
    pub submitted: usize,
    pub resolved: usize,
}

/// Feed episode credits into the tracker through the one-shot,
/// mechanical-success-reversing door. Idempotent on re-run (already-resolved
/// traces no-op). Returns a [`CreditTally`] so silent losses (swept/resolved
/// traces) are observable instead of invisible.
pub fn record_episode_credits(tracker: &mut OutcomeTracker, credits: &[TurnCredit]) -> CreditTally {
    let mut tally = CreditTally::default();
    for credit in credits {
        if let Some((trace_id, outcome)) = credit_to_inferred(credit) {
            tally.submitted += 1;
            // Observe before the one-shot pop so a no-op is countable.
            if tracker.has_pending(&trace_id) {
                tally.resolved += 1;
            }
            tracker.record_inferred_outcome(&trace_id, outcome);
        }
    }
    if tally.resolved < tally.submitted {
        tracing::debug!(
            submitted = tally.submitted,
            resolved = tally.resolved,
            "outcome_bridge: {} conversation credit(s) hit no pending trace (already \
             resolved or swept) — lost signal",
            tally.submitted - tally.resolved
        );
    }
    tally
}

/// End-to-end convenience: classify typed turns, assign episode credit, and
/// record into the tracker. Returns the number of credits recorded.
pub fn record_conversation_outcomes(
    tracker: &mut OutcomeTracker,
    turns: &[ConversationTurn],
) -> CreditTally {
    let credits = assign_credit(turns);
    record_episode_credits(tracker, &credits)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::episode::EpisodeResolution;
    use car_inference::InferenceTask;

    // model_id is intentionally a param even though the bridge never reads it
    // (the tracker resolves the model from the pending entry by trace_id) — so a
    // test can pass None and watch it still pass, documenting that fact.
    fn credit(
        trace_id: Option<&str>,
        kind: Credit,
        model_id: Option<&str>,
        conf: f64,
    ) -> TurnCredit {
        TurnCredit {
            turn_ref: "r".into(),
            model_id: model_id.map(Into::into),
            trace_id: trace_id.map(Into::into),
            credit: kind,
            confidence: conf,
            episode_id: 0,
            resolution: EpisodeResolution::Ongoing,
        }
    }

    #[test]
    fn failure_credit_reverses_mechanical_success() {
        // THE load-bearing test (neo): a turn that completed with output is
        // booked a mechanical success; a conversation Failure must reverse it.
        let mut t = OutcomeTracker::new();
        let tid = t.record_start("gpt-5.4", InferenceTask::Generate, "test");
        t.record_complete(&tid, 100, 10, 20); // output_tokens=20 → mechanical success
        assert_eq!(t.profile("gpt-5.4").unwrap().success_count, 1);

        let tally = record_episode_credits(
            &mut t,
            &[credit(Some(&tid), Credit::Failure, Some("gpt-5.4"), 0.85)],
        );
        assert_eq!(tally.resolved, 1);
        let p = t.profile("gpt-5.4").unwrap();
        assert_eq!(p.success_count, 0, "mechanical success must be reversed");
        assert_eq!(p.fail_count, 1, "failure must be booked");
    }

    #[test]
    fn success_credit_keeps_completion_success() {
        let mut t = OutcomeTracker::new();
        let tid = t.record_start("m2", InferenceTask::Generate, "test");
        t.record_complete(&tid, 50, 5, 10);
        record_episode_credits(
            &mut t,
            &[credit(Some(&tid), Credit::Success, Some("m2"), 0.6)],
        );
        let p = t.profile("m2").unwrap();
        assert_eq!(p.success_count, 1, "already-credited success stays counted");
        assert_eq!(p.fail_count, 0);
    }

    #[test]
    fn observer_turns_without_trace_id_are_skipped() {
        let mut t = OutcomeTracker::new();
        let tally =
            record_episode_credits(&mut t, &[credit(None, Credit::Failure, Some("m"), 0.85)]);
        assert_eq!(tally.submitted, 0);
        assert_eq!(tally.resolved, 0);
    }

    #[test]
    fn re_feeding_resolved_trace_is_idempotent_noop() {
        let mut t = OutcomeTracker::new();
        let tid = t.record_start("m", InferenceTask::Generate, "test");
        t.record_complete(&tid, 10, 1, 2);
        record_episode_credits(
            &mut t,
            &[credit(Some(&tid), Credit::Failure, Some("m"), 0.85)],
        );
        let after_first = {
            let p = t.profile("m").unwrap();
            (p.success_count, p.fail_count)
        };
        // Second feed of the same (already-popped) trace: one-shot no-op.
        record_episode_credits(
            &mut t,
            &[credit(Some(&tid), Credit::Failure, Some("m"), 0.85)],
        );
        let p = t.profile("m").unwrap();
        assert_eq!(
            (p.success_count, p.fail_count),
            after_first,
            "re-feed must be a no-op"
        );
    }

    #[test]
    fn submitted_but_unresolved_trace_changes_nothing() {
        // The headline caveat: a credit with a valid trace_id but no matching
        // pending entry (never started, already resolved, or swept) counts as
        // submitted (n=1) yet resolves NOTHING. submitted != resolved.
        let mut t = OutcomeTracker::new();
        let tally = record_episode_credits(
            &mut t,
            &[credit(
                Some("never-started-trace"),
                Credit::Failure,
                Some("ghost"),
                0.85,
            )],
        );
        assert_eq!(tally.submitted, 1, "has trace_id → submitted");
        assert_eq!(tally.resolved, 0, "no pending trace → resolved nothing");
        assert!(
            t.profile("ghost").is_none(),
            "no pending → no profile mutation"
        );
    }

    #[test]
    fn end_to_end_records_attributed_turns() {
        let mut t = OutcomeTracker::new();
        let tid = t.record_start("m1", InferenceTask::Generate, "test");
        t.record_complete(&tid, 10, 1, 2);
        // user asks, assistant (attributed to tid) answers, user circles → Failure.
        let turns = vec![
            ConversationTurn::user("convert to async", "u1"),
            ConversationTurn::assistant(
                "threaded version",
                "a1",
                Some("m1".into()),
                Some(tid.clone()),
            ),
            ConversationTurn::user("no, that's not what i asked", "u2"),
            ConversationTurn::assistant("async version", "a2", Some("m1".into()), None),
        ];
        let tally = record_conversation_outcomes(&mut t, &turns);
        assert!(tally.resolved >= 1);
        let p = t.profile("m1").unwrap();
        assert_eq!(
            p.fail_count, 1,
            "the circled-on attributed turn is booked a failure"
        );
        assert_eq!(p.success_count, 0);
    }
}