Skip to main content

qualia_client_core/
context_binding.rs

1//! Compiles chat session scope from installed ontologies, active model, and qapps.
2//!
3//! Produces a capability briefing the LLM receives via `graph_context` and prompt prefix.
4
5use std::collections::HashMap;
6use std::fs;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use qualia_core_db::{
12    profiles::CapabilityProfile,
13    q_hash,
14    resource_catalog::{OntologyResource, ResourceCatalog},
15    NQuin, CAPABILITY_DESCRIPTORS,
16};
17
18use crate::chat_session::{ChatEnvironment, OntologyScopeSummary};
19use crate::model_lifecycle;
20use crate::resource_import;
21
22const MAX_LEXICON_PER_ONTOLOGY: usize = 256;
23const MAX_MANIFEST_QUINS: usize = 16;
24const OBJECT_HASH_MASK: u64 = 0x0FFF_FFFF_FFFF_FFFF;
25
26#[derive(Debug)]
27pub enum BindError {
28    Io(std::io::Error),
29    Json(serde_json::Error),
30    NotFound(String),
31    Compile(String),
32}
33
34impl std::fmt::Display for BindError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            BindError::Io(e) => write!(f, "IO error: {e}"),
38            BindError::Json(e) => write!(f, "JSON error: {e}"),
39            BindError::NotFound(id) => write!(f, "Not found: {id}"),
40            BindError::Compile(msg) => write!(f, "Compile error: {msg}"),
41        }
42    }
43}
44
45impl From<std::io::Error> for BindError {
46    fn from(e: std::io::Error) -> Self {
47        BindError::Io(e)
48    }
49}
50
51impl From<serde_json::Error> for BindError {
52    fn from(e: serde_json::Error) -> Self {
53        BindError::Json(e)
54    }
55}
56
57/// LTL / Allen-interval axiom window bound to a chat session.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct AxiomBounds {
60    pub start_year: u16,
61    pub end_year: u16,
62    /// `q_hash` of spatial context label (0 = unset).
63    pub spatial_context_hash: u64,
64    #[serde(default)]
65    pub spatial_context_label: String,
66}
67
68impl Default for AxiomBounds {
69    fn default() -> Self {
70        Self {
71            start_year: 1800,
72            end_year: 2100,
73            spatial_context_hash: 0,
74            spatial_context_label: String::new(),
75        }
76    }
77}
78
79impl AxiomBounds {
80    pub fn label(&self) -> String {
81        format!("[{}–{}]", self.start_year, self.end_year)
82    }
83
84    pub fn with_spatial_label(mut self, label: &str) -> Self {
85        self.spatial_context_label = label.trim().to_string();
86        self.spatial_context_hash = if self.spatial_context_label.is_empty() {
87            0
88        } else {
89            q_hash(&self.spatial_context_label)
90        };
91        self
92    }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ChatEnvironmentConfig {
97    pub session_id: String,
98    pub ontology_ids: Vec<String>,
99    pub prior_session_ids: Vec<String>,
100    #[serde(default)]
101    pub session_kind: crate::chat_session::SessionKind,
102    #[serde(default)]
103    pub participants: Vec<crate::chat_session::ChatParticipant>,
104    #[serde(default)]
105    pub graph_mutation: bool,
106    #[serde(default)]
107    pub axiom_bounds: AxiomBounds,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111struct OntologyMetaSidecar {
112    ontology_id: String,
113    quin_count: u64,
114    #[allow(dead_code)]
115    q42_path: String,
116}
117
118/// Stack-allocated inference packet for the orchestrator.
119#[derive(Debug, Clone)]
120pub struct InferenceContextPacket {
121    pub augmented_prompt: String,
122    pub graph_context_json: String,
123    pub graph_scope_hashes: Vec<u64>,
124    pub context_namespaces: Vec<u64>,
125    pub routed_ontology_ids: Vec<String>,
126    pub routing_brief: String,
127    pub active_profile: Option<CapabilityProfile>,
128    pub model_path: String,
129    pub axiom_bounds: AxiomBounds,
130}
131
132pub fn compile_chat_environment(
133    storage: &Path,
134    catalog: &ResourceCatalog,
135    config: &ChatEnvironmentConfig,
136) -> Result<ChatEnvironment, BindError> {
137    let active = load_active_model_record();
138    let profile_id = active.as_ref().map(|r| r.profile_id).unwrap_or(0);
139
140    let installed = list_installed_ontology_ids(storage);
141    let mut ontology_ids: Vec<String> = if config.ontology_ids.is_empty() {
142        installed.clone()
143    } else {
144        config
145            .ontology_ids
146            .iter()
147            .filter(|id| installed.contains(id))
148            .cloned()
149            .collect()
150    };
151
152    if crate::chat_ontology::wordnet_available(storage) {
153        for id in ["wordnet", "wordnet-rdf", "english-wordnet"] {
154            if installed.iter().any(|i| i == id) && !ontology_ids.contains(&id.to_string()) {
155                ontology_ids.push(id.to_string());
156            }
157        }
158        if ontology_ids.iter().all(|id| !id.contains("wordnet"))
159            && installed.iter().any(|i| i.starts_with("wordnet"))
160        {
161            if let Some(w) = installed.iter().find(|i| i.contains("wordnet")).cloned() {
162                ontology_ids.push(w);
163            }
164        }
165    }
166
167    let mut graph_scope_hashes = vec![q_hash(&format!("chat:session:{}", config.session_id))];
168    let mut lexicon_prefixes: Vec<u64> = Vec::new();
169    let mut ontology_summaries = Vec::new();
170
171    for ont_id in &ontology_ids {
172        graph_scope_hashes.push(q_hash(&format!("ont:{ont_id}")));
173        if let Some(summary) =
174            compile_ontology_scope(storage, catalog, ont_id, &mut lexicon_prefixes)?
175        {
176            ontology_summaries.push(summary);
177        }
178    }
179
180    for prior in &config.prior_session_ids {
181        graph_scope_hashes.push(q_hash(&format!("chat:session:{prior}")));
182    }
183
184    lexicon_prefixes.sort_unstable();
185    lexicon_prefixes.dedup();
186    if lexicon_prefixes.len() > MAX_LEXICON_PER_ONTOLOGY * 4 {
187        lexicon_prefixes.truncate(MAX_LEXICON_PER_ONTOLOGY * 4);
188    }
189
190    graph_scope_hashes.sort_unstable();
191    graph_scope_hashes.dedup();
192
193    let installed_qapps = list_qapp_names(storage);
194    let daemon_reachable = daemon_is_running();
195    let engine_capabilities: Vec<String> = CAPABILITY_DESCRIPTORS
196        .iter()
197        .map(|capability| capability.name.to_string())
198        .collect();
199
200    let model_id = active.as_ref().map(|r| r.model_id.clone());
201    let model_modality = active
202        .as_ref()
203        .map(|r| r.modality.clone())
204        .unwrap_or_else(|| "text".to_string());
205    let context_window = active.as_ref().map(|r| r.context_window).unwrap_or(4096);
206
207    let profile = crate::user_profile::load_profile();
208    let capability_briefing = build_capability_briefing(
209        storage,
210        &config.session_id,
211        config.session_kind,
212        &config.participants,
213        &profile,
214        active.as_ref(),
215        &ontology_summaries,
216        &installed_qapps,
217        &engine_capabilities,
218        daemon_reachable,
219        &config.prior_session_ids,
220        lexicon_prefixes.len(),
221    );
222
223    let env = ChatEnvironment {
224        session_id: config.session_id.clone(),
225        active_model_profile_id: profile_id,
226        ontology_ids,
227        prior_session_ids: config.prior_session_ids.clone(),
228        graph_scope_hashes: graph_scope_hashes.clone(),
229        lexicon_prefixes,
230        capability_briefing,
231        model_id,
232        model_modality,
233        context_window,
234        engine_capabilities,
235        installed_qapps,
236        ontology_summaries,
237        daemon_reachable,
238        session_kind: config.session_kind,
239        participants: config.participants.clone(),
240        graph_mutation: config.graph_mutation,
241        axiom_bounds: config.axiom_bounds.clone(),
242    };
243
244    write_environment_manifest_q42(storage, &config.session_id, &graph_scope_hashes)?;
245    Ok(env)
246}
247
248pub fn refresh_session_environment(
249    storage: &Path,
250    catalog: &ResourceCatalog,
251    session_id: &str,
252) -> Result<ChatEnvironment, BindError> {
253    let existing = crate::chat_session::load_session(storage, session_id).map_err(|e| match e {
254        crate::chat_session::ChatError::NotFound(id) => BindError::NotFound(id),
255        crate::chat_session::ChatError::Io(err) => BindError::Io(err),
256        crate::chat_session::ChatError::Json(err) => BindError::Json(err),
257        other => BindError::Compile(other.to_string()),
258    })?;
259
260    let config = ChatEnvironmentConfig {
261        session_id: session_id.to_string(),
262        ontology_ids: existing.environment.ontology_ids,
263        prior_session_ids: existing.environment.prior_session_ids,
264        session_kind: existing.meta.session_kind,
265        participants: existing.meta.participants.clone(),
266        graph_mutation: existing.environment.graph_mutation,
267        axiom_bounds: existing.environment.axiom_bounds,
268    };
269
270    let env = compile_chat_environment(storage, catalog, &config)?;
271    env.save_to_session_dir(storage)
272        .map_err(|e| BindError::Compile(e.to_string()))?;
273    Ok(env)
274}
275
276pub fn build_inference_packet(
277    env: &ChatEnvironment,
278    user_prompt: &str,
279    catalog: &ResourceCatalog,
280) -> Result<InferenceContextPacket, BindError> {
281    let use_ollama = crate::inference_backend::use_ollama_harness();
282    let active = load_active_model_record();
283
284    let (active_profile, model_path) = if use_ollama {
285        // Optional Ollama path: no GGUF activation required. model_path is a label only.
286        let ib = crate::inference_backend::load_inference_backend_settings();
287        let label = format!("ollama:{}", ib.ollama_model);
288        (None, label)
289    } else {
290        let active = active.ok_or_else(|| {
291            BindError::Compile(
292                "No active model — activate one in LLM Hub, or set Inference Backend to Ollama in Settings"
293                    .to_string(),
294            )
295        })?;
296        if !std::path::Path::new(&active.gguf_path).is_file() {
297            return Err(BindError::Compile(format!(
298                "GGUF missing at {}",
299                active.gguf_path
300            )));
301        }
302        let llm = catalog.find_llm(&active.model_id);
303        let active_profile = llm.map(|m| {
304            m.to_capability_profile_with_projector(&active.gguf_path, active.mmproj_path.as_deref())
305        });
306        (active_profile, active.gguf_path.clone())
307    };
308
309    let graph_context_json = serde_json::to_string(env)?;
310    let backend_note = if use_ollama {
311        "\n[inference_backend: ollama — optional HTTP harness; Qualia retrieval/routing still apply]\n"
312    } else {
313        ""
314    };
315    let augmented_prompt = format!(
316        "{}\n{}---\nUser: {}\n---",
317        env.capability_briefing, backend_note, user_prompt
318    );
319
320    Ok(InferenceContextPacket {
321        augmented_prompt,
322        graph_context_json,
323        graph_scope_hashes: env.graph_scope_hashes.clone(),
324        context_namespaces: Vec::new(),
325        routed_ontology_ids: Vec::new(),
326        routing_brief: String::new(),
327        active_profile,
328        model_path,
329        axiom_bounds: env.axiom_bounds.clone(),
330    })
331}
332
333pub fn load_active_model_record() -> Option<model_lifecycle::ActiveModelRecord> {
334    let path = crate::state::app_meta_dir().join("active_model.json");
335    let text = fs::read_to_string(path).ok()?;
336    serde_json::from_str(&text).ok()
337}
338
339fn daemon_is_running() -> bool {
340    let state = match crate::state::APP_STATE.get() {
341        Some(s) => s,
342        None => return false,
343    };
344    *state.daemon_running.lock().unwrap()
345}
346
347fn list_qapp_names(storage: &Path) -> Vec<String> {
348    let dir = crate::qapp_paths::qapps_dir(storage);
349    let mut names = Vec::new();
350    if let Ok(entries) = fs::read_dir(dir) {
351        for entry in entries.filter_map(Result::ok) {
352            if entry.path().is_dir() {
353                names.push(entry.file_name().to_string_lossy().into_owned());
354            }
355        }
356    }
357    names.sort();
358    names
359}
360
361pub fn list_installed_ontology_ids(storage: &Path) -> Vec<String> {
362    let index = resource_import::index_dir(storage);
363    let mut ids = Vec::new();
364
365    if let Ok(entries) = fs::read_dir(&index) {
366        for entry in entries.filter_map(Result::ok) {
367            let path = entry.path();
368            let name = entry.file_name().to_string_lossy().into_owned();
369            if name.ends_with(".q42.meta.json") {
370                if let Ok(text) = fs::read_to_string(&path) {
371                    if let Ok(meta) = serde_json::from_str::<OntologyMetaSidecar>(&text) {
372                        ids.push(meta.ontology_id);
373                        continue;
374                    }
375                }
376            }
377            if name.ends_with(".q42") && !name.contains(".meta.") {
378                let id = name.trim_end_matches(".q42").to_string();
379                if !id.is_empty() {
380                    ids.push(id);
381                }
382            }
383        }
384    }
385
386    ids.sort();
387    ids.dedup();
388    ids
389}
390
391fn compile_ontology_scope(
392    storage: &Path,
393    catalog: &ResourceCatalog,
394    ont_id: &str,
395    lexicon_out: &mut Vec<u64>,
396) -> Result<Option<OntologyScopeSummary>, BindError> {
397    let q42_path = resource_import::index_dir(storage).join(format!("{ont_id}.q42"));
398    if !q42_path.is_file() {
399        return Ok(None);
400    }
401
402    let meta = read_ontology_meta(storage, ont_id);
403    let quin_count = meta
404        .as_ref()
405        .map(|m| m.quin_count)
406        .unwrap_or_else(|| count_q42_quins(&q42_path).unwrap_or(0));
407
408    if let Ok(quins) = qualia_core_db::q42_reader::read_q42_quins(&q42_path) {
409        collect_lexicon_prefixes(&quins, lexicon_out);
410    }
411
412    let entry = catalog.find_ontology(ont_id);
413    let name = entry
414        .map(|o: &OntologyResource| o.name.clone())
415        .unwrap_or_else(|| ont_id.to_string());
416
417    Ok(Some(OntologyScopeSummary {
418        id: ont_id.to_string(),
419        name,
420        quin_count,
421        q42_path: q42_path.to_string_lossy().into_owned(),
422        domain: entry.and_then(|o| o.domain.clone()),
423        tags: entry.and_then(|o| o.tags.clone()),
424        source: entry.and_then(|o| o.source.clone()),
425    }))
426}
427
428fn read_ontology_meta(storage: &Path, ont_id: &str) -> Option<OntologyMetaSidecar> {
429    let path = resource_import::index_dir(storage).join(format!("{ont_id}.q42.meta.json"));
430    let text = fs::read_to_string(path).ok()?;
431    serde_json::from_str(&text).ok()
432}
433
434fn collect_lexicon_prefixes(quins: &[NQuin], out: &mut Vec<u64>) {
435    let mut freq: HashMap<u64, u32> = HashMap::new();
436    for q in quins {
437        *freq.entry(q.subject & OBJECT_HASH_MASK).or_insert(0) += 1;
438        *freq.entry(q.predicate & OBJECT_HASH_MASK).or_insert(0) += 1;
439    }
440
441    let mut ranked: Vec<(u64, u32)> = freq.into_iter().collect();
442    ranked.sort_by(|a, b| b.1.cmp(&a.1));
443    for (hash, _) in ranked.into_iter().take(MAX_LEXICON_PER_ONTOLOGY) {
444        out.push(hash);
445    }
446}
447
448fn count_q42_quins(path: &Path) -> Result<u64, BindError> {
449    Ok(qualia_core_db::q42_reader::read_q42_quins(path)
450        .map_err(|e| BindError::Compile(e.to_string()))?
451        .len() as u64)
452}
453
454fn write_environment_manifest_q42(
455    storage: &Path,
456    session_id: &str,
457    scopes: &[u64],
458) -> Result<(), BindError> {
459    let dir = crate::chat_session::chats_dir(storage).join(session_id);
460    fs::create_dir_all(&dir)?;
461    let out_path = dir.join("environment.q42");
462
463    let mut quins = Vec::new();
464    let subject = q_hash(&format!("chat:session:{session_id}"));
465    let predicate = q_hash("q42:hasGraphScope");
466
467    for (i, scope) in scopes.iter().take(MAX_MANIFEST_QUINS).enumerate() {
468        let object = *scope & OBJECT_HASH_MASK;
469        let metadata = i as u64;
470        let context = q_hash("chat:environment");
471        let parity = subject ^ predicate ^ object ^ context ^ metadata;
472        quins.push(NQuin {
473            subject,
474            predicate,
475            object,
476            context,
477            metadata,
478            parity,
479        });
480    }
481
482    if quins.is_empty() {
483        return Ok(());
484    }
485
486    write_quins_to_q42(&quins, &out_path)?;
487    Ok(())
488}
489
490fn write_quins_to_q42(quins: &[NQuin], out_path: &Path) -> Result<(), BindError> {
491    qualia_core_db::q42_volume::write_sorted_quins_volume(out_path, quins)?;
492    Ok(())
493}
494
495fn build_capability_briefing(
496    storage: &Path,
497    session_id: &str,
498    session_kind: crate::chat_session::SessionKind,
499    participants: &[crate::chat_session::ChatParticipant],
500    profile: &crate::user_profile::UserProfile,
501    model: Option<&model_lifecycle::ActiveModelRecord>,
502    ontologies: &[OntologyScopeSummary],
503    qapps: &[String],
504    engines: &[String],
505    daemon_reachable: bool,
506    prior_sessions: &[String],
507    lexicon_count: usize,
508) -> String {
509    let mut lines = vec![
510        "[Qualia Chat Environment]".to_string(),
511        format!("session_id: {session_id}"),
512        format!(
513            "session_kind: {}",
514            match session_kind {
515                crate::chat_session::SessionKind::Solo => "solo",
516                crate::chat_session::SessionKind::Group => "group",
517            }
518        ),
519    ];
520
521    if session_kind == crate::chat_session::SessionKind::Group && !participants.is_empty() {
522        lines.push("participants:".to_string());
523        for p in participants {
524            lines.push(format!(
525                "  - {} ({}) role={}",
526                p.display_name, p.did, p.role
527            ));
528        }
529        if profile.sharing.share_display_name {
530            lines.push(format!("local_user: {}", profile.display_name));
531        }
532        if profile.sharing.share_public_did {
533            lines.push(format!("local_did: {}", profile.public_did));
534        }
535        lines.push(
536            "instructions: This is a group chat. Attribute user messages to participants when author metadata is present. Respect each participant's shared scope only.".to_string(),
537        );
538        lines.push(
539            "agent_hierarchy: LLM/Webizen agents are sub-agents of their human principal (sub_agent_of), not independent participants. Only use peer agent outcomes when cooperative_agents metadata marks them shareable.".to_string(),
540        );
541    }
542
543    if let Some(m) = model {
544        if profile.sharing.share_active_model {
545            lines.push(format!(
546                "active_model: {} (profile_id=0x{:016x}, modality={}, quantization={}, context_window={})",
547                m.model_id, m.profile_id, m.modality, m.quantization, m.context_window
548            ));
549            if m.modality == "multimodal" {
550                lines.push(
551                    "vision: multimodal model with mmproj projector — image ingest available via native vision_ingest"
552                        .to_string(),
553                );
554            }
555        } else {
556            lines.push("active_model: [hidden by sharing policy]".to_string());
557        }
558    } else if profile.sharing.share_active_model {
559        lines.push("active_model: none (inference blocked until LLM Hub activation)".to_string());
560    }
561
562    if profile.sharing.share_daemon_status {
563        lines.push(format!(
564            "graph_daemon: {} (localhost SPARQL-style /query over installed .q42 indexes)",
565            if daemon_reachable {
566                "reachable"
567            } else {
568                "not running"
569            }
570        ));
571    }
572
573    if profile.sharing.share_ontology_scope {
574        if ontologies.is_empty() {
575            lines.push("installed_ontologies: none — import ontologies in Ontology Hub for grounded citations".to_string());
576        } else {
577            lines.push("installed_ontologies:".to_string());
578            for o in ontologies {
579                lines.push(format!(
580                    "  - {} ({}) — {} quins at {}",
581                    o.name, o.id, o.quin_count, o.q42_path
582                ));
583            }
584            lines.push(format!(
585                "lexicon_prefixes_sampled: {lexicon_count} predicate/subject hashes"
586            ));
587        }
588    } else {
589        lines.push("installed_ontologies: [hidden by sharing policy]".to_string());
590    }
591
592    if !prior_sessions.is_empty() {
593        lines.push(format!("prior_sessions: {}", prior_sessions.join(", ")));
594    }
595
596    if profile.sharing.share_installed_qapps {
597        if qapps.is_empty() {
598            lines.push("installed_qapps: none".to_string());
599        } else {
600            lines.push(format!("installed_qapps: {}", qapps.join(", ")));
601        }
602    }
603
604    lines.push(format!("native_engines: {}", engines.join(", ")));
605    lines.push("native_tool_routing:".to_string());
606    for capability in CAPABILITY_DESCRIPTORS {
607        lines.push(format!(
608            "  - {} [{}] -> {}",
609            capability.name,
610            capability.domain,
611            capability.mcp_tools.join(", ")
612        ));
613    }
614
615    lines.push(
616        "instructions: Ground factual claims in installed ontology quins. Route exact STEM calculations through the listed native MCP tools; do not imitate those solvers in generated prose. Cite graph scope hashes when asserting domain facts. Use Anatomy qapp handoff for spatial/clinical visualization. Refuse ungrounded speculation when ontologies are available.".to_string(),
617    );
618
619    lines.push(crate::chat_ontology::build_chat_ontology_briefing(storage));
620
621    lines.join("\n")
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use std::env;
628
629    #[test]
630    fn compile_environment_deterministic_scopes() {
631        let mut storage = env::temp_dir();
632        storage.push(format!("qualia-bind-{}", rand::random::<u32>()));
633        let catalog = ResourceCatalog::empty();
634        let config = ChatEnvironmentConfig {
635            session_id: "test-session".to_string(),
636            ontology_ids: vec![],
637            prior_session_ids: vec![],
638            session_kind: crate::chat_session::SessionKind::Solo,
639            participants: vec![],
640            graph_mutation: false,
641            axiom_bounds: AxiomBounds::default(),
642        };
643        let env = compile_chat_environment(&storage, &catalog, &config).unwrap();
644        assert!(env.capability_briefing.contains("test-session"));
645        assert!(!env.graph_scope_hashes.is_empty());
646        let _ = fs::remove_dir_all(&storage);
647    }
648}