1use 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
114pub 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
121pub const MEMORY_FLOOR_MB: u32 = 512;
123
124static LLM_MEMORY_BYTES: AtomicU64 = AtomicU64::new(0);
125static KV_CACHE_USED_MB: AtomicU32 = AtomicU32::new(0);
126static LAST_DECODE_TOK_S_MILLI: AtomicU32 = AtomicU32::new(0);
128static 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
165pub 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
185pub 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#[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 pub container: String,
577}
578
579#[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
598pub 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 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
626fn 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
635pub 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 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
740pub 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 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 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
792fn 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
810pub 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
864pub 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
877pub 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}