Skip to main content

kernel/discovery/
service.rs

1//! [`DiscoveryService`]: the discovery capstone. It runs the store scanners and
2//! reconciles what they found against the registry — updating known records,
3//! registering new ones, marking on-disk-gone models missing (guarded by a
4//! weights-present check), migrating a moved model's saved config onto its new
5//! record, and summarizing the result.
6
7use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use crate::discovery::duplicates::{
11    DEFAULT_THRESHOLD, DuplicateGroup, content_fingerprint, detect,
12};
13use crate::discovery::scanner::{DiscoveredModel, StoreScanner};
14use crate::records::byte_format::BYTES_PER_MIB;
15use crate::records::{Modality, ModelRecord, ModelState, SourceKind, format_bytes, stable_id};
16use crate::registry::{Registry, RegistryError};
17
18/// The count and byte total of models found for one source kind.
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub struct KindStat {
21    /// How many models were found.
22    pub count: usize,
23    /// Their combined on-disk size in bytes.
24    pub bytes: i64,
25}
26
27/// The outcome of a discovery pass: per-kind counts, totals, duplicate groups,
28/// non-fatal issues, and the store kinds whose scan failed.
29#[derive(Debug, Clone, Default, PartialEq)]
30pub struct DiscoverySummary {
31    /// Per source-kind stats.
32    pub per_kind: BTreeMap<SourceKind, KindStat>,
33    /// Total models found across all stores.
34    pub total_count: usize,
35    /// Total bytes across all found models.
36    pub total_bytes: i64,
37    /// Groups of duplicate weight files.
38    pub duplicates: Vec<DuplicateGroup>,
39    /// Non-fatal issues surfaced during the scan.
40    pub issues: Vec<String>,
41    /// Store kinds whose scan failed wholesale.
42    pub failed_kinds: Vec<SourceKind>,
43}
44
45impl DiscoverySummary {
46    /// A one-line, human-readable summary of what was found.
47    pub fn headline(&self) -> String {
48        if self.total_count == 0 {
49            return "No models found on this Mac yet.".to_owned();
50        }
51        let ordered = [
52            (SourceKind::ollama(), "in Ollama"),
53            (SourceKind::huggingface_cache(), "in the Hugging Face cache"),
54            (SourceKind::lm_studio(), "in LM Studio"),
55            (SourceKind::builtin(), "built in"),
56        ];
57        let mut parts = Vec::new();
58        for (kind, label) in ordered {
59            if let Some(stat) = self.per_kind.get(&kind)
60                && stat.count > 0
61            {
62                parts.push(format!("{} {label}", stat.count));
63            }
64        }
65        let loose = self.kind_count(&SourceKind::file()) + self.kind_count(&SourceKind::folder());
66        if loose == 1 {
67            parts.push("1 loose file".to_owned());
68        } else if loose > 1 {
69            parts.push(format!("{loose} loose files"));
70        }
71        let models = if self.total_count == 1 {
72            "1 model".to_owned()
73        } else {
74            format!("{} models", self.total_count)
75        };
76        let breakdown = if parts.is_empty() {
77            String::new()
78        } else {
79            format!(" — {}", parts.join(", "))
80        };
81        format!(
82            "Found {models} on this Mac{breakdown}. Total: {}.",
83            format_bytes(self.total_bytes)
84        )
85    }
86
87    fn kind_count(&self, kind: &SourceKind) -> usize {
88        self.per_kind.get(kind).map_or(0, |stat| stat.count)
89    }
90}
91
92/// Runs a set of scanners and reconciles their findings into a registry.
93pub struct DiscoveryService {
94    scanners: Vec<Box<dyn StoreScanner>>,
95    duplicate_threshold: i64,
96}
97
98impl DiscoveryService {
99    /// A service over `scanners`, using the default duplicate threshold.
100    pub fn new(scanners: Vec<Box<dyn StoreScanner>>) -> Self {
101        Self::with_threshold(scanners, DEFAULT_THRESHOLD)
102    }
103
104    /// A service with an explicit duplicate-detection size threshold.
105    pub fn with_threshold(scanners: Vec<Box<dyn StoreScanner>>, duplicate_threshold: i64) -> Self {
106        Self {
107            scanners,
108            duplicate_threshold,
109        }
110    }
111
112    /// Scan every store and reconcile the results into `registry`, returning a
113    /// summary. Records are updated/inserted; a scanned model no longer on disk
114    /// is marked missing; a model whose files moved carries its saved config to
115    /// the new record.
116    pub fn discover(&self, registry: &mut Registry) -> Result<DiscoverySummary, RegistryError> {
117        let mut discovered: Vec<DiscoveredModel> = Vec::new();
118        let mut issues: Vec<String> = Vec::new();
119        let mut failed_kinds: BTreeSet<SourceKind> = BTreeSet::new();
120        for scanner in &self.scanners {
121            let result = scanner.scan();
122            discovered.extend(result.discovered);
123            issues.extend(result.issues);
124            failed_kinds.extend(result.failed_kinds);
125        }
126        for kind in &failed_kinds {
127            issues.push(format!(
128                "skipped the missing check for {} — its store could not be read",
129                kind.as_str()
130            ));
131        }
132
133        let existing: Vec<ModelRecord> = registry.list().into_iter().cloned().collect();
134        let existing_by_id: HashMap<&str, &ModelRecord> = existing
135            .iter()
136            .map(|record| (record.id.as_str(), record))
137            .collect();
138
139        let mut seen_ids: HashSet<String> = HashSet::new();
140        let mut to_register: Vec<ModelRecord> = Vec::new();
141        let mut new_record_ids: HashSet<String> = HashSet::new();
142
143        for model in &discovered {
144            let id = stable_id(&model.source);
145            if !seen_ids.insert(id.clone()) {
146                continue;
147            }
148            match existing_by_id.get(id.as_str()) {
149                Some(existing_record) => {
150                    let mut record = (*existing_record).clone();
151                    record.content_fingerprint = fingerprint(model, Some(existing_record));
152                    record.name = model.name.clone();
153                    record.source = model.source.clone();
154                    record.footprint_bytes = Some(model.footprint_bytes);
155                    record.primary_weight_path = model.primary_weight_path.clone();
156                    // An empty capability hint is usually nothing learned, so what
157                    // the record holds stands. Not when the modality moved under
158                    // it: those capabilities were a different kind of model's (a
159                    // reranker once read as an embedder kept "embed").
160                    let mut modality_moved = false;
161                    if let Some(modality) = &model.modality_hint {
162                        modality_moved = record.modality != *modality;
163                        record.modality = modality.clone();
164                    }
165                    if modality_moved || !model.capabilities_hint.is_empty() {
166                        record.capabilities = model.capabilities_hint.clone();
167                    }
168                    if let Some(context) = model.context_length_hint {
169                        record.context_length = Some(context);
170                    }
171                    if let Some(template) = model.has_chat_template_hint {
172                        record.has_chat_template = Some(template);
173                    }
174                    if model.tool_capable_hint.is_some() {
175                        record.supports_tools = model.tool_capable_hint;
176                    }
177                    if let Some(stops) = &model.stop_tokens_hint {
178                        record.stop_tokens = Some(stops.clone());
179                    }
180                    record.execution = model.execution_hint;
181                    record.downloading = model.downloading;
182                    if record.state == ModelState::Missing {
183                        record.state = ModelState::Unresolved;
184                    }
185                    to_register.push(record);
186                }
187                None => {
188                    let mut record = ModelRecord::new(
189                        &model.name,
190                        model
191                            .modality_hint
192                            .clone()
193                            .unwrap_or_else(Modality::unknown),
194                        model.capabilities_hint.clone(),
195                        model.source.clone(),
196                    );
197                    record.execution = model.execution_hint;
198                    record.footprint_bytes = Some(model.footprint_bytes);
199                    record.state = ModelState::Unresolved;
200                    record.context_length = model.context_length_hint;
201                    record.has_chat_template = model.has_chat_template_hint;
202                    record.supports_tools = model.tool_capable_hint;
203                    record.stop_tokens = model.stop_tokens_hint.clone();
204                    record.primary_weight_path = model.primary_weight_path.clone();
205                    record.downloading = model.downloading;
206                    record.content_fingerprint = fingerprint(model, None);
207                    to_register.push(record);
208                    new_record_ids.insert(id);
209                }
210            }
211        }
212
213        let scanned_kinds: HashSet<SourceKind> = self
214            .scanners
215            .iter()
216            .flat_map(|scanner| scanner.kinds())
217            .filter(|kind| !failed_kinds.contains(kind))
218            .collect();
219
220        for record in &existing {
221            if scanned_kinds.contains(&record.source.kind)
222                && !seen_ids.contains(&record.id)
223                && record.state != ModelState::Missing
224                && !weights_present(record)
225            {
226                let mut stale = record.clone();
227                stale.state = ModelState::Missing;
228                to_register.push(stale);
229            }
230        }
231
232        let missing_candidates: Vec<&ModelRecord> = existing
233            .iter()
234            .filter(|record| {
235                !seen_ids.contains(&record.id)
236                    && (record.state == ModelState::Missing
237                        || scanned_kinds.contains(&record.source.kind))
238            })
239            .collect();
240        let migrated_away =
241            migrate_moved_config(&mut to_register, &new_record_ids, &missing_candidates);
242
243        let keep: Vec<ModelRecord> = to_register
244            .into_iter()
245            .filter(|record| !migrated_away.contains(&record.id))
246            .collect();
247        registry.register_all(keep)?;
248        for id in &migrated_away {
249            registry.unregister(id)?;
250        }
251
252        let mut per_kind: BTreeMap<SourceKind, KindStat> = BTreeMap::new();
253        for model in &discovered {
254            let stat = per_kind.entry(model.source.kind.clone()).or_default();
255            stat.count += 1;
256            stat.bytes += model.footprint_bytes;
257        }
258        let duplicate_candidates: Vec<(String, PathBuf)> = discovered
259            .iter()
260            .filter_map(|model| {
261                model
262                    .primary_weight_path
263                    .as_ref()
264                    .map(|path| (model.name.clone(), PathBuf::from(path)))
265            })
266            .collect();
267
268        Ok(DiscoverySummary {
269            total_count: discovered.len(),
270            total_bytes: discovered.iter().map(|model| model.footprint_bytes).sum(),
271            duplicates: detect(&duplicate_candidates, self.duplicate_threshold),
272            per_kind,
273            issues,
274            failed_kinds: failed_kinds.into_iter().collect(),
275        })
276    }
277}
278
279/// Whether a record's weights are still on disk: its primary weight file if it
280/// names one (non-empty), otherwise the source path. A record with no weight
281/// path is treated as absent.
282fn weights_present(record: &ModelRecord) -> bool {
283    let Some(path) = record
284        .primary_weight_path
285        .as_deref()
286        .filter(|path| !path.is_empty())
287    else {
288        return false;
289    };
290    Path::new(path).exists() || Path::new(&record.source.path).exists()
291}
292
293/// Two sizes compared as the whole mebibytes they round to.
294///
295/// A record written before sizes were exact carries a rounded figure, and a
296/// scan measures the byte the file actually ends on; compared exactly the two
297/// could never match, and a model that had merely moved would lose the settings
298/// saved against it. Mebibytes are what these comparisons always used.
299fn same_size(recorded: Option<i64>, scanned: Option<i64>) -> bool {
300    let mebibytes = |bytes: Option<i64>| bytes.map(|bytes| bytes / BYTES_PER_MIB);
301    mebibytes(recorded) == mebibytes(scanned)
302}
303
304/// The content fingerprint for a discovered model, reusing the existing record's
305/// fingerprint when the weight path and footprint are unchanged (to avoid
306/// re-hashing), and preserving it when the model has no weight path.
307fn fingerprint(model: &DiscoveredModel, existing: Option<&ModelRecord>) -> Option<String> {
308    let Some(path) = &model.primary_weight_path else {
309        return existing.and_then(|record| record.content_fingerprint.clone());
310    };
311    if let Some(existing) = existing
312        && let Some(known) = &existing.content_fingerprint
313        && existing.primary_weight_path.as_deref() == Some(path.as_str())
314        && same_size(existing.footprint_bytes, Some(model.footprint_bytes))
315    {
316        return Some(known.clone());
317    }
318    content_fingerprint(Path::new(path))
319}
320
321/// Move a newly-found model's saved config (params, system prompt, alias) from a
322/// uniquely-matching missing record (same fingerprint and footprint) — the model
323/// moved on disk. Returns the ids of the claimed (orphaned) records to remove.
324fn migrate_moved_config(
325    to_register: &mut [ModelRecord],
326    new_record_ids: &HashSet<String>,
327    missing_candidates: &[&ModelRecord],
328) -> HashSet<String> {
329    let mut claimed: HashSet<String> = HashSet::new();
330    for record in to_register.iter_mut() {
331        if !new_record_ids.contains(&record.id) {
332            continue;
333        }
334        let Some(fingerprint) = record.content_fingerprint.clone() else {
335            continue;
336        };
337        let footprint = record.footprint_bytes;
338        // Extract the unique orphan's config as owned values, then drop the
339        // iterator (it borrows `claimed`) before mutating `claimed` below.
340        let orphan = {
341            let mut matches = missing_candidates.iter().filter(|candidate| {
342                candidate.content_fingerprint.as_deref() == Some(fingerprint.as_str())
343                    && same_size(candidate.footprint_bytes, footprint)
344                    && !claimed.contains(&candidate.id)
345            });
346            match (matches.next(), matches.next()) {
347                (Some(orphan), None) => Some((
348                    orphan.id.clone(),
349                    orphan.param_values.clone(),
350                    orphan.system_prompt.clone(),
351                    orphan.alias.clone(),
352                )),
353                _ => None,
354            }
355        };
356        let Some((orphan_id, param_values, system_prompt, alias)) = orphan else {
357            continue;
358        };
359        record.param_values = param_values;
360        record.system_prompt = system_prompt;
361        record.alias = alias;
362        claimed.insert(orphan_id);
363    }
364    claimed
365}