Skip to main content

car_inference/
concierge_answer.rs

1//! What `concierge.ask` answers with — built so the facts never depend on a
2//! language model.
3//!
4//! [`summarize`] states the local model world deterministically: what each
5//! installed model is doing, what maintenance would do and why, the memory
6//! policy, and what the concierge did on its own. A model then answers the
7//! question from that evidence, bounded; [`trim_repetition`] cuts the loops
8//! small models fall into, and [`ungrounded_ids`] names any model id the
9//! reply mentions that the evidence does not contain, so an invented model
10//! is flagged rather than passed off as verified.
11
12use std::fmt::Write as _;
13
14use serde::Serialize;
15
16use crate::maintenance::MaintenanceReport;
17use crate::portfolio::{Portfolio, PortfolioAction, Verdict};
18
19/// What `concierge.ask` returns.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct ConciergeAnswer {
22    /// The reply: the model's answer to the question, or the facts alone.
23    pub answer: String,
24    /// The deterministic facts the answer rests on.
25    pub summary: String,
26    /// The model that answered, when one did.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub model_used: Option<String>,
29    /// Model ids the reply named that the evidence does not contain.
30    pub ungrounded: Vec<String>,
31}
32
33fn gb(bytes: u64) -> f64 {
34    bytes as f64 / 1e9
35}
36
37fn verdict_label(verdict: Verdict) -> &'static str {
38    match verdict {
39        Verdict::Protected => "protected",
40        Verdict::NeverRunsHere => "cannot run on this machine",
41        Verdict::CannotLoad => "never loaded successfully",
42        Verdict::Superseded => "replaced by a newer model",
43        Verdict::Idle => "unused",
44        Verdict::Active => "in use",
45        Verdict::Unknown => "not enough history yet",
46    }
47}
48
49/// The deterministic state of this machine's local models, in plain words.
50/// `memory_policy` is the active local-model memory policy as a word
51/// (`everyday`, `local_focused`, `custom`).
52pub fn summarize(
53    portfolio: &Portfolio,
54    maintenance: &MaintenanceReport,
55    memory_policy: &str,
56) -> String {
57    let mut out = String::new();
58    let installed = portfolio.models.len();
59    let _ = writeln!(out, "Installed local models: {installed}.");
60    for assessment in &portfolio.models {
61        let freed = assessment
62            .retire
63            .as_ref()
64            .filter(|_| assessment.action == PortfolioAction::Retire)
65            .map(|r| format!("; retiring it would free {:.1} GB", gb(r.freed_bytes)))
66            .unwrap_or_default();
67        let _ = writeln!(
68            out,
69            "- {} ({}): {}{}{}",
70            assessment.name,
71            assessment.model_id,
72            verdict_label(assessment.verdict),
73            if assessment.evidence.is_empty() {
74                String::new()
75            } else {
76                format!(" — {}", assessment.evidence.join("; "))
77            },
78            freed
79        );
80    }
81    if portfolio.reclaimable_bytes > 0 {
82        let _ = writeln!(
83            out,
84            "Retiring every model recommended for it would free {:.1} GB.",
85            gb(portfolio.reclaimable_bytes)
86        );
87    }
88    let policy = match maintenance.policy {
89        crate::update_prefs::UpdatePolicy::Auto => "auto (CAR maintains local models itself)",
90        crate::update_prefs::UpdatePolicy::Notify => "notify (CAR suggests, you decide)",
91        crate::update_prefs::UpdatePolicy::Off => "off (no maintenance)",
92    };
93    let _ = writeln!(out, "Update policy: {policy}.");
94    if maintenance.auto_grace_until.is_some() {
95        let _ = writeln!(
96            out,
97            "This is the first week under the default policy, so maintenance only suggests for now."
98        );
99    }
100    if let Some(disk) = maintenance.disk {
101        let _ = writeln!(
102            out,
103            "Disk: {:.0} GB free of {:.0} GB{}.",
104            gb(disk.free_bytes),
105            gb(disk.total_bytes),
106            if maintenance.pressured {
107                " — space is short, so unused models may be retired"
108            } else {
109                ""
110            }
111        );
112    }
113    let queued: Vec<String> = maintenance
114        .plan
115        .act
116        .iter()
117        .chain(maintenance.plan.suggest.iter())
118        .map(|d| format!("{} ({:.1} GB)", d.model_id, gb(d.freed_bytes)))
119        .collect();
120    if queued.is_empty() {
121        let _ = writeln!(out, "Maintenance has nothing to retire right now.");
122    } else {
123        let _ = writeln!(out, "Maintenance would retire: {}.", queued.join(", "));
124    }
125    for held in &maintenance.plan.held {
126        let _ = writeln!(out, "Kept {}: {}.", held.model_id, held.why);
127    }
128    let _ = writeln!(out, "Memory policy for local models: {memory_policy}.");
129    if let Some(sizing) = &maintenance.resource_policy {
130        let _ = writeln!(
131            out,
132            "The memory policy {} change from {:?} to {:?}: {}.",
133            if sizing.applied { "did" } else { "would" },
134            sizing.from,
135            sizing.to,
136            sizing.reason
137        );
138    }
139    if !portfolio.recent_actions.is_empty() {
140        let _ = writeln!(
141            out,
142            "What the concierge did on its own in the last 30 days:"
143        );
144        for action in &portfolio.recent_actions {
145            let _ = writeln!(
146                out,
147                "- {} {}{}{}",
148                action.kind,
149                action.subject,
150                if action.interrupted {
151                    " (interrupted; finished at the next daemon start)"
152                } else {
153                    ""
154                },
155                action
156                    .undo
157                    .as_deref()
158                    .map(|undo| format!(" — undo with `{undo}`"))
159                    .unwrap_or_default()
160            );
161        }
162    }
163    for pin in &portfolio.revoked_pins {
164        let named = if pin.pin == pin.model_id {
165            String::new()
166        } else {
167            format!(" (as `{}`)", pin.pin)
168        };
169        let _ = writeln!(
170            out,
171            "Its publisher revoked {}; agents still pin it{named}: {}. Point them at another model.",
172            pin.model_id,
173            pin.agents.join(", ")
174        );
175    }
176    for copy in &portfolio.revoked_copies {
177        let _ = writeln!(
178            out,
179            "{} holds {:.1} GB in a directory named for {}, which its publisher revoked; CAR never removes it on its own.",
180            copy.path.display(),
181            copy.bytes as f64 / 1e9,
182            copy.model_id
183        );
184    }
185    let user_orphans: Vec<&str> = portfolio
186        .orphans
187        .iter()
188        .filter(|o| o.owner == crate::retire::OrphanOwner::User)
189        .map(|o| o.repo.as_str())
190        .collect();
191    if !user_orphans.is_empty() {
192        let _ = writeln!(
193            out,
194            "{} Hugging Face repos no CAR model uses are yours and are never touched.",
195            user_orphans.len()
196        );
197    }
198    out
199}
200
201/// Cut a reply at the first substantial line it repeats — the loop a small
202/// model falls into — and drop trailing blank lines.
203pub fn trim_repetition(text: &str) -> String {
204    let mut seen = std::collections::HashSet::new();
205    let mut kept = Vec::new();
206    for line in text.lines() {
207        let key = line.trim().to_lowercase();
208        if key.len() >= 24 && !seen.insert(key) {
209            break;
210        }
211        kept.push(line);
212    }
213    while kept.last().is_some_and(|line| line.trim().is_empty()) {
214        kept.pop();
215    }
216    kept.join("\n")
217}
218
219/// Model families a reply might name by bare name ("Llama 3.1 70B") rather
220/// than by id.
221const MODEL_FAMILIES: [&str; 9] = [
222    "llama", "gpt", "mistral", "gemma", "deepseek", "phi", "claude", "grok", "qwen",
223];
224
225/// What the reply names that the evidence does not contain, deduplicated,
226/// in order of appearance: model ids (`owner/name[:tag]`, or more segments,
227/// as `parslee/openrouter/<alias>`), then model families named by bare name.
228/// A model id's last segment carries a digit (`qwen3-8b`, `Qwen3-4B-4bit`),
229/// which is what keeps `and/or` and `read/write` out.
230pub fn ungrounded_ids(reply: &str, evidence: &str) -> Vec<String> {
231    let evidence = evidence.to_lowercase();
232    let mut out: Vec<String> = Vec::new();
233    for raw in reply.split(|c: char| c.is_whitespace() || "()[]{}<>,;\"'`*".contains(c)) {
234        let token = raw.trim_end_matches(['.', ':', '!', '?']);
235        let parts: Vec<&str> = token.split('/').collect();
236        let valid = |part: &str| {
237            part.len() >= 2
238                && part.chars().any(|c| c.is_ascii_alphabetic())
239                && part
240                    .chars()
241                    .all(|c| c.is_ascii_alphanumeric() || "._:-".contains(c))
242        };
243        let is_id = parts.len() >= 2
244            && parts.iter().all(|p| valid(p))
245            && parts
246                .last()
247                .is_some_and(|name| name.chars().any(|c| c.is_ascii_digit()));
248        if !is_id {
249            continue;
250        }
251        let lower = token.to_lowercase();
252        if !evidence.contains(&lower) && !out.iter().any(|o| o.eq_ignore_ascii_case(token)) {
253            out.push(token.to_string());
254        }
255    }
256    let reply = reply.to_lowercase();
257    for family in MODEL_FAMILIES {
258        let named = reply
259            .split(|c: char| !c.is_ascii_alphanumeric())
260            .any(|word| word.starts_with(family));
261        if named && !evidence.contains(family) {
262            out.push(format!("{family} (family)"));
263        }
264    }
265    out
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn a_loop_is_cut_at_its_first_repeat() {
274        let reply = "Keep qwen3-8b, it is in use.\n\
275                     You could retire the vision model to free space.\n\
276                     Keep qwen3-8b, it is in use.\n\
277                     You could retire the vision model to free space.\n";
278        assert_eq!(
279            trim_repetition(reply),
280            "Keep qwen3-8b, it is in use.\nYou could retire the vision model to free space."
281        );
282        // Short lines (a bullet, "Yes.") may repeat legitimately.
283        assert_eq!(trim_repetition("- a\n- a\nok"), "- a\n- a\nok");
284    }
285
286    #[test]
287    fn only_ids_missing_from_the_evidence_are_flagged() {
288        let evidence = "- Qwen3-8B (mlx/qwen3-8b:4bit): in use";
289        let reply = "Keep mlx/qwen3-8b:4bit. Consider meta/llama-4-70b, \
290                     and see mlx/qwen3-8b:4bit again; 24/7 uptime; a/b test; \
291                     read/write and/or input/output.";
292        assert_eq!(
293            ungrounded_ids(reply, evidence),
294            vec!["meta/llama-4-70b".to_string(), "llama (family)".to_string()]
295        );
296        assert!(ungrounded_ids("(mlx/qwen3-8b:4bit).", evidence).is_empty());
297        // A bare family the evidence never mentions, and a longer id.
298        assert_eq!(
299            ungrounded_ids("Try Mistral Large or parslee/openrouter/fast-2.", evidence),
300            vec![
301                "parslee/openrouter/fast-2".to_string(),
302                "mistral (family)".to_string()
303            ]
304        );
305        // A family the evidence does contain is grounded.
306        assert!(ungrounded_ids("Qwen3 is fine.", evidence).is_empty());
307    }
308
309    #[test]
310    fn the_summary_states_the_facts_without_a_model() {
311        use crate::maintenance::{Decision, MaintenancePlan};
312        use crate::portfolio::{
313            Assessment, PortfolioPolicy, RecentAction, RetireSummary, RevokedPin,
314        };
315        let portfolio = Portfolio {
316            policy: PortfolioPolicy::new(100, Some(0)),
317            models: vec![Assessment {
318                model_id: "mlx/old:4bit".into(),
319                name: "Old".into(),
320                verdict: Verdict::Idle,
321                action: PortfolioAction::Retire,
322                evidence: vec!["unused 40 days (threshold 30)".into()],
323                kept: false,
324                last_used: None,
325                retire: Some(RetireSummary {
326                    freed_bytes: 4_300_000_000,
327                    digest: "d".into(),
328                    refusals: Vec::new(),
329                    also_retires: Vec::new(),
330                }),
331            }],
332            reclaimable_bytes: 4_300_000_000,
333            orphans: Vec::new(),
334            recent_actions: vec![RecentAction {
335                at: 90,
336                kind: "retire".into(),
337                subject: "mlx/gone:4bit".into(),
338                detail: String::new(),
339                undo: Some("car models pull mlx/gone:4bit".into()),
340                use_case: None,
341                prior: None,
342                interrupted: false,
343            }],
344            revoked_pins: vec![RevokedPin {
345                pin: "Bad Model".into(),
346                model_id: "mlx/bad:4bit".into(),
347                agents: vec!["digest".into(), "monthly".into()],
348            }],
349            revoked_copies: vec![crate::retire::RevokedCopy {
350                model_id: "mlx/bad:4bit".into(),
351                path: "/models/bad".into(),
352                bytes: 2_000_000_000,
353            }],
354        };
355        let report = MaintenanceReport {
356            plan: MaintenancePlan {
357                suggest: vec![Decision {
358                    model_id: "mlx/old:4bit".into(),
359                    verdict: Verdict::Idle,
360                    freed_bytes: 4_300_000_000,
361                    digest: "d".into(),
362                    reason: String::new(),
363                }],
364                ..Default::default()
365            },
366            ..Default::default()
367        };
368        let summary = summarize(&portfolio, &report, "everyday");
369        assert!(summary.contains("Old (mlx/old:4bit): unused"), "{summary}");
370        assert!(summary.contains("free 4.3 GB"), "{summary}");
371        assert!(
372            summary.contains("Maintenance would retire: mlx/old:4bit (4.3 GB)"),
373            "{summary}"
374        );
375        assert!(
376            summary.contains("undo with `car models pull mlx/gone:4bit`"),
377            "{summary}"
378        );
379        assert!(
380            summary.contains("Memory policy for local models: everyday"),
381            "{summary}"
382        );
383        assert!(
384            summary.contains(
385                "revoked mlx/bad:4bit; agents still pin it (as `Bad Model`): digest, monthly."
386            ),
387            "{summary}"
388        );
389        assert!(
390            summary.contains("/models/bad holds 2.0 GB in a directory named for mlx/bad:4bit"),
391            "{summary}"
392        );
393    }
394}