Skip to main content

qualia_core_db/inference/
post_turn_verify.rs

1//! Post-turn verification — "generate first, heal second".
2//!
3//! Alternative to mid-decode Webizen Sentinel gating: run the LLM at full resident
4//! speed (like Ollama), then **verify and self-heal** the completed draft against
5//! the quant-graph fact table and a lightweight CML-shaped claim extract before
6//! finalising the turn.
7//!
8//! # Why this exists
9//! Mid-token governance (Phase-8 rings) is architecturally important for hard
10//! fail-closed signals, but continuous logit inspection is **not** what makes
11//! Qualia slower than Ollama — the GPU GEMV path is. Post-turn verify still
12//! earns its keep: it recovers quality from aggressive INT4 without taxing every
13//! token, and produces an auditable HTML/CML surface for the principal.
14//!
15//! # Pipeline
16//! 1. LLM emits plain draft (no mid-decode interrupt).
17//! 2. Extract crude claims / capital-style facts from draft + prompt.
18//! 3. `quant_graph_grounding` repair when high-stakes needles mismatch.
19//! 4. Emit `VerifiedTurn` with plain final text + HTML presentation + CML Turtle.
20
21use crate::quant_graph_grounding::{ground_generation, GroundingResult};
22
23/// One atomic check against the local fact / ontology surface.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct VerifyCheck {
26    pub id: String,
27    pub ok: bool,
28    pub detail: String,
29}
30
31/// Result of post-turn verification / self-heal.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct VerifiedTurn {
34    /// Plain text for logs / agents that want unadorned prose.
35    pub final_text: String,
36    /// HTML presentation of the answer + verification panel (CML-ish surface).
37    pub display_html: String,
38    /// Turtle sketch of cml:Proposed / observation claims (ingestible later).
39    pub cml_turtle: String,
40    /// Whether the draft was rewritten by the graph.
41    pub repaired: bool,
42    /// Checks performed (pass/fail).
43    pub checks: Vec<VerifyCheck>,
44    pub grounding_reason: Option<String>,
45}
46
47/// Env: return HTML as the primary `text` field from the agent when FastVerify is on.
48#[inline]
49pub fn return_html_as_text() -> bool {
50    matches!(
51        std::env::var("QUALIA_RETURN_VERIFY_HTML").ok().as_deref(),
52        Some("1") | Some("true") | Some("on")
53    ) || crate::inference_modes::fast_verify_html_default()
54}
55
56/// Full post-turn pass: graph ground + claim checks + HTML/CML packaging.
57pub fn verify_and_heal_turn(prompt: &str, draft: &str) -> VerifiedTurn {
58    let g: GroundingResult = ground_generation(prompt, draft);
59    let mut checks = Vec::new();
60
61    // Check 1: non-empty draft
62    checks.push(VerifyCheck {
63        id: "nonempty".into(),
64        ok: !draft.trim().is_empty(),
65        detail: if draft.trim().is_empty() {
66            "draft empty".into()
67        } else {
68            format!("{} chars", draft.len())
69        },
70    });
71
72    // Check 2: graph grounding
73    if let Some(ref reason) = g.reason {
74        checks.push(VerifyCheck {
75            id: format!("graph:{reason}"),
76            ok: !g.repaired,
77            detail: if g.repaired {
78                format!("repaired → {}", truncate(&g.text, 80))
79            } else {
80                "grounded (answer_ok present)".into()
81            },
82        });
83    } else {
84        checks.push(VerifyCheck {
85            id: "graph:no_match".into(),
86            ok: true,
87            detail: "no high-stakes fact needles matched prompt".into(),
88        });
89    }
90
91    // Check 3: crude self-consistency — if repaired, final must mention an answer_ok token
92    // from the grounding result text (Paris etc. already in repair string).
93    if g.repaired {
94        checks.push(VerifyCheck {
95            id: "heal_applied".into(),
96            ok: true,
97            detail: "quant-graph replaced ungrounded draft".into(),
98        });
99    }
100
101    // Check 4: extract simple [[wiki]] / #topic CML tags from prompt for context binding
102    let tags = extract_simple_cml_tags(prompt);
103    if !tags.is_empty() {
104        checks.push(VerifyCheck {
105            id: "cml_tags".into(),
106            ok: true,
107            detail: format!("{} context tag(s) from prompt", tags.len()),
108        });
109    }
110
111    let final_text = g.text.clone();
112    let display_html = render_turn_html(prompt, draft, &final_text, g.repaired, &checks, &tags);
113    let cml_turtle = render_cml_turtle(prompt, &final_text, g.repaired, &checks, &tags);
114
115    log::info!(
116        "post_turn_verify|repaired={}|checks={}|reason={:?}",
117        g.repaired,
118        checks.len(),
119        g.reason
120    );
121
122    VerifiedTurn {
123        final_text,
124        display_html,
125        cml_turtle,
126        repaired: g.repaired,
127        checks,
128        grounding_reason: g.reason,
129    }
130}
131
132/// When FastVerify (or quant-graph post path) is active, heal draft → final presentation.
133pub fn maybe_verify_turn(prompt: &str, draft: &str) -> VerifiedTurn {
134    if crate::inference_modes::post_turn_verify_enabled() {
135        verify_and_heal_turn(prompt, draft)
136    } else {
137        // Identity wrap (still produce HTML if caller asks later).
138        VerifiedTurn {
139            final_text: draft.to_string(),
140            display_html: format!(
141                "<article class=\"q-turn\"><p>{}</p></article>",
142                escape_html(draft)
143            ),
144            cml_turtle: String::new(),
145            repaired: false,
146            checks: vec![],
147            grounding_reason: None,
148        }
149    }
150}
151
152fn truncate(s: &str, n: usize) -> String {
153    if s.len() <= n {
154        s.to_string()
155    } else {
156        format!("{}…", &s[..n])
157    }
158}
159
160fn escape_html(s: &str) -> String {
161    s.chars()
162        .map(|c| match c {
163            '&' => "&amp;".into(),
164            '<' => "&lt;".into(),
165            '>' => "&gt;".into(),
166            '"' => "&quot;".into(),
167            _ => c.to_string(),
168        })
169        .collect()
170}
171
172/// Minimal tag parse: `#topic:x` `#project:y` `[[concept]]` (no client-core dependency).
173fn extract_simple_cml_tags(text: &str) -> Vec<(String, String)> {
174    let mut out = Vec::new();
175    // [[concept]]
176    let mut rest = text;
177    while let Some(start) = rest.find("[[") {
178        let after = &rest[start + 2..];
179        if let Some(end) = after.find("]]") {
180            let label = after[..end].trim();
181            if !label.is_empty() {
182                out.push(("general".into(), label.to_string()));
183            }
184            rest = &after[end + 2..];
185        } else {
186            break;
187        }
188    }
189    for raw in text.split_whitespace() {
190        let tok = raw.trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?' | ';' | ')' | '('));
191        if let Some(body) = tok.strip_prefix('#') {
192            if let Some((k, v)) = body.split_once(':') {
193                let tier = k.to_ascii_lowercase();
194                if matches!(
195                    tier.as_str(),
196                    "topic" | "project" | "task" | "pursuit" | "general"
197                ) {
198                    let label = v.replace('_', " ");
199                    if !label.is_empty() {
200                        out.push((tier, label));
201                    }
202                }
203            } else if !body.is_empty() {
204                out.push(("topic".into(), body.replace('_', " ")));
205            }
206        }
207    }
208    out
209}
210
211fn render_turn_html(
212    prompt: &str,
213    draft: &str,
214    final_text: &str,
215    repaired: bool,
216    checks: &[VerifyCheck],
217    tags: &[(String, String)],
218) -> String {
219    let status = if repaired {
220        "<span class=\"q-badge q-repaired\">self-healed</span>"
221    } else {
222        "<span class=\"q-badge q-ok\">verified</span>"
223    };
224    let mut checks_html = String::from("<ul class=\"q-checks\">");
225    for c in checks {
226        let mark = if c.ok { "✓" } else { "✗" };
227        let cls = if c.ok { "pass" } else { "fail" };
228        checks_html.push_str(&format!(
229            "<li class=\"{cls}\"><code>{mark} {}</code> — {}</li>",
230            escape_html(&c.id),
231            escape_html(&c.detail)
232        ));
233    }
234    checks_html.push_str("</ul>");
235
236    let mut tags_html = String::new();
237    if !tags.is_empty() {
238        tags_html.push_str("<p class=\"q-cml-tags\">");
239        for (t, l) in tags {
240            tags_html.push_str(&format!(
241                "<span class=\"q-tag\">#{}:{}</span> ",
242                escape_html(t),
243                escape_html(l)
244            ));
245        }
246        tags_html.push_str("</p>");
247    }
248
249    let draft_block = if repaired {
250        format!(
251            "<details class=\"q-draft\"><summary>Original draft (pre-heal)</summary><pre>{}</pre></details>",
252            escape_html(draft)
253        )
254    } else {
255        String::new()
256    };
257
258    format!(
259        r#"<!DOCTYPE html>
260<html lang="en"><head><meta charset="utf-8"/><title>Qualia turn</title>
261<style>
262body{{font-family:system-ui,sans-serif;background:#0c0f14;color:#e2e8f0;margin:1.5rem}}
263.q-turn{{max-width:42rem;margin:auto}}
264.q-badge{{font-size:.7rem;padding:.15rem .5rem;border-radius:999px;font-weight:700}}
265.q-ok{{background:rgba(16,185,129,.2);color:#6ee7b7}}
266.q-repaired{{background:rgba(245,158,11,.2);color:#fcd34d}}
267.q-answer{{font-size:1.1rem;line-height:1.55;padding:1rem;border:1px solid rgba(148,163,184,.2);border-radius:12px;background:rgba(255,255,255,.04)}}
268.q-checks{{font-size:.85rem;line-height:1.6}}
269.q-checks .pass{{color:#86efac}}
270.q-checks .fail{{color:#fca5a5}}
271.q-meta{{font-size:.75rem;color:#94a3b8;margin-top:1.5rem}}
272.q-tag{{display:inline-block;margin:.15rem;padding:.1rem .4rem;border-radius:6px;background:rgba(59,130,246,.15);color:#93c5fd;font-size:.75rem}}
273pre{{white-space:pre-wrap;font-size:.8rem;opacity:.85}}
274</style></head><body>
275<article class="q-turn" data-qualia-verify="1">
276  <header><h1>Response {status}</h1>
277  <p class="q-meta">Prompt: {prompt}</p>{tags}
278  </header>
279  <section class="q-answer"><p>{answer}</p></section>
280  {draft}
281  <section><h2>Verification</h2>{checks}
282  <p class="q-meta">Post-turn path: generate → graph/CML verify → finalise. Mid-decode Sentinel skipped in FastVerify mode.</p>
283  </section>
284</article></body></html>"#,
285        status = status,
286        prompt = escape_html(&truncate(prompt, 200)),
287        tags = tags_html,
288        answer = escape_html(final_text).replace('\n', "<br/>"),
289        draft = draft_block,
290        checks = checks_html,
291    )
292}
293
294fn render_cml_turtle(
295    prompt: &str,
296    final_text: &str,
297    repaired: bool,
298    checks: &[VerifyCheck],
299    tags: &[(String, String)],
300) -> String {
301    let mut out = String::from(
302        "@prefix cml: <https://webizen.org/cml#> .\n@prefix q42: <https://ns.webizen.org/q42/> .\n\n",
303    );
304    out.push_str("<urn:qualia:turn:current> a cml:Turn ;\n");
305    out.push_str(&format!("  cml:prompt {} ;\n", ttl_str(prompt)));
306    out.push_str(&format!("  cml:finalText {} ;\n", ttl_str(final_text)));
307    out.push_str(&format!(
308        "  cml:selfHealed {} ;\n",
309        if repaired { "true" } else { "false" }
310    ));
311    out.push_str("  cml:verifyPath \"post-turn\" .\n\n");
312    for (i, c) in checks.iter().enumerate() {
313        out.push_str(&format!(
314            "<urn:qualia:check:{i}> a cml:VerifyCheck, cml:Proposed ;\n"
315        ));
316        out.push_str(&format!("  cml:id {} ;\n", ttl_str(&c.id)));
317        out.push_str(&format!(
318            "  cml:ok {} ;\n",
319            if c.ok { "true" } else { "false" }
320        ));
321        out.push_str(&format!("  cml:detail {} .\n\n", ttl_str(&c.detail)));
322    }
323    for (t, l) in tags {
324        out.push_str(&format!(
325            "<urn:qualia:tag:{}:{}> a cml:Proposed ;\n  cml:tier {} ;\n  cml:label {} .\n\n",
326            t,
327            l.chars()
328                .filter(|c| c.is_alphanumeric())
329                .collect::<String>(),
330            ttl_str(t),
331            ttl_str(l)
332        ));
333    }
334    out
335}
336
337fn ttl_str(s: &str) -> String {
338    format!(
339        "\"{}\"",
340        s.replace('\\', "\\\\")
341            .replace('"', "\\\"")
342            .replace('\n', "\\n")
343    )
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::inference_modes::{set_inference_mode, InferenceMode};
350    use crate::quant_graph_grounding::reset_fact_store_to_defaults;
351
352    #[test]
353    fn heals_wrong_capital() {
354        if std::env::var("QUALIA_INFERENCE_MODE").is_ok() {
355            return;
356        }
357        reset_fact_store_to_defaults();
358        set_inference_mode(InferenceMode::FastVerify);
359        let v = verify_and_heal_turn("What is the capital of France?", "I think it is Lyon.");
360        assert!(v.repaired);
361        assert!(v.final_text.to_ascii_lowercase().contains("paris"));
362        assert!(v.display_html.contains("self-healed") || v.display_html.contains("q-repaired"));
363        assert!(v.cml_turtle.contains("cml:Turn"));
364        set_inference_mode(InferenceMode::Portable);
365    }
366
367    #[test]
368    fn leaves_good_answer() {
369        reset_fact_store_to_defaults();
370        let v = verify_and_heal_turn(
371            "What is the capital of France?",
372            "The capital of France is Paris.",
373        );
374        assert!(!v.repaired);
375        assert!(v.final_text.contains("Paris"));
376    }
377}