car-inference 0.22.0

Local model inference for CAR — Candle backend with Qwen3 models
Documentation
//! Proactive upgrade nudge — the decision logic that turns "an upgrade exists"
//! into "tell the user once, nicely, and never nag."
//!
//! This is the brain of the push-based upgrade story; the daemon supplies the
//! body (a periodic check that calls [`decide_nudge`] and broadcasts the
//! resulting [`UpgradeNudge`] over WebSocket, plus a dismiss RPC). Keeping the
//! decision here makes it pure and unit-testable: throttling, dismissals, and
//! the policy split (auto-apply vs notify vs off) all have golden tests.
//!
//! Rules:
//! - `policy == Off` → do nothing.
//! - Curated upgrades under `policy == Auto` are returned for background
//!   application; everything else becomes a single notification.
//! - Community (upstream) findings are *never* auto-applied — they only notify.
//! - At most one nudge per `throttle_secs` (default once/day), and a finding
//!   the user dismissed is never nudged again.

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::update_prefs::{UpdatePolicy, UpdatePreferences};
use crate::upgrade::{UpgradeFinding, UpgradeSource};

/// A single, plain-language upgrade notification for the user.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradeNudge {
    /// The upgrade being offered (the highest-priority one this round).
    pub finding: UpgradeFinding,
    /// One-line, jargon-free message: "A newer … is available — switch?".
    pub message: String,
    /// Stable key the client echoes back to dismiss this nudge.
    pub dismiss_key: String,
}

/// What the periodic check decided to do this round.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NudgeDecision {
    /// Curated upgrades to apply in the background (only under `Auto` policy).
    pub auto_apply: Vec<UpgradeFinding>,
    /// The single notification to surface, if any.
    pub nudge: Option<UpgradeNudge>,
}

/// Persisted nudge bookkeeping: when we last nudged, and what's been dismissed.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NudgeState {
    #[serde(default)]
    pub last_nudge_secs: u64,
    /// Dismiss keys the user has waved away; never nudged again.
    #[serde(default)]
    pub dismissed: Vec<String>,
}

impl NudgeState {
    pub fn default_path() -> PathBuf {
        std::env::var("HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(".car")
            .join("nudge-state.json")
    }

    pub fn load_from(path: &Path) -> Self {
        std::fs::read_to_string(path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    pub fn save_to(&self, path: &Path) -> Result<(), String> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
        }
        std::fs::write(
            path,
            serde_json::to_string_pretty(self).map_err(|e| e.to_string())?,
        )
        .map_err(|e| e.to_string())
    }

    /// Record that the user dismissed a nudge key.
    pub fn dismiss(&mut self, key: &str) {
        if !self.dismissed.iter().any(|k| k == key) {
            self.dismissed.push(key.to_string());
        }
    }
}

/// Default throttle: at most one nudge per day.
pub const DEFAULT_THROTTLE_SECS: u64 = 24 * 60 * 60;

/// A stable dismiss key for a finding — from→to identifies the specific
/// upgrade, so re-running the same upgrade won't re-nudge once dismissed, but a
/// *different* target later will.
fn dismiss_key(f: &UpgradeFinding) -> String {
    format!("{}=>{}", f.from_id, f.to_id)
}

/// Decide what to do given the current findings, prefs, and state. Pure: the
/// caller injects `now_secs` and `throttle_secs`, so throttling is testable.
///
/// Does **not** mutate state — the caller updates `last_nudge_secs` when it
/// actually surfaces the returned nudge (so a dropped notification can re-fire).
pub fn decide_nudge(
    findings: &[UpgradeFinding],
    prefs: &UpdatePreferences,
    state: &NudgeState,
    now_secs: u64,
    throttle_secs: u64,
    inference_active: bool,
) -> NudgeDecision {
    // Never interrupt active inference — defer the whole decision (nudge *and*
    // background auto-apply, which would contend for memory/compute) to a later
    // tick when the machine is idle. The daemon supplies this signal.
    if inference_active || matches!(prefs.policy, UpdatePolicy::Off) {
        return NudgeDecision::default();
    }

    // Under Auto, curated upgrades apply in the background; community never.
    let auto_apply: Vec<UpgradeFinding> = if matches!(prefs.policy, UpdatePolicy::Auto) {
        findings
            .iter()
            .filter(|f| f.source == UpgradeSource::Curated && f.target_pullable)
            .cloned()
            .collect()
    } else {
        Vec::new()
    };

    // Candidates to *notify* about = findings not being auto-applied and not
    // previously dismissed.
    let auto_keys: Vec<String> = auto_apply.iter().map(dismiss_key).collect();
    let mut notify_candidates: Vec<&UpgradeFinding> = findings
        .iter()
        .filter(|f| {
            let k = dismiss_key(f);
            !auto_keys.contains(&k) && !state.dismissed.contains(&k)
        })
        .collect();

    // Throttle: at most one nudge per window.
    let throttled = state.last_nudge_secs != 0
        && now_secs.saturating_sub(state.last_nudge_secs) < throttle_secs;

    let nudge = if throttled || notify_candidates.is_empty() {
        None
    } else {
        // Curated first (verified), then upstream; stable within tier by id.
        notify_candidates.sort_by(|a, b| {
            curated_first(a.source)
                .cmp(&curated_first(b.source))
                .then(a.to_id.cmp(&b.to_id))
        });
        let f = notify_candidates[0].clone();
        let message = nudge_message(&f);
        let dismiss_key = dismiss_key(&f);
        Some(UpgradeNudge {
            finding: f,
            message,
            dismiss_key,
        })
    };

    NudgeDecision { auto_apply, nudge }
}

fn curated_first(s: UpgradeSource) -> u8 {
    match s {
        UpgradeSource::Curated => 0,
        UpgradeSource::Upstream => 1,
    }
}

/// One plain-language line. No model ids, quant, or repos.
fn nudge_message(f: &UpgradeFinding) -> String {
    match f.source {
        UpgradeSource::Curated => format!(
            "A newer model is available to replace {}: {} Switch?",
            f.from_name, f.reason
        ),
        UpgradeSource::Upstream => format!(
            "{} has an update available. {} Refresh it?",
            f.from_name, f.reason
        ),
    }
}

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

    fn finding(from: &str, to: &str, source: UpgradeSource) -> UpgradeFinding {
        UpgradeFinding {
            from_id: from.into(),
            from_name: from.into(),
            to_id: to.into(),
            to_name: to.into(),
            reason: "newer line.".into(),
            trust_tier: if source == UpgradeSource::Curated {
                TrustTier::Curated
            } else {
                TrustTier::Community
            },
            source,
            target_pullable: true,
        }
    }

    fn prefs(policy: UpdatePolicy) -> UpdatePreferences {
        UpdatePreferences {
            policy,
            ..Default::default()
        }
    }

    #[test]
    fn active_inference_defers_everything() {
        // Even with an auto-applicable curated upgrade, an active inference
        // suppresses both the nudge and background auto-apply.
        let f = [finding("a", "b", UpgradeSource::Curated)];
        let d = decide_nudge(&f, &prefs(UpdatePolicy::Auto), &NudgeState::default(), 100, 10, true);
        assert!(d.nudge.is_none() && d.auto_apply.is_empty());
    }

    #[test]
    fn off_policy_does_nothing() {
        let f = [finding("a", "b", UpgradeSource::Curated)];
        let d = decide_nudge(&f, &prefs(UpdatePolicy::Off), &NudgeState::default(), 100, 10, false);
        assert!(d.nudge.is_none() && d.auto_apply.is_empty());
    }

    #[test]
    fn notify_policy_nudges_but_never_auto_applies() {
        let f = [finding("a", "b", UpgradeSource::Curated)];
        let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &NudgeState::default(), 100, 10, false);
        assert!(d.auto_apply.is_empty(), "Notify never auto-applies");
        assert!(d.nudge.is_some());
        assert!(d.nudge.unwrap().message.contains("Switch?"));
    }

    #[test]
    fn auto_policy_applies_curated_and_does_not_nudge_for_them() {
        let f = [finding("a", "b", UpgradeSource::Curated)];
        let d = decide_nudge(&f, &prefs(UpdatePolicy::Auto), &NudgeState::default(), 100, 10, false);
        assert_eq!(d.auto_apply.len(), 1);
        assert!(d.nudge.is_none(), "auto-applied curated upgrade isn't nudged");
    }

    #[test]
    fn auto_policy_still_nudges_community_never_auto_applies_it() {
        let f = [finding("a", "b", UpgradeSource::Upstream)];
        let d = decide_nudge(&f, &prefs(UpdatePolicy::Auto), &NudgeState::default(), 100, 10, false);
        assert!(d.auto_apply.is_empty(), "community is never auto-applied");
        assert!(d.nudge.is_some(), "community still notifies under Auto");
    }

    #[test]
    fn throttle_suppresses_within_window() {
        let f = [finding("a", "b", UpgradeSource::Curated)];
        let state = NudgeState {
            last_nudge_secs: 100,
            ..Default::default()
        };
        // 105 is within a 10s window of the last nudge → suppressed.
        let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 105, 10, false);
        assert!(d.nudge.is_none());
        // 120 is past the window → allowed again.
        let d2 = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 120, 10, false);
        assert!(d2.nudge.is_some());
    }

    #[test]
    fn dismissed_findings_are_never_nudged_again() {
        let f = [finding("a", "b", UpgradeSource::Curated)];
        let mut state = NudgeState::default();
        state.dismiss("a=>b");
        let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 100, 10, false);
        assert!(d.nudge.is_none(), "dismissed upgrade must not re-nudge");
    }

    #[test]
    fn curated_is_preferred_over_upstream_in_the_single_nudge() {
        let f = [
            finding("x", "x", UpgradeSource::Upstream),
            finding("a", "b", UpgradeSource::Curated),
        ];
        let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &NudgeState::default(), 100, 10, false);
        let n = d.nudge.expect("a nudge");
        assert_eq!(n.finding.source, UpgradeSource::Curated);
    }

    #[test]
    fn state_round_trips_and_dedups_dismissals() {
        let dir = std::env::temp_dir().join(format!("car-nudge-{}", std::process::id()));
        let path = dir.join("nudge-state.json");
        let mut s = NudgeState::default();
        s.dismiss("a=>b");
        s.dismiss("a=>b"); // dedup
        s.last_nudge_secs = 42;
        s.save_to(&path).unwrap();
        let back = NudgeState::load_from(&path);
        assert_eq!(back.dismissed, vec!["a=>b".to_string()]);
        assert_eq!(back.last_nudge_secs, 42);
        let _ = std::fs::remove_dir_all(&dir);
    }
}