Skip to main content

qualia_client_core/
model_lifecycle.rs

1//! Catalog LLM download → GGUF shard map → WAL → lifecycle (in-process).
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
5use std::sync::{Arc, OnceLock};
6
7use qualia_core_db::{
8    gguf_sharder::GGufSharder,
9    llm_agent::{AgentBackend, LocalLlmAgent},
10    orchestrator::{ModelLifecycle, NullThermalGovernor, TaskOrchestrator},
11    q_hash,
12    resource_catalog::{LLMResource, ResourceCatalog},
13    wal::WriteAheadLog,
14};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18#[derive(Debug)]
19pub enum ModelError {
20    NotFound(String),
21    NoDownloadUrl(String),
22    Download(String),
23    Shard(String),
24    Wal(String),
25    Activate(String),
26    Io(std::io::Error),
27    Json(serde_json::Error),
28}
29
30impl std::fmt::Display for ModelError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            ModelError::NotFound(id) => write!(f, "LLM not found in catalog: {id}"),
34            ModelError::NoDownloadUrl(id) => write!(f, "No download URL for: {id}"),
35            ModelError::Download(e) => write!(f, "Download failed: {e}"),
36            ModelError::Shard(e) => write!(f, "GGUF shard map failed: {e}"),
37            ModelError::Wal(e) => write!(f, "WAL write failed: {e}"),
38            ModelError::Activate(e) => write!(f, "Model activation failed: {e}"),
39            ModelError::Io(e) => write!(f, "IO error: {e}"),
40            ModelError::Json(e) => write!(f, "JSON error: {e}"),
41        }
42    }
43}
44
45impl From<std::io::Error> for ModelError {
46    fn from(e: std::io::Error) -> Self {
47        ModelError::Io(e)
48    }
49}
50
51impl From<serde_json::Error> for ModelError {
52    fn from(e: serde_json::Error) -> Self {
53        ModelError::Json(e)
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ActiveModelRecord {
59    pub model_id: String,
60    pub gguf_path: String,
61    pub profile_id: u64,
62    pub quantization: String,
63    pub lifecycle_state: String,
64    #[serde(default)]
65    pub modality: String,
66    #[serde(default)]
67    pub architecture: Option<String>,
68    #[serde(default)]
69    pub mmproj_path: Option<String>,
70    #[serde(default)]
71    pub context_window: u32,
72}
73
74#[derive(Debug, Clone, Serialize)]
75pub struct ModelInstallResult {
76    pub model_id: String,
77    pub gguf_path: String,
78    pub profile_id: u64,
79    pub pointer_quin_count: usize,
80    pub vision_pointer_quin_count: usize,
81    pub lifecycle_state: String,
82    pub wal_path: String,
83    pub modality: String,
84    pub mmproj_path: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize)]
88pub struct ModelStatus {
89    pub active: Option<ActiveModelRecord>,
90    pub lifecycle_state: String,
91    pub profile_id: Option<u64>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct InstallManifest {
96    pub model_id: String,
97    pub gguf_path: String,
98    pub profile_id: u64,
99    pub quantization: String,
100    pub pointer_quin_count: usize,
101    pub vision_pointer_quin_count: usize,
102    pub installed_at: u64,
103    pub wal_path: String,
104    pub modality: String,
105    pub architecture: Option<String>,
106    pub mmproj_path: Option<String>,
107    pub context_window: u32,
108}
109
110fn orchestrator() -> Arc<TaskOrchestrator> {
111    task_orchestrator()
112}
113
114/// Shared orchestrator for chat inference and model lifecycle transitions.
115pub fn task_orchestrator() -> Arc<TaskOrchestrator> {
116    static ORCH: OnceLock<Arc<TaskOrchestrator>> = OnceLock::new();
117    ORCH.get_or_init(|| Arc::new(TaskOrchestrator::new(Box::new(NullThermalGovernor))))
118        .clone()
119}
120
121/// UI RAM ceiling for LLM arena pressure (matches 0.0.12 Flutter plan).
122pub const MEMORY_FLOOR_MB: u32 = 512;
123
124static LLM_MEMORY_BYTES: AtomicU64 = AtomicU64::new(0);
125static KV_CACHE_USED_MB: AtomicU32 = AtomicU32::new(0);
126/// Last completed chat/decode throughput × 1000 (tok/s), or 0 if never measured.
127static LAST_DECODE_TOK_S_MILLI: AtomicU32 = AtomicU32::new(0);
128/// Unix-ish stamp (secs since process start not required — wall via duration store is enough).
129static LAST_DECODE_TOK_S_AT_SECS: AtomicU64 = AtomicU64::new(0);
130
131pub fn record_llm_memory_bytes(bytes: u64) {
132    LLM_MEMORY_BYTES.store(bytes, Ordering::Relaxed);
133}
134
135pub fn record_llm_memory_sample(bytes: u64) {
136    if bytes == 0 {
137        return;
138    }
139    let mut current = LLM_MEMORY_BYTES.load(Ordering::Relaxed);
140    while bytes > current {
141        match LLM_MEMORY_BYTES.compare_exchange(
142            current,
143            bytes,
144            Ordering::Relaxed,
145            Ordering::Relaxed,
146        ) {
147            Ok(_) => return,
148            Err(observed) => current = observed,
149        }
150    }
151}
152
153pub fn get_llm_memory_bytes() -> u64 {
154    LLM_MEMORY_BYTES.load(Ordering::Relaxed)
155}
156
157pub fn record_kv_cache_used_mb(megabytes: u32) {
158    KV_CACHE_USED_MB.store(megabytes, Ordering::Relaxed);
159}
160
161pub fn get_kv_cache_used_mb() -> u32 {
162    KV_CACHE_USED_MB.load(Ordering::Relaxed)
163}
164
165/// Record measured decode/chat throughput from a completed generation turn.
166///
167/// Call only with real token counts and wall time — never synthetic marketing numbers.
168pub fn record_last_decode_tok_s(tokens: u32, duration_ms: u64) {
169    if tokens == 0 || duration_ms == 0 {
170        return;
171    }
172    let tok_s = (tokens as f64) / (duration_ms as f64 / 1000.0);
173    if !tok_s.is_finite() || tok_s <= 0.0 {
174        return;
175    }
176    let milli = (tok_s * 1000.0).round().clamp(0.0, u32::MAX as f64) as u32;
177    LAST_DECODE_TOK_S_MILLI.store(milli, Ordering::Relaxed);
178    let now = std::time::SystemTime::now()
179        .duration_since(std::time::UNIX_EPOCH)
180        .map(|d| d.as_secs())
181        .unwrap_or(0);
182    LAST_DECODE_TOK_S_AT_SECS.store(now, Ordering::Relaxed);
183}
184
185/// Last measured tok/s from a completed turn, or `None` if never measured this process.
186pub fn get_last_decode_tok_s() -> Option<f64> {
187    let milli = LAST_DECODE_TOK_S_MILLI.load(Ordering::Relaxed);
188    if milli == 0 {
189        None
190    } else {
191        Some(milli as f64 / 1000.0)
192    }
193}
194
195pub fn get_last_decode_tok_s_at_unix() -> u64 {
196    LAST_DECODE_TOK_S_AT_SECS.load(Ordering::Relaxed)
197}
198
199pub fn get_thermal_state_label() -> &'static str {
200    orchestrator().thermal_state_label()
201}
202
203pub fn models_dir(storage_root: &Path) -> PathBuf {
204    storage_root.join("Models")
205}
206
207pub fn lifecycle_label(state: ModelLifecycle) -> &'static str {
208    match state {
209        ModelLifecycle::Discovered => "Discovered",
210        ModelLifecycle::MappedToDisk => "MappedToDisk",
211        ModelLifecycle::StreamingVRAM => "StreamingVRAM",
212        ModelLifecycle::Active => "Active",
213        ModelLifecycle::Scrubbing => "Scrubbing",
214    }
215}
216
217fn probe_and_activate_model(
218    agent: &LocalLlmAgent,
219    profile_id: u64,
220    model_label: &str,
221    gguf_path: &str,
222) -> Result<(), ModelError> {
223    let orch = orchestrator();
224    if let Some(resident) = orch.resident_model_id() {
225        if resident != profile_id {
226            log::info!(
227                "LLM_LOAD|unload-start|0.01|Evicting resident model 0x{resident:016x} before loading {}",
228                model_label
229            );
230            orch.evict_model(resident);
231            let wait_started = std::time::Instant::now();
232            while orch.scrubbing_lock.load(Ordering::Acquire) {
233                if wait_started.elapsed() > std::time::Duration::from_secs(5) {
234                    log::error!("LLM_LOAD|failed|1.00|Timed out waiting for prior model eviction");
235                    return Err(ModelError::Activate(
236                        "Timed out waiting for prior model eviction".to_string(),
237                    ));
238                }
239                std::thread::sleep(std::time::Duration::from_millis(5));
240            }
241            record_llm_memory_bytes(0);
242            record_kv_cache_used_mb(0);
243            log::info!("LLM_LOAD|unload-done|0.03|Previous resident model scrubbed from memory");
244        }
245    }
246    let mut sys = sysinfo::System::new_all();
247    sys.refresh_memory();
248    let ram_total_gib = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
249    let ram_used_gib = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
250    let ram_free_gib = (ram_total_gib - ram_used_gib).max(0.0);
251    {
252        let mut state = orch.current_model_state.lock().unwrap();
253        *state = ModelLifecycle::MappedToDisk;
254        *state = ModelLifecycle::StreamingVRAM;
255    }
256    log::info!("LLM_LOAD|prepare|0.02|Preparing model {}", model_label);
257    log::info!(
258        "LLM_LOAD|ram-check|0.04|System RAM {:.1}/{:.1} GiB used; {:.1} GiB available",
259        ram_used_gib,
260        ram_total_gib,
261        ram_free_gib
262    );
263    match qualia_core_db::resident_model::mount_resident_gguf(
264        profile_id,
265        gguf_path,
266        orch.mlock_enabled
267            .load(std::sync::atomic::Ordering::Relaxed),
268    ) {
269        Ok(report) => {
270            let kv_cache_mb = (report.kv_cache_bytes / (1024 * 1024)).min(u32::MAX as u64) as u32;
271            record_llm_memory_bytes(report.mapped_bytes);
272            record_kv_cache_used_mb(kv_cache_mb);
273            if let Err(err) = orch.load_model(agent, profile_id) {
274                log::error!("LLM_LOAD|failed|1.00|Activation failed: {}", err);
275                return Err(ModelError::Activate(err.to_string()));
276            }
277            orch.register_resident_model(profile_id, report.mapped_bytes + report.kv_cache_bytes);
278            log::info!(
279                "LLM_LOAD|placement|0.96|Model mapped in system RAM ({:.2} GiB) and KV cache reserved in VRAM/system memory ({} MiB)",
280                report.mapped_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
281                kv_cache_mb
282            );
283            log::info!(
284                "LLM_LOAD|active|1.00|Model ready (mapped {:.2} GiB, kv {} MiB, backend {})",
285                report.mapped_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
286                kv_cache_mb,
287                if report.directml_enabled {
288                    "DirectML+wgpu"
289                } else {
290                    "wgpu"
291                }
292            );
293        }
294        Err(err) => {
295            record_llm_memory_bytes(0);
296            record_kv_cache_used_mb(0);
297            let mut state = orch.current_model_state.lock().unwrap();
298            *state = ModelLifecycle::MappedToDisk;
299            log::error!("LLM_LOAD|failed|1.00|Activation failed: {}", err);
300            return Err(ModelError::Activate(err));
301        }
302    }
303    Ok(())
304}
305
306pub fn unload_active_model(profile_id: Option<u64>) {
307    let orch = orchestrator();
308    let resident = profile_id.or_else(|| orch.resident_model_id());
309    if let Some(model_id) = resident {
310        log::info!("LLM_LOAD|unload-start|0.00|Unloading resident model 0x{model_id:016x}");
311        orch.evict_model(model_id);
312        let wait_started = std::time::Instant::now();
313        while orch.scrubbing_lock.load(Ordering::Acquire) {
314            if wait_started.elapsed() > std::time::Duration::from_secs(5) {
315                log::warn!("LLM_LOAD|failed|1.00|Timed out waiting for model scrub");
316                break;
317            }
318            std::thread::sleep(std::time::Duration::from_millis(5));
319        }
320        log::info!("LLM_LOAD|unload-done|1.00|Model memory scrub complete");
321    }
322    record_llm_memory_bytes(0);
323    record_kv_cache_used_mb(0);
324}
325
326fn unix_now() -> u64 {
327    std::time::SystemTime::now()
328        .duration_since(std::time::UNIX_EPOCH)
329        .unwrap_or_default()
330        .as_secs()
331}
332
333fn llm_filename(model: &LLMResource) -> String {
334    model
335        .download
336        .local_filename()
337        .unwrap_or_else(|| format!("{}.gguf", model.id))
338}
339
340fn install_manifest_path(models_dir: &Path, model_id: &str) -> PathBuf {
341    models_dir.join(format!("{model_id}.install.json"))
342}
343
344fn projector_filename(model: &LLMResource) -> Option<String> {
345    model.vision_projector.as_ref().and_then(|d| {
346        d.local_filename().or_else(|| {
347            d.resolved_url()
348                .and_then(|u| u.rsplit('/').next().map(|s| s.to_string()))
349        })
350    })
351}
352
353fn write_install_manifest(
354    models_dir: &Path,
355    model: &LLMResource,
356    gguf_path: &Path,
357    mmproj_path: Option<&Path>,
358    profile_id: u64,
359    pointer_quin_count: usize,
360    vision_pointer_quin_count: usize,
361    wal_path: &Path,
362) -> Result<(), ModelError> {
363    let modality = if model.is_multimodal() {
364        "multimodal".to_string()
365    } else {
366        "text".to_string()
367    };
368    let manifest = InstallManifest {
369        model_id: model.id.clone(),
370        gguf_path: gguf_path.to_string_lossy().into_owned(),
371        profile_id,
372        quantization: model
373            .quantization
374            .clone()
375            .unwrap_or_else(|| "Q4_K_M".to_string()),
376        pointer_quin_count,
377        vision_pointer_quin_count,
378        installed_at: unix_now(),
379        wal_path: wal_path.to_string_lossy().into_owned(),
380        modality: modality.clone(),
381        architecture: model.architecture.clone(),
382        mmproj_path: mmproj_path.map(|p| p.to_string_lossy().into_owned()),
383        context_window: model.effective_context_window(),
384    };
385    let json = serde_json::to_string_pretty(&manifest)?;
386    std::fs::write(install_manifest_path(models_dir, &model.id), json)?;
387    Ok(())
388}
389
390pub fn finalize_llm_install(
391    model: &LLMResource,
392    gguf_path: &Path,
393    mmproj_path: Option<&Path>,
394    storage_root: &Path,
395) -> Result<ModelInstallResult, ModelError> {
396    let models = models_dir(storage_root);
397    std::fs::create_dir_all(&models)?;
398
399    if !gguf_path.is_file() {
400        return Err(ModelError::Io(std::io::Error::new(
401            std::io::ErrorKind::NotFound,
402            format!("GGUF not found: {}", gguf_path.display()),
403        )));
404    }
405
406    if model.is_multimodal() && mmproj_path.is_none() {
407        return Err(ModelError::Shard(
408            "Multimodal model requires vision projector (mmproj) GGUF".to_string(),
409        ));
410    }
411
412    let path_str = gguf_path.to_string_lossy().into_owned();
413    let sharder = GGufSharder::new(path_str.clone());
414    let mut pointer_quins = sharder.generate_bidx_pointer_map();
415    let mut vision_pointer_quin_count = 0usize;
416
417    if let Some(mmproj) = mmproj_path {
418        if !mmproj.is_file() {
419            return Err(ModelError::Io(std::io::Error::new(
420                std::io::ErrorKind::NotFound,
421                format!("mmproj not found: {}", mmproj.display()),
422            )));
423        }
424        let mmproj_str = mmproj.to_string_lossy().into_owned();
425        let vision_sharder = GGufSharder::new(mmproj_str);
426        let vision_quins = vision_sharder.generate_bidx_pointer_map();
427        vision_pointer_quin_count = vision_quins.len();
428        pointer_quins.extend(vision_quins);
429    }
430
431    let wal_path = models.join("models.wal");
432    let mut wal = WriteAheadLog::open(&wal_path)
433        .map_err(|e| ModelError::Wal(format!("Cannot open {}: {}", wal_path.display(), e)))?;
434
435    let timestamp = unix_now();
436    let prov = model.provenance_quin(timestamp, &path_str);
437    wal.append_mutation(&prov)
438        .map_err(|e| ModelError::Wal(e.to_string()))?;
439
440    if let Some(src_quin) = model.source_url_quin() {
441        wal.append_mutation(&src_quin)
442            .map_err(|e| ModelError::Wal(e.to_string()))?;
443    }
444
445    for q in &pointer_quins {
446        wal.append_mutation(q)
447            .map_err(|e| ModelError::Wal(e.to_string()))?;
448    }
449
450    for q in &model.to_quins() {
451        wal.append_mutation(q)
452            .map_err(|e| ModelError::Wal(e.to_string()))?;
453    }
454
455    let mmproj_str = mmproj_path.map(|p| p.to_string_lossy().into_owned());
456    let profile = model.to_capability_profile_with_projector(&path_str, mmproj_str.as_deref());
457    write_install_manifest(
458        &models,
459        model,
460        gguf_path,
461        mmproj_path,
462        profile.profile_id,
463        pointer_quins
464            .len()
465            .saturating_sub(vision_pointer_quin_count),
466        vision_pointer_quin_count,
467        &wal_path,
468    )?;
469
470    {
471        let orch = orchestrator();
472        let mut state = orch.current_model_state.lock().unwrap();
473        *state = ModelLifecycle::MappedToDisk;
474    }
475
476    let modality = if model.is_multimodal() {
477        "multimodal".to_string()
478    } else {
479        "text".to_string()
480    };
481
482    Ok(ModelInstallResult {
483        model_id: model.id.clone(),
484        gguf_path: path_str,
485        profile_id: profile.profile_id,
486        pointer_quin_count: pointer_quins
487            .len()
488            .saturating_sub(vision_pointer_quin_count),
489        vision_pointer_quin_count,
490        lifecycle_state: lifecycle_label(ModelLifecycle::MappedToDisk).to_string(),
491        wal_path: wal_path.to_string_lossy().into_owned(),
492        modality,
493        mmproj_path: mmproj_str,
494    })
495}
496
497pub async fn install_catalog_llm(
498    catalog: &ResourceCatalog,
499    id: &str,
500    storage_root: &Path,
501) -> Result<ModelInstallResult, ModelError> {
502    let model = catalog
503        .find_llm(id)
504        .ok_or_else(|| ModelError::NotFound(id.to_string()))?;
505
506    let url = model
507        .download
508        .resolved_url()
509        .ok_or_else(|| ModelError::NoDownloadUrl(id.to_string()))?;
510
511    let models = models_dir(storage_root);
512    std::fs::create_dir_all(&models)?;
513
514    let filename = llm_filename(model);
515    let local_path = models.join(&filename);
516
517    crate::resource_import::stream_download(&url, &local_path)
518        .await
519        .map_err(ModelError::Download)?;
520
521    let mmproj_path = if let (Some(ref vp), Some(vp_url)) = (
522        model.vision_projector.as_ref(),
523        model
524            .vision_projector
525            .as_ref()
526            .and_then(|d| d.resolved_url()),
527    ) {
528        let vp_name = projector_filename(model).unwrap_or_else(|| "mmproj.gguf".to_string());
529        let vp_path = models.join(&vp_name);
530        crate::resource_import::stream_download(&vp_url, &vp_path)
531            .await
532            .map_err(ModelError::Download)?;
533        let _ = vp;
534        Some(vp_path)
535    } else {
536        None
537    };
538
539    finalize_llm_install(model, &local_path, mmproj_path.as_deref(), storage_root)
540}
541
542pub fn load_install_manifest(storage_root: &Path, model_id: &str) -> Option<InstallManifest> {
543    let path = install_manifest_path(&models_dir(storage_root), model_id);
544    let text = std::fs::read_to_string(path).ok()?;
545    serde_json::from_str(&text).ok()
546}
547
548fn sanitize_local_model_id(stem: &str) -> String {
549    let cleaned: String = stem
550        .chars()
551        .map(|c| {
552            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
553                c
554            } else {
555                '_'
556            }
557        })
558        .collect();
559    if cleaned.is_empty() {
560        "local-model".to_string()
561    } else {
562        cleaned
563    }
564}
565
566/// Discovered weight container in a vault directory (CLI / lifecycle tooling).
567///
568/// Name retained for API stability; entries may be `.gguf` **or** preferred `.p64`.
569#[derive(Debug, Clone, Serialize)]
570pub struct VaultGgufEntry {
571    pub name: String,
572    pub path: String,
573    pub profile_id: u64,
574    pub size_bytes: u64,
575    /// `p64` | `gguf` — engine prefers `p64` when both exist for the same stem.
576    pub container: String,
577}
578
579/// Exact duplicate GGUF files, grouped by content rather than filename.
580///
581/// The audit is deliberately read-only: lifecycle code may report redundant files,
582/// but never copies, moves, or deletes a model behind the operator's back.
583#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
584pub struct VaultDuplicateGroup {
585    pub sha256: String,
586    pub size_bytes: u64,
587    pub canonical_path: String,
588    pub duplicate_paths: Vec<String>,
589}
590
591impl VaultDuplicateGroup {
592    pub fn reclaimable_bytes(&self) -> u64 {
593        self.size_bytes
594            .saturating_mul(self.duplicate_paths.len() as u64)
595    }
596}
597
598/// Recursively scan `vault_dir` for `.p64` and `.gguf` weight containers.
599///
600/// When both exist for the same stem (e.g. `foo.p64` + `foo.gguf`), **only the
601/// `.p64` is listed** — GGUF is import-only once converted. `.f16.p64` keeps a
602/// distinct stem and is listed separately.
603pub fn scan_vault_gguf(vault_dir: &Path) -> Result<Vec<VaultGgufEntry>, std::io::Error> {
604    if !vault_dir.is_dir() {
605        return Err(std::io::Error::new(
606            std::io::ErrorKind::NotFound,
607            format!("Vault directory not found: {}", vault_dir.display()),
608        ));
609    }
610    let mut raw = Vec::new();
611    collect_vault_models(vault_dir, &mut raw)?;
612    // Prefer p64: drop gguf when a p64 with the same base stem exists.
613    let p64_stems: std::collections::HashSet<String> = raw
614        .iter()
615        .filter(|e| e.container == "p64")
616        .map(|e| vault_stem_key(&e.path))
617        .collect();
618    let mut out: Vec<VaultGgufEntry> = raw
619        .into_iter()
620        .filter(|e| e.container == "p64" || !p64_stems.contains(&vault_stem_key(&e.path)))
621        .collect();
622    out.sort_by(|a, b| a.name.cmp(&b.name));
623    Ok(out)
624}
625
626/// Stem key for prefer-p64 dedup: `foo.gguf` / `foo.p64` → `foo`; `foo.f16.p64` → `foo.f16`.
627fn vault_stem_key(path: &str) -> String {
628    Path::new(path)
629        .file_stem()
630        .and_then(|s| s.to_str())
631        .unwrap_or("")
632        .to_string()
633}
634
635/// Hash only same-sized GGUF candidates and report byte-identical duplicates.
636///
637/// Size bucketing avoids reading every multi-gigabyte model during a routine audit.
638/// Within each exact-content group the shortest, then lexicographically first, path
639/// is suggested as the canonical keeper. No filesystem mutation is performed.
640pub fn audit_vault_duplicates(
641    vault_dir: &Path,
642) -> Result<Vec<VaultDuplicateGroup>, std::io::Error> {
643    use std::collections::BTreeMap;
644    use std::io::Read;
645
646    let entries = scan_vault_gguf(vault_dir)?;
647    let mut by_size: BTreeMap<u64, Vec<&VaultGgufEntry>> = BTreeMap::new();
648    for entry in &entries {
649        by_size.entry(entry.size_bytes).or_default().push(entry);
650    }
651
652    let mut groups = Vec::new();
653    for (size_bytes, candidates) in by_size {
654        if candidates.len() < 2 {
655            continue;
656        }
657        let mut by_digest: BTreeMap<String, Vec<String>> = BTreeMap::new();
658        for candidate in candidates {
659            let mut file = std::fs::File::open(&candidate.path)?;
660            let mut hasher = Sha256::new();
661            // Keep the cold-path hash buffer modest enough for Windows' default
662            // main-thread stack while still reading in efficient chunks.
663            let mut buffer = [0u8; 64 * 1024];
664            loop {
665                let read = file.read(&mut buffer)?;
666                if read == 0 {
667                    break;
668                }
669                hasher.update(&buffer[..read]);
670            }
671            let digest = hasher.finalize();
672            let mut digest_hex = String::with_capacity(digest.len() * 2);
673            for byte in digest {
674                use std::fmt::Write;
675                let _ = write!(digest_hex, "{byte:02x}");
676            }
677            by_digest
678                .entry(digest_hex)
679                .or_default()
680                .push(candidate.path.clone());
681        }
682
683        for (sha256, mut paths) in by_digest {
684            if paths.len() < 2 {
685                continue;
686            }
687            paths.sort_by(|a, b| {
688                let a_depth = Path::new(a).components().count();
689                let b_depth = Path::new(b).components().count();
690                a_depth.cmp(&b_depth).then_with(|| a.cmp(b))
691            });
692            let canonical_path = paths.remove(0);
693            groups.push(VaultDuplicateGroup {
694                sha256,
695                size_bytes,
696                canonical_path,
697                duplicate_paths: paths,
698            });
699        }
700    }
701    groups.sort_by(|a, b| a.canonical_path.cmp(&b.canonical_path));
702    Ok(groups)
703}
704
705fn collect_vault_models(dir: &Path, out: &mut Vec<VaultGgufEntry>) -> Result<(), std::io::Error> {
706    for entry in std::fs::read_dir(dir)? {
707        let entry = entry?;
708        let path = entry.path();
709        if path.is_dir() {
710            collect_vault_models(&path, out)?;
711            continue;
712        }
713        let ext = path
714            .extension()
715            .and_then(|e| e.to_str())
716            .unwrap_or("")
717            .to_ascii_lowercase();
718        let container = match ext.as_str() {
719            "p64" => "p64",
720            "gguf" => "gguf",
721            _ => continue,
722        };
723        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("model");
724        let model_id = sanitize_local_model_id(stem);
725        let size_bytes = entry.metadata()?.len();
726        out.push(VaultGgufEntry {
727            name: path
728                .file_name()
729                .map(|n| n.to_string_lossy().into_owned())
730                .unwrap_or_else(|| model_id.clone()),
731            path: path.to_string_lossy().into_owned(),
732            profile_id: q_hash(&format!("profile:local:{model_id}")),
733            size_bytes,
734            container: container.into(),
735        });
736    }
737    Ok(())
738}
739
740/// Resolve a model reference (filename, stem, or path) inside `vault_dir`.
741///
742/// Preference order: exact path → `.p64` → `.gguf` → vault scan (p64 preferred).
743pub fn resolve_vault_model(vault_dir: &Path, model_ref: &str) -> Result<PathBuf, ModelError> {
744    let direct = PathBuf::from(model_ref);
745    if direct.is_file() {
746        // If operator points at a GGUF that has a converted sibling .p64, prefer p64.
747        if let Some(p64) = prefer_p64_sibling(&direct) {
748            return Ok(p64);
749        }
750        return Ok(direct);
751    }
752    let in_vault = vault_dir.join(model_ref);
753    if in_vault.is_file() {
754        if let Some(p64) = prefer_p64_sibling(&in_vault) {
755            return Ok(p64);
756        }
757        return Ok(in_vault);
758    }
759    // Prefer p64 extension first (designed runtime format).
760    if !model_ref.ends_with(".p64") && !model_ref.ends_with(".gguf") {
761        let with_p64 = vault_dir.join(format!("{model_ref}.p64"));
762        if with_p64.is_file() {
763            return Ok(with_p64);
764        }
765        let with_gguf = vault_dir.join(format!("{model_ref}.gguf"));
766        if with_gguf.is_file() {
767            if let Some(p64) = prefer_p64_sibling(&with_gguf) {
768                return Ok(p64);
769            }
770            return Ok(with_gguf);
771        }
772    }
773    for entry in scan_vault_gguf(vault_dir).map_err(ModelError::Io)? {
774        let stem = PathBuf::from(&entry.path)
775            .file_stem()
776            .and_then(|s| s.to_str())
777            .unwrap_or("")
778            .to_string();
779        if entry.name == model_ref
780            || stem == model_ref
781            || format!("0x{:016x}", entry.profile_id) == model_ref.to_lowercase()
782        {
783            return Ok(PathBuf::from(entry.path));
784        }
785    }
786    Err(ModelError::NotFound(format!(
787        "No p64/GGUF matching `{model_ref}` under {}",
788        vault_dir.display()
789    )))
790}
791
792/// If `path` is a `.gguf` and `{stem}.p64` exists beside it, return the p64.
793fn prefer_p64_sibling(path: &Path) -> Option<PathBuf> {
794    let is_gguf = path
795        .extension()
796        .and_then(|e| e.to_str())
797        .map(|e| e.eq_ignore_ascii_case("gguf"))
798        .unwrap_or(false);
799    if !is_gguf {
800        return None;
801    }
802    let p64 = path.with_extension("p64");
803    if p64.is_file() {
804        Some(p64)
805    } else {
806        None
807    }
808}
809
810/// Map and activate a vault weight file (`.p64` preferred, or `.gguf`) without catalog install.
811pub async fn activate_vault_gguf(gguf_path: &Path) -> Result<ActiveModelRecord, ModelError> {
812    let path = prefer_p64_sibling(gguf_path).unwrap_or_else(|| gguf_path.to_path_buf());
813    if !path.is_file() {
814        return Err(ModelError::Io(std::io::Error::new(
815            std::io::ErrorKind::NotFound,
816            format!("Model not found: {}", path.display()),
817        )));
818    }
819    let stem = path
820        .file_stem()
821        .and_then(|s| s.to_str())
822        .unwrap_or("local-model");
823    let model_id = sanitize_local_model_id(stem);
824    let path_str = path.to_string_lossy().into_owned();
825    let profile_id = q_hash(&format!("profile:local:{model_id}"));
826    let quant = if path
827        .extension()
828        .and_then(|e| e.to_str())
829        .map(|e| e.eq_ignore_ascii_case("p64"))
830        .unwrap_or(false)
831    {
832        "p64"
833    } else {
834        "vault"
835    };
836
837    let agent = LocalLlmAgent::with_local_backend(
838        format!("did:qualia:cli-vault:{profile_id}"),
839        AgentBackend::Local {
840            model_path: path_str.clone(),
841            context_window: 4096,
842            quantization: quant.to_string(),
843            vision_projector_path: None,
844            modality: "text".to_string(),
845            architecture: None,
846        },
847    );
848    probe_and_activate_model(&agent, profile_id, &model_id, &path_str)?;
849
850    let lifecycle = *orchestrator().current_model_state.lock().unwrap();
851    Ok(ActiveModelRecord {
852        model_id,
853        gguf_path: path_str,
854        profile_id,
855        quantization: quant.to_string(),
856        lifecycle_state: lifecycle_label(lifecycle).to_string(),
857        modality: "text".to_string(),
858        architecture: None,
859        mmproj_path: None,
860        context_window: 4096,
861    })
862}
863
864/// Block until eviction scrub completes or `timeout` elapses.
865pub fn wait_for_eviction_scrub(timeout: std::time::Duration) -> bool {
866    let orch = orchestrator();
867    let started = std::time::Instant::now();
868    while orch.scrubbing_lock.load(Ordering::Acquire) {
869        if started.elapsed() > timeout {
870            return false;
871        }
872        std::thread::sleep(std::time::Duration::from_millis(5));
873    }
874    true
875}
876
877/// Register and activate a user-selected `.gguf` file at an arbitrary path.
878///
879/// Writes `{model_id}.install.json` under `{storage_root}/Models/` (path is not copied),
880/// maps GGUF tensor pointers into the WAL, and transitions lifecycle → `Active`.
881pub fn finalize_local_gguf(
882    gguf_path: &Path,
883    storage_root: &Path,
884) -> Result<ActiveModelRecord, ModelError> {
885    if !gguf_path.is_file() {
886        return Err(ModelError::Io(std::io::Error::new(
887            std::io::ErrorKind::NotFound,
888            format!("GGUF not found: {}", gguf_path.display()),
889        )));
890    }
891
892    let models = models_dir(storage_root);
893    std::fs::create_dir_all(&models)?;
894
895    let stem = gguf_path
896        .file_stem()
897        .and_then(|s| s.to_str())
898        .unwrap_or("local-model");
899    let model_id = sanitize_local_model_id(stem);
900    let path_str = gguf_path.to_string_lossy().into_owned();
901    let profile_id = q_hash(&format!("profile:local:{model_id}"));
902
903    let sharder = GGufSharder::new(path_str.clone());
904    let pointer_quins = sharder.generate_bidx_pointer_map();
905    let pointer_quin_count = pointer_quins.len();
906
907    let wal_path = models.join("models.wal");
908    let mut wal = WriteAheadLog::open(&wal_path)
909        .map_err(|e| ModelError::Wal(format!("Cannot open {}: {}", wal_path.display(), e)))?;
910
911    let subject = q_hash(&format!("local-gguf:{model_id}"));
912    let predicate = q_hash("prov:wasDerivedFrom");
913    let object = q_hash(&path_str);
914    let context = q_hash("ctx:local-gguf");
915    let prov = qualia_core_db::NQuin {
916        subject,
917        predicate,
918        object,
919        context,
920        metadata: unix_now(),
921        parity: subject ^ predicate ^ object ^ context,
922    };
923    wal.append_mutation(&prov)
924        .map_err(|e| ModelError::Wal(e.to_string()))?;
925
926    for quin in pointer_quins {
927        wal.append_mutation(&quin)
928            .map_err(|e| ModelError::Wal(e.to_string()))?;
929    }
930
931    let manifest = InstallManifest {
932        model_id: model_id.clone(),
933        gguf_path: path_str.clone(),
934        profile_id,
935        quantization: "local".to_string(),
936        pointer_quin_count,
937        vision_pointer_quin_count: 0,
938        installed_at: unix_now(),
939        wal_path: wal_path.to_string_lossy().into_owned(),
940        modality: "text".to_string(),
941        architecture: None,
942        mmproj_path: None,
943        context_window: 4096,
944    };
945    let json = serde_json::to_string_pretty(&manifest)?;
946    std::fs::write(install_manifest_path(&models, &model_id), json)?;
947
948    let agent = LocalLlmAgent::with_local_backend(
949        format!("did:qualia:local-gguf:{profile_id}"),
950        AgentBackend::Local {
951            model_path: path_str.clone(),
952            context_window: 4096,
953            quantization: "local".to_string(),
954            vision_projector_path: None,
955            modality: "text".to_string(),
956            architecture: None,
957        },
958    );
959    probe_and_activate_model(&agent, profile_id, &model_id, &path_str)?;
960
961    let lifecycle = *orchestrator().current_model_state.lock().unwrap();
962    Ok(ActiveModelRecord {
963        model_id,
964        gguf_path: path_str,
965        profile_id,
966        quantization: "local".to_string(),
967        lifecycle_state: lifecycle_label(lifecycle).to_string(),
968        modality: "text".to_string(),
969        architecture: None,
970        mmproj_path: None,
971        context_window: 4096,
972    })
973}
974
975pub fn activate_model_for_id(
976    model_id: &str,
977    storage_root: &Path,
978) -> Result<ActiveModelRecord, ModelError> {
979    let manifest = load_install_manifest(storage_root, model_id).ok_or_else(|| {
980        ModelError::Activate(format!(
981            "No install manifest for `{model_id}` — download the model in LLM Hub first"
982        ))
983    })?;
984
985    if !Path::new(&manifest.gguf_path).is_file() {
986        return Err(ModelError::Activate(format!(
987            "GGUF missing at {}",
988            manifest.gguf_path
989        )));
990    }
991
992    if manifest.modality == "multimodal" {
993        let mmproj = manifest.mmproj_path.as_deref().ok_or_else(|| {
994            ModelError::Activate("Multimodal manifest missing mmproj_path".to_string())
995        })?;
996        if !Path::new(mmproj).is_file() {
997            return Err(ModelError::Activate(format!(
998                "Vision projector missing at {mmproj}"
999            )));
1000        }
1001    }
1002
1003    let agent = LocalLlmAgent::with_local_backend(
1004        format!("did:qualia:profile:{}", manifest.profile_id),
1005        AgentBackend::Local {
1006            model_path: manifest.gguf_path.clone(),
1007            context_window: manifest.context_window,
1008            quantization: manifest.quantization.clone(),
1009            vision_projector_path: manifest.mmproj_path.clone(),
1010            modality: manifest.modality.clone(),
1011            architecture: manifest.architecture.clone(),
1012        },
1013    );
1014    probe_and_activate_model(
1015        &agent,
1016        manifest.profile_id,
1017        &manifest.model_id,
1018        &manifest.gguf_path,
1019    )?;
1020
1021    let lifecycle = *orchestrator().current_model_state.lock().unwrap();
1022    let record = ActiveModelRecord {
1023        model_id: manifest.model_id,
1024        gguf_path: manifest.gguf_path,
1025        profile_id: manifest.profile_id,
1026        quantization: manifest.quantization,
1027        lifecycle_state: lifecycle_label(lifecycle).to_string(),
1028        modality: manifest.modality,
1029        architecture: manifest.architecture,
1030        mmproj_path: manifest.mmproj_path,
1031        context_window: manifest.context_window,
1032    };
1033
1034    Ok(record)
1035}
1036
1037pub fn activate_model(
1038    profile_id: u64,
1039    storage_root: &Path,
1040) -> Result<ActiveModelRecord, ModelError> {
1041    let models = models_dir(storage_root);
1042    let entries = std::fs::read_dir(&models).map_err(ModelError::Io)?;
1043    for entry in entries.filter_map(Result::ok) {
1044        let path = entry.path();
1045        if path.extension().and_then(|e| e.to_str()) != Some("json") {
1046            continue;
1047        }
1048        if !path
1049            .file_name()
1050            .map(|n| n.to_string_lossy().contains(".install.json"))
1051            .unwrap_or(false)
1052        {
1053            continue;
1054        }
1055        if let Ok(text) = std::fs::read_to_string(&path) {
1056            if let Ok(manifest) = serde_json::from_str::<InstallManifest>(&text) {
1057                if manifest.profile_id == profile_id {
1058                    return activate_model_for_id(&manifest.model_id, storage_root);
1059                }
1060            }
1061        }
1062    }
1063    Err(ModelError::Activate(format!(
1064        "No installed model with profile_id 0x{profile_id:016x}"
1065    )))
1066}
1067
1068pub fn get_model_lifecycle_state() -> ModelLifecycle {
1069    *orchestrator().current_model_state.lock().unwrap()
1070}
1071
1072pub fn get_model_status(active: Option<ActiveModelRecord>) -> ModelStatus {
1073    let lifecycle = get_model_lifecycle_state();
1074    ModelStatus {
1075        profile_id: active.as_ref().map(|r| r.profile_id),
1076        active,
1077        lifecycle_state: lifecycle_label(lifecycle).to_string(),
1078    }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084
1085    #[test]
1086    fn duplicate_audit_groups_only_identical_gguf_files() {
1087        let root = std::env::temp_dir().join(format!(
1088            "qualia-gguf-audit-{}-{}",
1089            std::process::id(),
1090            unix_now()
1091        ));
1092        let nested = root.join("nested");
1093        std::fs::create_dir_all(&nested).unwrap();
1094        std::fs::write(root.join("keeper.gguf"), b"same model bytes").unwrap();
1095        std::fs::write(nested.join("copy.gguf"), b"same model bytes").unwrap();
1096        std::fs::write(root.join("same-size-not-copy.gguf"), b"other model byte").unwrap();
1097
1098        let groups = audit_vault_duplicates(&root).unwrap();
1099        assert_eq!(groups.len(), 1);
1100        assert_eq!(groups[0].size_bytes, 16);
1101        assert!(groups[0].canonical_path.ends_with("keeper.gguf"));
1102        assert_eq!(groups[0].duplicate_paths.len(), 1);
1103        assert!(groups[0].duplicate_paths[0].ends_with("copy.gguf"));
1104        assert_eq!(groups[0].reclaimable_bytes(), 16);
1105
1106        std::fs::remove_dir_all(root).unwrap();
1107    }
1108
1109    #[test]
1110    fn vault_scan_prefers_p64_over_gguf_same_stem() {
1111        let root = std::env::temp_dir().join(format!(
1112            "qualia-vault-p64-{}-{}",
1113            std::process::id(),
1114            unix_now()
1115        ));
1116        std::fs::create_dir_all(&root).unwrap();
1117        std::fs::write(root.join("smollm.gguf"), b"gguf-bytes").unwrap();
1118        std::fs::write(root.join("smollm.p64"), b"p64\0bytes").unwrap();
1119        std::fs::write(root.join("other.gguf"), b"only-gguf").unwrap();
1120
1121        let entries = scan_vault_gguf(&root).unwrap();
1122        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1123        assert!(
1124            names.iter().any(|n| *n == "smollm.p64"),
1125            "p64 should be listed: {names:?}"
1126        );
1127        assert!(
1128            !names.iter().any(|n| *n == "smollm.gguf"),
1129            "gguf should be hidden when p64 exists: {names:?}"
1130        );
1131        assert!(names.iter().any(|n| *n == "other.gguf"));
1132
1133        let resolved = resolve_vault_model(&root, "smollm").unwrap();
1134        assert!(
1135            resolved.extension().and_then(|e| e.to_str()) == Some("p64"),
1136            "resolve stem should pick p64: {}",
1137            resolved.display()
1138        );
1139        let via_gguf = resolve_vault_model(&root, "smollm.gguf").unwrap();
1140        assert!(
1141            via_gguf.extension().and_then(|e| e.to_str()) == Some("p64"),
1142            "resolve gguf name should still prefer sibling p64: {}",
1143            via_gguf.display()
1144        );
1145
1146        std::fs::remove_dir_all(root).unwrap();
1147    }
1148}