Skip to main content

qualia_core_db/extensions/
resource_catalog.rs

1//! Resource Catalog — canonical types, Quin encoding, and CapabilityProfile binding.
2//!
3//! This module defines the authoritative Rust types for the YAML-backed resource
4//! catalog (`resources/*.yaml`). It is the single source of truth — the Flutter
5//! bridge and CLI both reference these types, not their own copies.
6//!
7//! # Quin encoding
8//!
9//! Each catalog entry can be encoded as a set of `NQuin`s for first-class
10//! graph membership. Predicate convention:
11//!
12//! | Predicate string         | Meaning                        |
13//! |--------------------------|--------------------------------|
14//! | `llm:hasFormat`          | Model file format (e.g. `gguf`)|
15//! | `llm:hasQuantization`    | Quantization level             |
16//! | `llm:hasSizeMb`          | File size (inline integer)     |
17//! | `llm:hasRamEstimateMb`   | Peak RAM needed (inline int)   |
18//! | `llm:hasSourceRepo`      | Source repo hash               |
19//! | `llm:hasLicense`         | License identifier hash        |
20//! | `ont:hasFormat`          | Ontology serialisation format  |
21//! | `ont:hasDomain`          | Domain tag hash                |
22//! | `prov:wasGeneratedBy`    | Provenance: download event     |
23//! | `prov:atPath`            | Local file path hash           |
24//! | `prov:atTimestamp`       | Unix timestamp (inline int)    |
25
26use crate::llm_agent::AgentBackend;
27use crate::profiles::CapabilityProfile;
28use crate::{q_hash, NQuin};
29use serde::{Deserialize, Serialize};
30
31// ─── Inline type tag for object field (resolver.rs convention) ───────────────
32const INLINE_TAG_INTEGER: u64 = 0x1u64 << 60;
33
34// ─── Catalog context hashes (compile-time) ───────────────────────────────────
35const CTX_LLM: u64 = q_hash("catalog:llm");
36const CTX_ONT: u64 = q_hash("catalog:ontology");
37const CTX_PROV: u64 = q_hash("catalog:provenance");
38
39// ─── Download info ────────────────────────────────────────────────────────────
40
41/// Where and how to fetch a resource.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DownloadInfo {
44    /// Source type: `"huggingface"`, `"direct"`, `"github"`.
45    #[serde(rename = "type")]
46    pub download_type: String,
47    /// HuggingFace repo slug (e.g. `"unsloth/Phi-3-mini-4k-instruct-GGUF"`).
48    pub repo: Option<String>,
49    /// Filename within the repo (e.g. `"Phi-3-mini-4k-instruct.Q4_K_M.gguf"`).
50    pub file: Option<String>,
51    /// Direct URL for `"direct"` or `"github"` sources.
52    pub url: Option<String>,
53    /// Path within a GitHub repo (used with `type: github`).
54    pub path: Option<String>,
55}
56
57impl DownloadInfo {
58    /// Resolve to a concrete download URL.
59    pub fn resolved_url(&self) -> Option<String> {
60        match self.download_type.as_str() {
61            "huggingface" => {
62                let repo = self.repo.as_deref()?;
63                let file = self.file.as_deref()?;
64                Some(format!(
65                    "https://huggingface.co/{}/resolve/main/{}",
66                    repo, file
67                ))
68            }
69            "github" => {
70                let repo = self.repo.as_deref()?;
71                let path = self.path.as_deref()?;
72                let path = path.trim_start_matches('/');
73                Some(format!(
74                    "https://raw.githubusercontent.com/{}/main/{}",
75                    repo, path
76                ))
77            }
78            _ => self.url.clone(),
79        }
80    }
81
82    /// Local filename to use when saving (falls back to last URL segment).
83    pub fn local_filename(&self) -> Option<String> {
84        if let Some(ref f) = self.file {
85            return Some(f.clone());
86        }
87        self.url
88            .as_ref()
89            .and_then(|u| u.split('/').last().map(|s| s.to_string()))
90    }
91}
92
93// ─── LLM resource ─────────────────────────────────────────────────────────────
94
95/// A downloadable LLM / model package (primarily GGUF).
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct LLMResource {
98    pub id: String,
99    pub name: String,
100    pub provider: Option<String>,
101    /// File format — always `"gguf"` for Qualia-compatible models.
102    pub format: String,
103    /// Quantization level (e.g. `"Q4_K_M"`, `"Q8_0"`).
104    pub quantization: Option<String>,
105    /// Compressed file size in MB.
106    pub size_mb: Option<u32>,
107    pub download: DownloadInfo,
108    pub license: Option<String>,
109    pub recommended_for: Option<Vec<String>>,
110    /// Peak RAM required (model + runtime overhead) in MB.
111    pub ram_estimate_mb: Option<u32>,
112    pub tags: Option<Vec<String>>,
113    pub last_verified: Option<String>,
114    pub notes: Option<String>,
115    /// `text` (default) or `multimodal` (requires `vision_projector`).
116    pub modality: Option<String>,
117    /// Vision / CLIP projector GGUF paired with the language model.
118    pub vision_projector: Option<DownloadInfo>,
119    /// Architecture family for multimodal routing: `llava`, `qwen2vl`, `smolvlm`, `gemma3`.
120    pub architecture: Option<String>,
121    /// Recommended decode context window in tokens.
122    pub context_window: Option<u32>,
123}
124
125impl LLMResource {
126    pub fn is_multimodal(&self) -> bool {
127        matches!(self.modality.as_deref(), Some("multimodal" | "vision"))
128            || self.vision_projector.is_some()
129    }
130
131    pub fn effective_context_window(&self) -> u32 {
132        self.context_window
133            .unwrap_or(if self.is_multimodal() { 8192 } else { 4096 })
134    }
135    /// Encode this catalog entry as a set of `NQuin`s.
136    ///
137    /// Returns Quins using the `catalog:llm` context graph.  Vec allocation is
138    /// intentional — this runs once at catalog load, not in a hot evaluator loop.
139    pub fn to_quins(&self) -> Vec<NQuin> {
140        let subject = q_hash(&format!("llm:{}", self.id));
141        let mut out = Vec::with_capacity(8);
142
143        // Format (always present)
144        out.push(Self::quin(
145            subject,
146            q_hash("llm:hasFormat"),
147            q_hash(&self.format),
148            CTX_LLM,
149        ));
150
151        // Quantization
152        if let Some(ref q) = self.quantization {
153            out.push(Self::quin(
154                subject,
155                q_hash("llm:hasQuantization"),
156                q_hash(q),
157                CTX_LLM,
158            ));
159        }
160
161        // File size (inline integer)
162        if let Some(sz) = self.size_mb {
163            out.push(Self::quin(
164                subject,
165                q_hash("llm:hasSizeMb"),
166                INLINE_TAG_INTEGER | sz as u64,
167                CTX_LLM,
168            ));
169        }
170
171        // Peak RAM (inline integer)
172        if let Some(ram) = self.ram_estimate_mb {
173            out.push(Self::quin(
174                subject,
175                q_hash("llm:hasRamEstimateMb"),
176                INLINE_TAG_INTEGER | ram as u64,
177                CTX_LLM,
178            ));
179        }
180
181        // Source repo
182        if let Some(ref repo) = self.download.repo {
183            out.push(Self::quin(
184                subject,
185                q_hash("llm:hasSourceRepo"),
186                q_hash(&format!("hf:{}", repo)),
187                CTX_LLM,
188            ));
189        }
190
191        // License
192        if let Some(ref lic) = self.license {
193            out.push(Self::quin(
194                subject,
195                q_hash("llm:hasLicense"),
196                q_hash(&format!("license:{}", lic)),
197                CTX_LLM,
198            ));
199        }
200
201        // Provider
202        if let Some(ref prov) = self.provider {
203            out.push(Self::quin(
204                subject,
205                q_hash("llm:hasProvider"),
206                q_hash(prov),
207                CTX_LLM,
208            ));
209        }
210
211        if self.is_multimodal() {
212            out.push(Self::quin(
213                subject,
214                q_hash("llm:hasModality"),
215                q_hash("multimodal"),
216                CTX_LLM,
217            ));
218        }
219
220        if let Some(ref arch) = self.architecture {
221            out.push(Self::quin(
222                subject,
223                q_hash("llm:hasArchitecture"),
224                q_hash(arch),
225                CTX_LLM,
226            ));
227        }
228
229        out
230    }
231
232    /// Generate a provenance `NQuin` recording a completed download event.
233    ///
234    /// Written to the WAL immediately after the file is saved to disk.
235    pub fn provenance_quin(&self, timestamp_unix: u64, local_path: &str) -> NQuin {
236        let subject = q_hash(&format!("download:{}", self.id));
237        let predicate = q_hash("prov:wasGeneratedBy");
238        let object = q_hash(local_path);
239        let context = CTX_PROV;
240        // Store lower 32 bits of Unix timestamp in metadata
241        let metadata = timestamp_unix & 0xFFFF_FFFF;
242        let parity = subject ^ predicate ^ object ^ context ^ metadata;
243        NQuin {
244            subject,
245            predicate,
246            object,
247            context,
248            metadata,
249            parity,
250        }
251    }
252
253    /// Generate a supplementary provenance Quin recording the source URL.
254    pub fn source_url_quin(&self) -> Option<NQuin> {
255        let url = self.download.resolved_url()?;
256        let subject = q_hash(&format!("download:{}", self.id));
257        let predicate = q_hash("prov:hadPrimarySource");
258        let object = q_hash(&url);
259        let parity = subject ^ predicate ^ object ^ CTX_PROV;
260        Some(NQuin {
261            subject,
262            predicate,
263            object,
264            context: CTX_PROV,
265            metadata: 0,
266            parity,
267        })
268    }
269
270    /// Build a `CapabilityProfile` for this model, bound to its local file path.
271    ///
272    /// The profile ID is deterministic: `q_hash("profile:{id}")`, so callers
273    /// can look it up without storing a separate reference.
274    pub fn to_capability_profile(&self, local_path: &str) -> CapabilityProfile {
275        self.to_capability_profile_with_projector(local_path, None)
276    }
277
278    pub fn to_capability_profile_with_projector(
279        &self,
280        local_path: &str,
281        vision_projector_path: Option<&str>,
282    ) -> CapabilityProfile {
283        let modality = if self.is_multimodal() {
284            "multimodal".to_string()
285        } else {
286            "text".to_string()
287        };
288        CapabilityProfile {
289            profile_id: q_hash(&format!("profile:{}", self.id)),
290            active_engines: vec![],
291            loaded_ontologies: vec![],
292            preferred_backend: AgentBackend::Local {
293                model_path: local_path.to_string(),
294                context_window: self.effective_context_window(),
295                quantization: self
296                    .quantization
297                    .clone()
298                    .unwrap_or_else(|| "Q4_K_M".to_string()),
299                vision_projector_path: vision_projector_path.map(|s| s.to_string()),
300                modality,
301                architecture: self.architecture.clone(),
302            },
303            permitted_intent_frames: vec![],
304        }
305    }
306
307    fn quin(subject: u64, predicate: u64, object: u64, context: u64) -> NQuin {
308        let parity = subject ^ predicate ^ object ^ context;
309        NQuin {
310            subject,
311            predicate,
312            object,
313            context,
314            metadata: 0,
315            parity,
316        }
317    }
318}
319
320// ─── Ontology resource ────────────────────────────────────────────────────────
321
322/// A downloadable ontology (OWL, SKOS, RDF/Turtle, etc.).
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct OntologyResource {
325    pub id: String,
326    pub name: String,
327    pub acronym: Option<String>,
328    pub source: Option<String>,
329    pub format: String,
330    /// YAML may use fractional estimates (e.g. `0.2` MB).
331    pub size_estimate_mb: Option<f64>,
332    pub download: DownloadInfo,
333    pub license: Option<String>,
334    pub domain: Option<String>,
335    pub tags: Option<Vec<String>>,
336    pub import_strategy: Option<String>,
337    pub last_verified: Option<String>,
338    pub notes: Option<String>,
339}
340
341impl OntologyResource {
342    /// Encode this ontology entry as `NQuin`s in the `catalog:ontology` context.
343    pub fn to_quins(&self) -> Vec<NQuin> {
344        let subject = q_hash(&format!("ont:{}", self.id));
345        let mut out = Vec::with_capacity(5);
346
347        out.push(Self::quin(
348            subject,
349            q_hash("ont:hasFormat"),
350            q_hash(&self.format),
351            CTX_ONT,
352        ));
353
354        if let Some(ref domain) = self.domain {
355            out.push(Self::quin(
356                subject,
357                q_hash("ont:hasDomain"),
358                q_hash(domain),
359                CTX_ONT,
360            ));
361        }
362
363        if let Some(sz) = self.size_estimate_mb {
364            let mb = sz.ceil().max(0.0) as u64;
365            out.push(Self::quin(
366                subject,
367                q_hash("ont:hasSizeMb"),
368                INLINE_TAG_INTEGER | mb,
369                CTX_ONT,
370            ));
371        }
372
373        if let Some(ref lic) = self.license {
374            out.push(Self::quin(
375                subject,
376                q_hash("ont:hasLicense"),
377                q_hash(&format!("license:{}", lic)),
378                CTX_ONT,
379            ));
380        }
381
382        if let Some(ref src) = self.source {
383            out.push(Self::quin(
384                subject,
385                q_hash("ont:hasSource"),
386                q_hash(src),
387                CTX_ONT,
388            ));
389        }
390
391        out
392    }
393
394    /// Provenance Quin for a completed ontology import.
395    pub fn provenance_quin(&self, timestamp_unix: u64, local_path: &str) -> NQuin {
396        let subject = q_hash(&format!("import:{}", self.id));
397        let predicate = q_hash("prov:wasGeneratedBy");
398        let object = q_hash(local_path);
399        let metadata = timestamp_unix & 0xFFFF_FFFF;
400        let parity = subject ^ predicate ^ object ^ CTX_PROV ^ metadata;
401        NQuin {
402            subject,
403            predicate,
404            object,
405            context: CTX_PROV,
406            metadata,
407            parity,
408        }
409    }
410
411    fn quin(subject: u64, predicate: u64, object: u64, context: u64) -> NQuin {
412        let parity = subject ^ predicate ^ object ^ context;
413        NQuin {
414            subject,
415            predicate,
416            object,
417            context,
418            metadata: 0,
419            parity,
420        }
421    }
422}
423
424// ─── SPARQL endpoint resource ─────────────────────────────────────────────────
425
426/// A public SPARQL endpoint.
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct SPARQLResource {
429    pub id: String,
430    pub name: String,
431    pub endpoint: String,
432    pub gui: Option<String>,
433    pub maintainer: Option<String>,
434    pub reliability: Option<String>,
435    pub domains: Option<Vec<String>>,
436    pub rate_limit: Option<String>,
437    pub federation_supported: Option<bool>,
438    pub example_queries: Option<Vec<ExampleQuery>>,
439    pub last_verified: Option<String>,
440    pub notes: Option<String>,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct ExampleQuery {
445    pub description: Option<String>,
446    pub query: Option<String>,
447}
448
449// ─── Resource catalog ─────────────────────────────────────────────────────────
450
451/// The complete in-memory resource catalog, loaded from `resources/*.yaml`.
452pub struct ResourceCatalog {
453    pub llms: Vec<LLMResource>,
454    pub ontologies: Vec<OntologyResource>,
455    pub sparql_endpoints: Vec<SPARQLResource>,
456}
457
458impl ResourceCatalog {
459    pub fn empty() -> Self {
460        Self {
461            llms: vec![],
462            ontologies: vec![],
463            sparql_endpoints: vec![],
464        }
465    }
466
467    pub fn find_llm(&self, id: &str) -> Option<&LLMResource> {
468        self.llms.iter().find(|r| r.id == id)
469    }
470
471    pub fn find_ontology(&self, id: &str) -> Option<&OntologyResource> {
472        self.ontologies.iter().find(|r| r.id == id)
473    }
474
475    pub fn find_sparql(&self, id: &str) -> Option<&SPARQLResource> {
476        self.sparql_endpoints.iter().find(|r| r.id == id)
477    }
478
479    /// Serialize a summary for FRB / JSON consumers.
480    pub fn summary_json(&self) -> String {
481        #[derive(Serialize)]
482        struct Summary {
483            llm_count: usize,
484            ontology_count: usize,
485            sparql_count: usize,
486        }
487        serde_json::to_string(&Summary {
488            llm_count: self.llms.len(),
489            ontology_count: self.ontologies.len(),
490            sparql_count: self.sparql_endpoints.len(),
491        })
492        .unwrap_or_else(|_| "{}".to_string())
493    }
494}
495
496// ─── YAML loader (canonical — CLI, Flutter, client-core) ─────────────────────
497
498use std::path::{Path, PathBuf};
499
500/// Errors loading `resources/*.yaml`.
501#[derive(Debug, Clone, PartialEq, Eq)]
502pub enum CatalogError {
503    Io { path: PathBuf, message: String },
504    Parse { path: PathBuf, message: String },
505    Index { message: String },
506}
507
508impl std::fmt::Display for CatalogError {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        match self {
511            CatalogError::Io { path, message } => {
512                write!(f, "cannot read {}: {}", path.display(), message)
513            }
514            CatalogError::Parse { path, message } => {
515                write!(f, "{}: {}", path.display(), message)
516            }
517            CatalogError::Index { message } => write!(f, "catalog.yaml: {}", message),
518        }
519    }
520}
521
522impl std::error::Error for CatalogError {}
523
524#[derive(Debug, Deserialize)]
525struct CatalogRoot {
526    catalog: CatalogMeta,
527}
528
529#[derive(Debug, Deserialize)]
530struct CatalogMeta {
531    sources: CatalogSources,
532}
533
534#[derive(Debug, Deserialize)]
535struct CatalogSources {
536    llms: String,
537    ontologies: String,
538    sparql_endpoints: String,
539}
540
541#[derive(Debug, Deserialize)]
542struct LlmsFile {
543    llms: Vec<LLMResource>,
544}
545
546#[derive(Debug, Deserialize)]
547struct OntologiesFile {
548    ontologies: Vec<OntologyResource>,
549}
550
551#[derive(Debug, Deserialize)]
552struct SparqlFile {
553    sparql_endpoints: Vec<SPARQLResource>,
554}
555
556fn read_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, CatalogError> {
557    let raw = std::fs::read_to_string(path).map_err(|e| CatalogError::Io {
558        path: path.to_path_buf(),
559        message: e.to_string(),
560    })?;
561    serde_yaml::from_str(&raw).map_err(|e| CatalogError::Parse {
562        path: path.to_path_buf(),
563        message: e.to_string(),
564    })
565}
566
567/// Resolve the resources directory for desktop / dev builds.
568///
569/// Order: `QUALIA_RESOURCES_DIR` → `{exe}/bundled/resources/` → dev tree `../../resources`.
570pub fn resolve_resources_dir() -> PathBuf {
571    if let Ok(extra) = std::env::var("QUALIA_RESOURCES_DIR") {
572        return PathBuf::from(extra);
573    }
574
575    if let Ok(exe) = std::env::current_exe() {
576        if let Some(root) = exe.parent() {
577            for rel in ["bundled/resources", "resources", "bundled"] {
578                let candidate = root.join(rel);
579                if candidate.join("catalog.yaml").is_file() {
580                    return candidate;
581                }
582            }
583        }
584    }
585
586    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../resources")
587}
588
589/// Load the full catalog from `dir/catalog.yaml` and referenced child YAML files.
590pub fn load_from_dir(dir: &Path) -> Result<ResourceCatalog, CatalogError> {
591    let index_path = dir.join("catalog.yaml");
592    let index: CatalogRoot = read_yaml(&index_path)?;
593
594    let llms_path = dir.join(&index.catalog.sources.llms);
595    let ont_path = dir.join(&index.catalog.sources.ontologies);
596    let sparql_path = dir.join(&index.catalog.sources.sparql_endpoints);
597
598    let llms_file: LlmsFile = read_yaml(&llms_path)?;
599    let ont_file: OntologiesFile = read_yaml(&ont_path)?;
600    let sparql_file: SparqlFile = read_yaml(&sparql_path)?;
601
602    Ok(ResourceCatalog {
603        llms: llms_file.llms,
604        ontologies: ont_file.ontologies,
605        sparql_endpoints: sparql_file.sparql_endpoints,
606    })
607}
608
609/// Load from [`resolve_resources_dir()`].
610pub fn load_default() -> Result<ResourceCatalog, CatalogError> {
611    load_from_dir(&resolve_resources_dir())
612}
613
614#[cfg(test)]
615mod load_tests {
616    use super::*;
617
618    fn resources_fixture_dir() -> PathBuf {
619        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../resources")
620    }
621
622    #[test]
623    fn loads_all_entries_from_committed_yaml() {
624        let dir = resources_fixture_dir();
625        let cat = load_from_dir(&dir).expect("catalog should load");
626        assert!(
627            cat.llms.len() >= 10,
628            "expected >=10 LLMs, got {}",
629            cat.llms.len()
630        );
631        let multimodal = cat.llms.iter().filter(|m| m.is_multimodal()).count();
632        assert!(
633            multimodal >= 3,
634            "expected >=3 multimodal LLMs, got {multimodal}"
635        );
636        assert!(
637            cat.ontologies.len() >= 12,
638            "expected >=12 ontologies, got {}",
639            cat.ontologies.len()
640        );
641        assert!(
642            cat.sparql_endpoints.len() >= 3,
643            "expected >=3 SPARQL endpoints, got {}",
644            cat.sparql_endpoints.len()
645        );
646    }
647
648    #[test]
649    fn github_download_resolves_raw_url() {
650        let info = DownloadInfo {
651            download_type: "github".to_string(),
652            repo: Some("schemaorg/schemaorg".to_string()),
653            file: None,
654            url: None,
655            path: Some("data/releases/27.0/schemaorg-all-https.rdf".to_string()),
656        };
657        let url = info.resolved_url().expect("github url");
658        assert!(url.contains("raw.githubusercontent.com/schemaorg/schemaorg"));
659        assert!(url.contains("schemaorg-all-https.rdf"));
660    }
661
662    #[test]
663    fn find_llm_by_id() {
664        let cat = load_from_dir(&resources_fixture_dir()).unwrap();
665        assert!(cat.find_llm("phi-3-mini-4k-instruct-q4km").is_some());
666    }
667}