1use std::path::Path;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex, OnceLock};
6
7use qualia_core_db::{
8 NQuin,
9 llm_agent::{
10 AgentError, AgentIntent, AgentOutput, AgentRuntime, LocalLlmAgent, WebizenVerdict,
11 },
12 modalities::logic::n3_compiler::N3OutputMode,
13 orchestrator::{ModelLifecycle, OrchestrationResult},
14 q_hash,
15 wal::WriteAheadLog,
16};
17use serde::{Deserialize, Serialize};
18
19use crate::chat_retrieval::{GraphCitation, RetrievalBundle};
20use crate::chat_session;
21use crate::context_binding::{self, InferenceContextPacket};
22use crate::ontology_router::OntologyRoutingDecision;
23
24const OBJECT_HASH_MASK: u64 = 0x0FFF_FFFF_FFFF_FFFF;
25
26static INFERENCE_CANCEL: AtomicBool = AtomicBool::new(false);
27
28static LOCAL_AGENT_DECODE_LANE: OnceLock<Mutex<()>> = OnceLock::new();
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ChatInferenceResult {
35 pub text: String,
36 pub provenance_hashes: Vec<String>,
37 pub citations: Vec<GraphCitation>,
38 pub retrieval_triple_count: usize,
39 pub tokens_generated: u32,
40 pub inference_duration_ms: u64,
41 pub committed: bool,
42 pub block_reason: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub sub_agent_of: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub agent_did: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub model_id: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub agent_backend: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub semantic_quin: Option<[u64; 6]>,
54 #[serde(default)]
55 pub wal_committed: bool,
56 #[serde(default)]
57 pub sieve_token_count: u8,
58 #[serde(default)]
59 pub shield_alert: bool,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub axiom_bounds_label: Option<String>,
62 #[serde(default)]
64 pub wal_suspended: bool,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub suspended_agreement_id: Option<u64>,
67}
68
69pub fn request_cancel_inference() {
70 INFERENCE_CANCEL.store(true, Ordering::SeqCst);
71}
72
73pub fn clear_cancel_inference() {
74 INFERENCE_CANCEL.store(false, Ordering::SeqCst);
75}
76
77pub fn is_inference_cancelled() -> bool {
78 INFERENCE_CANCEL.load(Ordering::SeqCst)
79}
80
81#[derive(Debug, Clone, Default)]
82pub struct ChatInferenceOptions {
83 pub reply_to_fragment_id: Option<String>,
84 pub graph_mutation: bool,
86 pub semantic_profile: Option<crate::agent_registry::AgentSemanticProfile>,
89 pub allowed_ontology_ids: Vec<String>,
92}
93
94pub fn run_chat_inference_with_options(
95 session_id: &str,
96 prompt: &str,
97 on_token: Option<Arc<dyn Fn(String) + Send + Sync>>,
98) -> ChatInferenceResult {
99 run_chat_inference_full(
100 session_id,
101 prompt,
102 on_token,
103 ChatInferenceOptions::default(),
104 )
105}
106
107pub fn run_chat_inference_for_agent(
112 session_id: &str,
113 prompt: &str,
114 agent_slug: Option<&str>,
115 on_token: Option<Arc<dyn Fn(String) + Send + Sync>>,
116) -> ChatInferenceResult {
117 let Some(slug) = agent_slug.filter(|slug| !slug.trim().is_empty()) else {
118 return run_chat_inference_with_options(session_id, prompt, on_token);
119 };
120 let started = std::time::Instant::now();
121 let lane = LOCAL_AGENT_DECODE_LANE.get_or_init(|| Mutex::new(()));
122 let _lease = lane.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
123 let Some(state) = crate::state::APP_STATE.get() else {
124 return empty_result(started, "Application not initialized", None);
125 };
126 let storage = match state.config.lock() {
127 Ok(config) => config.storage_path.clone(),
128 Err(error) => return empty_result(started, &error.to_string(), None),
129 };
130 let Some(agent) = crate::agent_registry::get_agent(Path::new(&storage), slug) else {
131 return empty_result(started, &format!("Unknown agent @{slug}"), None);
132 };
133 if !agent.enabled {
134 return empty_result(started, &format!("Agent @{} is disabled", agent.slug), None);
135 }
136 let model_id = match &agent.backend {
137 crate::agent_registry::AgentBackendSpec::LocalEngine { model_id } => model_id.clone(),
138 crate::agent_registry::AgentBackendSpec::RemoteMcp { .. } => {
139 return empty_result(
140 started,
141 "Remote agent must be dispatched through its MCP provider",
142 None,
143 );
144 }
145 };
146 if let Err(error) =
147 crate::chat_agents::bind_local_roster_agent(Path::new(&storage), session_id, &agent)
148 {
149 return empty_result(started, &format!("Could not bind agent: {error}"), None);
150 }
151 if let Some(model_id) = model_id {
152 let active_record = crate::api::load_active_model_record_from_disk();
153 let already_active = active_record
154 .as_ref()
155 .is_some_and(|active| active.model_id == model_id);
156 if !already_active {
157 if let Some(active) = active_record {
162 crate::model_lifecycle::unload_active_model(Some(active.profile_id));
163 }
164 if let Err(error) =
165 crate::model_lifecycle::activate_model_for_id(&model_id, Path::new(&storage))
166 {
167 return empty_result(
168 started,
169 &format!("Could not load @{slug}'s model `{model_id}`: {error}"),
170 None,
171 );
172 }
173 }
174 }
175 run_chat_inference_full(
176 session_id,
177 prompt,
178 on_token,
179 ChatInferenceOptions {
180 semantic_profile: Some(agent.semantic_profile),
181 allowed_ontology_ids: agent.data_policy.allowed_ontology_ids,
182 ..ChatInferenceOptions::default()
183 },
184 )
185}
186
187pub fn run_chat_inference_full(
188 session_id: &str,
189 prompt: &str,
190 on_token: Option<Arc<dyn Fn(String) + Send + Sync>>,
191 options: ChatInferenceOptions,
192) -> ChatInferenceResult {
193 clear_cancel_inference();
194 qualia_core_db::llm_bench::set_sampler_config(Some(
198 qualia_core_db::sampler::SamplerConfig::chat_default(),
199 ));
200 let started = std::time::Instant::now();
201
202 let state = match crate::state::APP_STATE.get() {
203 Some(s) => s,
204 None => {
205 return empty_result(started, "Application not initialized", None);
206 }
207 };
208
209 let storage = state.config.lock().unwrap().storage_path.clone();
210 let catalog = crate::api::load_workspace_catalog();
211 let ib_settings = crate::inference_backend::load_inference_backend_settings();
212 let use_ollama = matches!(
213 ib_settings.backend,
214 crate::chat_agents::AgentBackendKind::Ollama
215 );
216
217 if !use_ollama && crate::model_lifecycle::get_model_lifecycle_state() != ModelLifecycle::Active
219 {
220 return empty_result(
221 started,
222 "No active model — download and activate a model in LLM Hub, or switch Inference Backend to Ollama in Settings.",
223 None,
224 );
225 }
226
227 let env = match context_binding::refresh_session_environment(
228 Path::new(&storage),
229 &catalog,
230 session_id,
231 ) {
232 Ok(e) => e,
233 Err(e) => return empty_result(started, &e.to_string(), None),
234 };
235
236 let bounds_label = env.axiom_bounds.label();
237 let empty = |reason: &str| empty_result(started, reason, Some(&bounds_label));
238
239 if let Err(reason) = validate_axiom_preflight(prompt, &env.axiom_bounds) {
240 return empty_result(started, &reason, Some(&bounds_label));
241 }
242
243 let profile = crate::user_profile::load_profile();
244 let mut agent_cfg =
245 match crate::chat_agents::load_local_agent_config(Path::new(&storage), session_id) {
246 Ok(c) => c,
247 Err(e) => return empty(&e),
248 };
249 agent_cfg.backend = ib_settings.backend;
251 if use_ollama {
252 agent_cfg.model_id = Some(ib_settings.ollama_model.clone());
253 }
254
255 let focus_terms = options
256 .semantic_profile
257 .as_ref()
258 .map(crate::agent_registry::AgentSemanticProfile::focus_terms)
259 .unwrap_or_default();
260 let routing = crate::ontology_router::route_prompt_with_focus_and_allowlist(
261 &env,
262 prompt,
263 &focus_terms,
264 &options.allowed_ontology_ids,
265 );
266 let retrieval = crate::chat_retrieval::retrieve_graph_context(
267 Path::new(&storage),
268 &env,
269 prompt,
270 &routing.ontology_ids,
271 );
272
273 let packet = match build_augmented_packet(
274 Path::new(&storage),
275 session_id,
276 &env,
277 prompt,
278 &retrieval,
279 &catalog,
280 &routing,
281 options.semantic_profile.as_ref(),
282 options.reply_to_fragment_id.as_deref(),
283 ) {
284 Ok(p) => p,
285 Err(e) => return empty(&e.to_string()),
286 };
287
288 if use_ollama {
290 return run_ollama_chat_turn(
291 session_id,
292 &agent_cfg,
293 &packet,
294 &retrieval,
295 &ib_settings,
296 on_token,
297 started,
298 Path::new(&storage),
299 empty,
300 );
301 }
302
303 let active = match crate::api::load_active_model_record_from_disk() {
304 Some(r) => r,
305 None => return empty("No active model record — activate in LLM Hub."),
306 };
307
308 let agent = LocalLlmAgent::with_local_backend(
309 agent_cfg.sub_agent_did.clone(),
310 qualia_core_db::llm_agent::AgentBackend::Local {
311 model_path: packet.model_path.clone(),
312 context_window: active.context_window,
313 quantization: active.quantization.clone(),
314 vision_projector_path: active.mmproj_path.clone(),
315 modality: active.modality.clone(),
316 architecture: active.architecture.clone(),
317 },
318 );
319
320 if let Some(lex_path) = resolve_sieve_lex_path(
321 Path::new(&storage),
322 &env,
323 &packet.model_path,
324 &routing.ontology_ids,
325 ) {
326 agent.configure_sieve_lex(lex_path);
327 }
328 if let Err(err) =
329 crate::model_lifecycle::task_orchestrator().load_model(&agent, active.profile_id)
330 {
331 return empty(&format!("Model unavailable: {err}"));
332 }
333 crate::model_lifecycle::record_llm_memory_sample(
334 agent
335 .memory_used_bytes
336 .load(std::sync::atomic::Ordering::Relaxed),
337 );
338
339 let frame_hash = q_hash(&format!("purpose:ChatSession:{session_id}"));
340 let graph_mutation = options.graph_mutation || env.graph_mutation;
341 let (intent_predicate, mcp_intent_frame_hash, output_mode) = if graph_mutation {
342 (frame_hash, frame_hash, N3OutputMode::GraphMutation)
343 } else {
344 (
345 q_hash("llm:ReadGraph"),
346 q_hash("purpose:General"),
347 N3OutputMode::FreeText,
348 )
349 };
350
351 let intent = AgentIntent {
352 intent_predicate,
353 requested_graph_scope: packet.graph_scope_hashes.clone(),
354 context_namespaces: packet.context_namespaces.clone(),
355 requires_network: false,
356 ilp_offer_micro_cents: 0,
357 principal_did_hash: q_hash(&profile.public_did),
358 mcp_intent_frame_hash,
359 output_mode,
360 clearance_ceiling: 0,
361 max_sentinel_depth: 32,
362 active_profile: packet.active_profile.clone(),
363 };
364
365 if is_inference_cancelled() {
366 return empty("Generation cancelled");
367 }
368
369 if graph_mutation {
370 return run_orchestrated_inference(
371 session_id,
372 &agent,
373 &agent_cfg,
374 &packet,
375 &retrieval,
376 intent,
377 started,
378 Path::new(&storage),
379 empty,
380 );
381 }
382
383 match agent.validate_intent(&intent) {
384 WebizenVerdict::Deny { reason, .. } => return empty(reason),
385 WebizenVerdict::DenyWithExplanation { explanation, .. } => return empty(&explanation),
386 WebizenVerdict::RequireReconfirmation { reason } => return empty(&reason),
387 _ => {}
388 }
389
390 let t0 = std::time::Instant::now();
391 let output = if let Some(cb) = on_token {
392 let (text, mut prov, tokens, semantic_quin) = agent.infer_local_model_streaming(
393 &packet.augmented_prompt,
394 &packet.graph_context_json,
395 Some(move |delta: String| {
396 if !is_inference_cancelled() {
397 cb(delta);
398 }
399 }),
400 );
401 prov.extend(retrieval.provenance_hashes.iter().copied());
402 prov.sort_unstable();
403 prov.dedup();
404 AgentOutput {
405 text,
406 semantic_quin,
407 provenance_quins: prov,
408 tokens_generated: tokens,
409 inference_duration_ms: t0.elapsed().as_millis() as u64,
410 peak_memory_bytes: 0,
411 }
412 } else {
413 match agent.infer(&packet.augmented_prompt, &packet.graph_context_json) {
414 Ok(mut o) => {
415 o.provenance_quins
416 .extend(retrieval.provenance_hashes.iter().copied());
417 o.provenance_quins.sort_unstable();
418 o.provenance_quins.dedup();
419 o
420 }
421 Err(AgentError::WebizenDenied { reason, .. }) => return empty(&reason),
422 Err(AgentError::SieveMisaligned) => {
423 return empty(
424 "Shield — sieve misaligned: model output did not match graph grammar.",
425 );
426 }
427 Err(e) => return empty(&format!("{e:?}")),
428 }
429 };
430
431 if is_inference_cancelled() {
432 return cancelled_result(&output, &retrieval, started);
433 }
434
435 match agent.validate_output(&output) {
436 WebizenVerdict::Deny { reason, .. } => {
437 return blocked_result(&output, &retrieval, started, reason.to_string());
438 }
439 WebizenVerdict::DenyWithExplanation { explanation, .. } => {
440 return blocked_result(&output, &retrieval, started, explanation);
441 }
442 _ => {}
443 }
444
445 let _ = persist_citations(session_id, Path::new(&storage), &output, &retrieval);
446
447 crate::model_lifecycle::record_llm_memory_sample(
448 agent
449 .memory_used_bytes
450 .load(std::sync::atomic::Ordering::Relaxed),
451 );
452 finalize_success_result(
453 output, &retrieval, started, &agent_cfg, false, 0, false, None,
454 )
455}
456
457fn run_ollama_chat_turn(
463 session_id: &str,
464 agent_cfg: &crate::chat_agents::ParticipantAgentConfig,
465 packet: &InferenceContextPacket,
466 retrieval: &RetrievalBundle,
467 settings: &crate::inference_backend::InferenceBackendSettings,
468 on_token: Option<Arc<dyn Fn(String) + Send + Sync>>,
469 started: std::time::Instant,
470 storage: &Path,
471 empty: impl Fn(&str) -> ChatInferenceResult,
472) -> ChatInferenceResult {
473 if is_inference_cancelled() {
474 return empty("Generation cancelled");
475 }
476
477 let harness = crate::ollama_harness::OllamaHarness::from_settings(settings);
478 let system = "You are a Webizen/Qualia assistant. Ground answers in the provided graph context when present. Prefer precise, citation-aware replies. Do not invent legal or medical facts.";
479 let user = packet.augmented_prompt.as_str();
480
481 let generation = match harness.generate(system, user) {
482 Ok(g) => g,
483 Err(e) => {
484 return empty(&format!(
485 "Ollama harness failed ({url}): {e}. Check Settings → Ollama (base URL, model, daemon running).",
486 url = harness.base_url
487 ));
488 }
489 };
490
491 if is_inference_cancelled() {
492 return empty("Generation cancelled");
493 }
494
495 if let Some(cb) = on_token.as_ref() {
497 if !generation.text.is_empty() {
498 cb(generation.text.clone());
499 }
500 }
501
502 let mut provenance = retrieval.provenance_hashes.clone();
503 provenance.sort_unstable();
504 provenance.dedup();
505
506 let tokens = generation.eval_count.unwrap_or(0);
507 let output = AgentOutput {
508 text: generation.text,
509 semantic_quin: None,
510 provenance_quins: provenance,
511 tokens_generated: tokens,
512 inference_duration_ms: started.elapsed().as_millis() as u64,
513 peak_memory_bytes: 0,
514 };
515
516 if output.text.trim().is_empty() {
517 return empty("Ollama returned an empty completion");
518 }
519
520 if !retrieval.citations.is_empty() && output.provenance_quins.is_empty() {
522 return empty("Ollama turn produced no provenance hashes from graph retrieval");
523 }
524
525 let _ = persist_citations(session_id, storage, &output, retrieval);
526 let mut result =
527 finalize_success_result(output, retrieval, started, agent_cfg, false, 0, false, None);
528 result.model_id = Some(generation.model);
529 result.agent_backend = Some("ollama".into());
530 result
531}
532
533fn run_orchestrated_inference(
534 session_id: &str,
535 agent: &LocalLlmAgent,
536 agent_cfg: &crate::chat_agents::ParticipantAgentConfig,
537 packet: &InferenceContextPacket,
538 retrieval: &RetrievalBundle,
539 intent: AgentIntent,
540 started: std::time::Instant,
541 storage: &Path,
542 empty: impl Fn(&str) -> ChatInferenceResult,
543) -> ChatInferenceResult {
544 let orch = crate::model_lifecycle::task_orchestrator();
545 let mut suspended = crate::guardianship::suspended_queue()
546 .lock()
547 .expect("suspended_queue");
548 let mut result = orch.orchestrate_inference(
549 agent,
550 &packet.augmented_prompt,
551 &packet.graph_context_json,
552 intent.clone(),
553 Some(&mut *suspended),
554 );
555
556 if let OrchestrationResult::Blocked {
557 rule_violated,
558 reason,
559 } = &result
560 {
561 if should_retry_symbolic_block(*rule_violated, reason) {
562 let corrective_prompt = build_corrective_retry_prompt(packet, reason);
563 result = orch.orchestrate_inference(
564 agent,
565 &corrective_prompt,
566 &packet.graph_context_json,
567 intent,
568 Some(&mut *suspended),
569 );
570 }
571 }
572
573 match result {
574 OrchestrationResult::Committed {
575 text,
576 mut provenance_quins,
577 semantic_quin,
578 wal_committed,
579 wal_suspended,
580 suspended_agreement_id,
581 } => {
582 provenance_quins.extend(retrieval.provenance_hashes.iter().copied());
583 provenance_quins.sort_unstable();
584 provenance_quins.dedup();
585
586 let sieve_tokens = if semantic_quin.is_some() { 3 } else { 0 };
587 let output = AgentOutput {
588 text,
589 semantic_quin,
590 provenance_quins,
591 tokens_generated: sieve_tokens,
592 inference_duration_ms: started.elapsed().as_millis() as u64,
593 peak_memory_bytes: 0,
594 };
595 let _ = persist_citations(session_id, storage, &output, retrieval);
596 crate::model_lifecycle::record_llm_memory_sample(
597 agent
598 .memory_used_bytes
599 .load(std::sync::atomic::Ordering::Relaxed),
600 );
601 finalize_success_result(
602 output,
603 retrieval,
604 started,
605 agent_cfg,
606 wal_committed,
607 sieve_tokens.min(255) as u8,
608 wal_suspended,
609 suspended_agreement_id,
610 )
611 }
612 OrchestrationResult::Blocked { reason, .. } => empty(reason),
613 OrchestrationResult::Failed(ref msg) if msg.contains("SieveMisaligned") => {
614 empty("Shield — sieve misaligned: model output did not match graph grammar.")
615 }
616 OrchestrationResult::Failed(msg) => empty(&msg),
617 }
618}
619
620fn should_retry_symbolic_block(rule_violated: u64, reason: &str) -> bool {
621 rule_violated == q_hash("q42:N3Compiler")
622 || reason.contains("SHACL")
623 || reason.contains("parseable N3")
624}
625
626fn build_corrective_retry_prompt(packet: &InferenceContextPacket, reason: &str) -> String {
627 let mut lines = vec![packet.augmented_prompt.clone()];
628 lines.push("[Symbolic corrective prompt]".to_string());
629 lines.push(format!(
630 "Your previous structured output was blocked by the deterministic SHACL/N3 gate: {reason}"
631 ));
632 if !packet.routed_ontology_ids.is_empty() {
633 lines.push(format!(
634 "Routed ontologies for this turn: {}",
635 packet.routed_ontology_ids.join(", ")
636 ));
637 }
638 if !packet.context_namespaces.is_empty() {
639 lines.push(format!(
640 "Context namespaces: {}",
641 packet
642 .context_namespaces
643 .iter()
644 .map(|h| format!("0x{h:016x}"))
645 .collect::<Vec<_>>()
646 .join(", ")
647 ));
648 }
649 lines.push(
650 "Emit only grounded N3 assertions or graph-mutation output that stays within the routed ontology predicates and shape expectations."
651 .to_string(),
652 );
653 lines.join("\n")
654}
655
656fn finalize_success_result(
657 output: AgentOutput,
658 retrieval: &RetrievalBundle,
659 started: std::time::Instant,
660 agent_cfg: &crate::chat_agents::ParticipantAgentConfig,
661 wal_committed: bool,
662 sieve_token_count: u8,
663 wal_suspended: bool,
664 suspended_agreement_id: Option<u64>,
665) -> ChatInferenceResult {
666 let duration_ms = started.elapsed().as_millis() as u64;
667 crate::model_lifecycle::record_last_decode_tok_s(output.tokens_generated, duration_ms);
668 ChatInferenceResult {
669 text: output.text,
670 provenance_hashes: output
671 .provenance_quins
672 .iter()
673 .map(|h| format!("0x{h:016x}"))
674 .collect(),
675 citations: retrieval.citations.clone(),
676 retrieval_triple_count: retrieval.triple_count,
677 tokens_generated: output.tokens_generated,
678 inference_duration_ms: duration_ms,
679 committed: true,
680 block_reason: None,
681 sub_agent_of: Some(agent_cfg.principal_did.clone()),
682 agent_did: Some(agent_cfg.sub_agent_did.clone()),
683 model_id: agent_cfg.model_id.clone(),
684 agent_backend: Some(agent_cfg.backend.as_str().to_string()),
685 semantic_quin: output.semantic_quin.map(quin_to_fields),
686 wal_committed,
687 sieve_token_count,
688 shield_alert: false,
689 axiom_bounds_label: None,
690 wal_suspended,
691 suspended_agreement_id,
692 }
693}
694
695fn cancelled_result(
696 output: &AgentOutput,
697 retrieval: &RetrievalBundle,
698 started: std::time::Instant,
699) -> ChatInferenceResult {
700 ChatInferenceResult {
701 text: output.text.clone(),
702 provenance_hashes: vec![],
703 citations: retrieval.citations.clone(),
704 retrieval_triple_count: retrieval.triple_count,
705 tokens_generated: output.tokens_generated,
706 inference_duration_ms: started.elapsed().as_millis() as u64,
707 committed: false,
708 block_reason: Some("Generation cancelled".to_string()),
709 sub_agent_of: None,
710 agent_did: None,
711 model_id: None,
712 agent_backend: None,
713 semantic_quin: output.semantic_quin.map(quin_to_fields),
714 wal_committed: false,
715 sieve_token_count: 0,
716 shield_alert: false,
717 axiom_bounds_label: None,
718 wal_suspended: false,
719 suspended_agreement_id: None,
720 }
721}
722
723fn quin_to_fields(q: NQuin) -> [u64; 6] {
724 [
725 q.subject,
726 q.predicate,
727 q.object,
728 q.context,
729 q.metadata,
730 q.parity,
731 ]
732}
733
734fn resolve_sieve_lex_path(
735 storage: &Path,
736 env: &crate::chat_session::ChatEnvironment,
737 model_path: &str,
738 preferred_ontology_ids: &[String],
739) -> Option<String> {
740 let index = storage.join("Index");
741 let ordered_ids = if preferred_ontology_ids.is_empty() {
742 env.ontology_ids.clone()
743 } else {
744 let mut ids = preferred_ontology_ids.to_vec();
745 for ont_id in &env.ontology_ids {
746 if !ids.contains(ont_id) {
747 ids.push(ont_id.clone());
748 }
749 }
750 ids
751 };
752 for ont_id in &ordered_ids {
753 let q42 = index.join(format!("{ont_id}.q42"));
754 if q42.is_file() {
755 if qualia_core_db::q42_volume::is_unified_volume(&q42).ok() == Some(true) {
756 return Some(q42.to_string_lossy().into_owned());
757 }
758 }
759 if let Some(lex) = crate::chat_ontology::resolve_wordnet_lex(&q42) {
760 return Some(lex.to_string_lossy().into_owned());
761 }
762 let lex_sibling = index.join(format!("{ont_id}.q42.lex"));
763 if lex_sibling.is_file() {
764 return Some(lex_sibling.to_string_lossy().into_owned());
765 }
766 }
767 if let Some(q42) = crate::chat_ontology::resolve_wordnet_q42(storage) {
768 if let Some(lex) = crate::chat_ontology::resolve_wordnet_lex(&q42) {
769 return Some(lex.to_string_lossy().into_owned());
770 }
771 }
772 let schema_q42 = "data/schemaorg/30.0/schemaorg-current-https.q42";
773 if Path::new(schema_q42).is_file() {
774 return Some(schema_q42.to_string());
775 }
776 let schema_lex = "data/schemaorg/30.0/schemaorg-current-https.q42.lex";
777 if Path::new(schema_lex).is_file() {
778 return Some(schema_lex.to_string());
779 }
780 let mut p = Path::new(model_path).to_path_buf();
781 if let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_string) {
782 p.set_file_name(format!("{stem}.q42.lex"));
783 if p.is_file() {
784 return Some(p.to_string_lossy().into_owned());
785 }
786 }
787 None
788}
789
790fn blocked_result(
791 output: &AgentOutput,
792 retrieval: &RetrievalBundle,
793 started: std::time::Instant,
794 reason: String,
795) -> ChatInferenceResult {
796 ChatInferenceResult {
797 text: output.text.clone(),
798 provenance_hashes: output
799 .provenance_quins
800 .iter()
801 .map(|h| format!("0x{h:016x}"))
802 .collect(),
803 citations: retrieval.citations.clone(),
804 retrieval_triple_count: retrieval.triple_count,
805 tokens_generated: output.tokens_generated,
806 inference_duration_ms: started.elapsed().as_millis() as u64,
807 committed: false,
808 block_reason: Some(reason.clone()),
809 sub_agent_of: None,
810 agent_did: None,
811 model_id: None,
812 agent_backend: None,
813 semantic_quin: output.semantic_quin.map(quin_to_fields),
814 wal_committed: false,
815 sieve_token_count: 0,
816 shield_alert: reason.contains("Shield"),
817 axiom_bounds_label: None,
818 wal_suspended: false,
819 suspended_agreement_id: None,
820 }
821}
822
823fn empty_result(
824 started: std::time::Instant,
825 reason: &str,
826 bounds_label: Option<&str>,
827) -> ChatInferenceResult {
828 let shield = reason.contains("Shield");
829 ChatInferenceResult {
830 text: String::new(),
831 provenance_hashes: vec![],
832 citations: vec![],
833 retrieval_triple_count: 0,
834 tokens_generated: 0,
835 inference_duration_ms: started.elapsed().as_millis() as u64,
836 committed: false,
837 block_reason: Some(reason.to_string()),
838 sub_agent_of: None,
839 agent_did: None,
840 model_id: None,
841 agent_backend: None,
842 semantic_quin: None,
843 wal_committed: false,
844 sieve_token_count: 0,
845 shield_alert: shield,
846 axiom_bounds_label: if shield {
847 bounds_label.map(str::to_string)
848 } else {
849 None
850 },
851 wal_suspended: false,
852 suspended_agreement_id: None,
853 }
854}
855
856const ANACHRONISM_TERMS: &[&str] = &[
857 "internet",
858 "smartphone",
859 "blockchain",
860 "covid",
861 "tiktok",
862 "youtube",
863 "wifi",
864 "email",
865];
866
867pub fn validate_axiom_preflight(
869 prompt: &str,
870 bounds: &context_binding::AxiomBounds,
871) -> Result<(), String> {
872 if bounds.start_year > bounds.end_year {
873 return Err("Shield — invalid axiom bounds (start year is after end year)".to_string());
874 }
875
876 for year in extract_years(prompt) {
877 if year < bounds.start_year || year > bounds.end_year {
878 return Err(format!(
879 "Shield — Fact clipped — outside axiom bounds [{}–{}]",
880 bounds.start_year, bounds.end_year
881 ));
882 }
883 }
884
885 if bounds.end_year <= 1935 {
886 let lower = prompt.to_ascii_lowercase();
887 for term in ANACHRONISM_TERMS {
888 if lower.contains(term) {
889 return Err(format!(
890 "Shield — Fact clipped — anachronism detected outside axiom bounds [{}–{}]",
891 bounds.start_year, bounds.end_year
892 ));
893 }
894 }
895 }
896
897 Ok(())
898}
899
900fn extract_years(text: &str) -> Vec<u16> {
901 let bytes = text.as_bytes();
902 let mut years = Vec::new();
903 let mut i = 0;
904 while i + 4 <= bytes.len() {
905 if bytes[i..i + 4].iter().all(|b| b.is_ascii_digit()) {
906 if let Ok(s) = std::str::from_utf8(&bytes[i..i + 4]) {
907 if let Ok(y) = s.parse::<u16>() {
908 if (1000..=2999).contains(&y) {
909 years.push(y);
910 }
911 }
912 }
913 i += 4;
914 } else {
915 i += 1;
916 }
917 }
918 years
919}
920
921fn build_augmented_packet(
922 storage: &Path,
923 session_id: &str,
924 env: &crate::chat_session::ChatEnvironment,
925 user_prompt: &str,
926 retrieval: &RetrievalBundle,
927 catalog: &qualia_core_db::resource_catalog::ResourceCatalog,
928 routing: &OntologyRoutingDecision,
929 semantic_profile: Option<&crate::agent_registry::AgentSemanticProfile>,
930 reply_to_fragment_id: Option<&str>,
931) -> Result<InferenceContextPacket, String> {
932 let mut packet = context_binding::build_inference_packet(env, user_prompt, catalog)
933 .map_err(|e| e.to_string())?;
934 packet.context_namespaces = routing.context_namespaces.clone();
935 packet.routed_ontology_ids = routing.ontology_ids.clone();
936 packet.routing_brief = routing.routing_brief.clone();
937
938 let thread_block = if let Some(fragment_id) = reply_to_fragment_id {
939 crate::chat_graph::build_thread_context_block(storage, session_id, fragment_id, 6)
940 .unwrap_or_default()
941 } else {
942 String::new()
943 };
944
945 let files_block =
946 crate::chat_files::build_chat_files_context_block(storage, session_id, 12_000);
947
948 let inforg_block = crate::cml_context::retrieve_context(storage, user_prompt, 6);
950
951 let session = crate::chat_session::load_session(storage, session_id).ok();
952 let cooperative_block = session
953 .as_ref()
954 .map(|s| {
955 crate::chat_agents::build_cooperative_agents_block(
956 storage,
957 session_id,
958 &s.messages,
959 &s.meta.participants,
960 )
961 })
962 .unwrap_or_default();
963 let semantic_block = semantic_profile
964 .map(crate::agent_registry::AgentSemanticProfile::briefing)
965 .unwrap_or_default();
966
967 let enriched_context = serde_json::json!({
968 "environment": serde_json::from_str::<serde_json::Value>(&packet.graph_context_json).unwrap_or_default(),
969 "axiom_bounds": {
970 "start_year": env.axiom_bounds.start_year,
971 "end_year": env.axiom_bounds.end_year,
972 "spatial_context_hash": format!("0x{:016x}", env.axiom_bounds.spatial_context_hash),
973 "spatial_context_label": env.axiom_bounds.spatial_context_label,
974 "label": env.axiom_bounds.label(),
975 },
976 "retrieval": {
977 "triple_count": retrieval.triple_count,
978 "daemon_match_count": retrieval.daemon_match_count,
979 "citations": retrieval.citations,
980 "provenance_hashes": retrieval.provenance_hashes.iter().map(|h| format!("0x{h:016x}")).collect::<Vec<_>>(),
981 },
982 "ontology_routing": {
983 "ontology_ids": routing.ontology_ids.clone(),
984 "matched_terms": routing.matched_terms.clone(),
985 "context_namespaces": routing.context_namespaces.iter().map(|h| format!("0x{h:016x}")).collect::<Vec<_>>(),
986 "brief": routing.routing_brief.clone(),
987 },
988 "agent_semantic_profile": semantic_profile,
989 "chat_graph_thread": thread_block,
990 "chat_files": files_block,
991 "cooperative_agents": cooperative_block,
992 "inforg_context": inforg_block.clone(),
993 });
994 packet.graph_context_json =
995 serde_json::to_string(&enriched_context).unwrap_or(packet.graph_context_json);
996
997 if thread_block.is_empty() {
998 packet.augmented_prompt = format!(
999 "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n---\nUser: {}\n---",
1000 env.capability_briefing,
1001 semantic_block,
1002 routing.routing_brief,
1003 cooperative_block,
1004 files_block,
1005 retrieval.context_block,
1006 inforg_block,
1007 user_prompt
1008 );
1009 } else {
1010 packet.augmented_prompt = format!(
1011 "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n---\nUser (replying to graph fragment): {}\n---",
1012 env.capability_briefing,
1013 semantic_block,
1014 routing.routing_brief,
1015 cooperative_block,
1016 thread_block,
1017 files_block,
1018 retrieval.context_block,
1019 inforg_block,
1020 user_prompt
1021 );
1022 }
1023
1024 Ok(packet)
1025}
1026
1027fn persist_citations(
1028 session_id: &str,
1029 storage: &Path,
1030 output: &AgentOutput,
1031 retrieval: &RetrievalBundle,
1032) -> Result<(), String> {
1033 let dir = chat_session::chats_dir(storage).join(session_id);
1034 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
1035 let wal_path = dir.join("citations.wal");
1036 let mut wal = WriteAheadLog::open(&wal_path).map_err(|e| e.to_string())?;
1037
1038 let session_subject = q_hash(&format!("chat:session:{session_id}"));
1039 for hash in &output.provenance_quins {
1040 let object = *hash & OBJECT_HASH_MASK;
1041 let quin = NQuin {
1042 subject: session_subject,
1043 predicate: q_hash("q42:groundedBy"),
1044 object,
1045 context: q_hash("chat:agent"),
1046 metadata: 0,
1047 parity: session_subject ^ q_hash("q42:groundedBy") ^ object ^ q_hash("chat:agent"),
1048 };
1049 wal.append_mutation(&quin).map_err(|e| e.to_string())?;
1050 }
1051
1052 let meta_path = dir.join("last_inference.json");
1053 let meta = serde_json::json!({
1054 "provenance_count": output.provenance_quins.len(),
1055 "citations": retrieval.citations,
1056 "tokens": output.tokens_generated,
1057 });
1058 std::fs::write(
1059 meta_path,
1060 serde_json::to_string_pretty(&meta).unwrap_or_default(),
1061 )
1062 .map_err(|e| e.to_string())?;
1063 Ok(())
1064}
1065
1066pub fn stream_event_token(delta: &str) -> String {
1068 serde_json::json!({"event":"token","data":delta}).to_string()
1069}
1070
1071pub fn stream_event_done(result: &ChatInferenceResult) -> String {
1072 serde_json::json!({"event":"done","data":result}).to_string()
1073}
1074
1075pub fn stream_event_error(msg: &str) -> String {
1076 serde_json::json!({"event":"error","data":msg}).to_string()
1077}