Skip to main content

qualia_client_core/
chat_inference.rs

1//! End-to-end Webizen-gated chat inference with retrieval, streaming, and provenance.
2
3use 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
28/// One consumer GPU normally has one practical full-model decode lane. Named
29/// local-agent turns acquire this cold-path lease before switching/using the
30/// resident model, preventing competing loads and VRAM overcommit.
31static 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    /// Super-Quin fields when the neuro-symbolic sieve completes (6 × u64, zero JSON objects).
52    #[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    /// Bilateral micro-commons mutation awaiting guardian co-signature.
63    #[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    /// Override session environment: route through sieve + orchestrator WAL path.
85    pub graph_mutation: bool,
86    /// Pointed ontology profile of the named agent serving this turn.  The
87    /// profile narrows relevance routing; it does not grant access itself.
88    pub semantic_profile: Option<crate::agent_registry::AgentSemanticProfile>,
89    /// Optional per-agent ontology/data-source boundary. An empty list keeps
90    /// the session's existing scope; a populated list can only narrow it.
91    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
107/// Run a local turn using a named roster agent.  A pinned model is activated on
108/// demand before inference; the lifecycle implementation owns any resident
109/// mapping replacement.  This is a cold control-path operation and never runs
110/// inside the decode/evaluator hot path.
111pub 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            // Local agents share a consumer device's bounded decode lane.  A
158            // model switch is explicit: scrub the old resident mapping before
159            // asking the lifecycle to map and activate the selected agent's
160            // model.  Agent definitions and queued-job records remain intact.
161            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    // Interactive chat uses the sampling chain (temperature / top-p / repeat-penalty) instead of
195    // greedy argmax, which collapses instruct models into repetition / degenerate loops. Benchmarks
196    // keep the unconfigured greedy default. See qualia_core_db::sampler::SamplerConfig::chat_default.
197    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    // Native GGUF path requires an active model; optional Ollama harness does not.
218    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    // Session agent backend tracks the global preference for this turn.
250    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    // ── Optional Ollama harness (retrieval + ontology routing still Qualia) ──
289    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
457/// Chat turn via optional Ollama HTTP harness.
458///
459/// Qualia still owns retrieval, ontology routing, axiom preflight, and
460/// citation provenance. Generation is delegated to Ollama so ETL/chat work
461/// can proceed while native GGUF inference is unavailable.
462fn 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    // Surface full completion once (streaming wire-up for Ollama is a follow-up).
496    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    // Provenance from retrieval is required for grounded turns when graph context exists.
521    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
867/// Pre-flight axiom bounds check before KV prefill / orchestrator dispatch.
868pub 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    // Inforg (CML) context: prior turns the person marked with #project/#topic/#task, permission-gated.
949    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
1066/// NDJSON stream events for Flutter FRB.
1067pub 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}