1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct AxiomBounds {
60 pub start_year: u16,
61 pub end_year: u16,
62 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#[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 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}