Skip to main content

qualia_core_db/inference/
quant_graph_grounding.rs

1//! QuantGraph mode — selective grounding / repair against a **NQuin fact graph**.
2//!
3//! Aggressive quantization can drop nuance; this module is the neuro-symbolic
4//! counterpart: after the LLM proposes text, high-stakes fact patterns are checked
5//! against an in-process fact graph (NQuin triples + human repair strings).
6//!
7//! Gated by [`crate::inference_modes::quant_graph_grounding_enabled`].
8//! Does not run in Portable or CudaTc modes.
9//!
10//! # Graph model
11//! Each fact is a parity-valid `NQuin`:
12//! - subject  = place / entity (`q_hash`)
13//! - predicate = `q42:capitalOf` (or other relation)
14//! - object   = answer entity hash
15//! - context  = `q42:grounding-fact`
16//! plus cold-path strings for prompt needles, answer tokens, and repair text.
17//!
18//! Expand later: load from SPARQL / Wellfair graph / CBOR-LD package.
19
20use std::sync::{Mutex, OnceLock};
21
22use crate::{q_hash, NQuin};
23
24/// Predicate IRI for capital facts (hashed into quins).
25pub const P_CAPITAL_OF: &str = "https://ns.webizen.org/q42/capitalOf";
26/// Graph context for all grounding facts.
27pub const CTX_GROUNDING: &str = "https://ns.webizen.org/q42/grounding-fact";
28
29/// Result of a grounding pass.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct GroundingResult {
32    pub text: String,
33    pub repaired: bool,
34    /// Stable reason id (e.g. `capital_france`).
35    pub reason: Option<String>,
36    /// Object entity hash when a fact matched (for provenance).
37    pub object_hash: Option<u64>,
38}
39
40/// Cold-path fact: NQuin + strings for matching and repair.
41#[derive(Debug, Clone)]
42pub struct GroundingFact {
43    pub quin: NQuin,
44    /// All must appear in the prompt (lowercase).
45    pub prompt_needles: Vec<String>,
46    /// If any appears in the answer, treat as grounded.
47    pub answer_ok: Vec<String>,
48    pub repair: String,
49    pub reason: String,
50}
51
52fn make_quin(subject_iri: &str, object_iri: &str) -> NQuin {
53    let subject = q_hash(subject_iri);
54    let predicate = q_hash(P_CAPITAL_OF);
55    let object = q_hash(object_iri);
56    let context = q_hash(CTX_GROUNDING);
57    let metadata = 0u64;
58    let parity = subject ^ predicate ^ object ^ context ^ metadata;
59    NQuin {
60        subject,
61        predicate,
62        object,
63        context,
64        metadata,
65        parity,
66    }
67}
68
69fn seed_defaults() -> Vec<GroundingFact> {
70    // Prefer bundled TSV if present; else embedded minimal set.
71    if let Some(facts) = try_load_bundled_facts() {
72        if !facts.is_empty() {
73            return facts;
74        }
75    }
76    vec![
77        GroundingFact {
78            quin: make_quin(
79                "https://example.org/place/France",
80                "https://example.org/place/Paris",
81            ),
82            prompt_needles: vec!["capital".into(), "france".into()],
83            answer_ok: vec!["paris".into()],
84            repair: "The capital of France is Paris.".into(),
85            reason: "capital_france".into(),
86        },
87        GroundingFact {
88            quin: make_quin(
89                "https://example.org/place/Australia",
90                "https://example.org/place/Canberra",
91            ),
92            prompt_needles: vec!["capital".into(), "australia".into()],
93            answer_ok: vec!["canberra".into()],
94            repair: "The capital of Australia is Canberra.".into(),
95            reason: "capital_australia".into(),
96        },
97        GroundingFact {
98            quin: make_quin(
99                "https://example.org/place/Japan",
100                "https://example.org/place/Tokyo",
101            ),
102            prompt_needles: vec!["capital".into(), "japan".into()],
103            answer_ok: vec!["tokyo".into()],
104            repair: "The capital of Japan is Tokyo.".into(),
105            reason: "capital_japan".into(),
106        },
107    ]
108}
109
110/// Parse TSV lines: reason \\t needles; \\t answer_ok; \\t repair \\t place_iri \\t city_iri
111pub fn parse_facts_tsv(text: &str) -> Vec<GroundingFact> {
112    let mut out = Vec::new();
113    for line in text.lines() {
114        let line = line.trim();
115        if line.is_empty() || line.starts_with('#') {
116            continue;
117        }
118        let cols: Vec<&str> = line.split('\t').collect();
119        if cols.len() < 6 {
120            continue;
121        }
122        let reason = cols[0].trim().to_string();
123        let needles: Vec<String> = cols[1]
124            .split(';')
125            .map(|s| s.trim().to_ascii_lowercase())
126            .filter(|s| !s.is_empty())
127            .collect();
128        let answer_ok: Vec<String> = cols[2]
129            .split(';')
130            .map(|s| s.trim().to_ascii_lowercase())
131            .filter(|s| !s.is_empty())
132            .collect();
133        let repair = cols[3].trim().to_string();
134        let place_iri = cols[4].trim();
135        let city_iri = cols[5].trim();
136        if needles.is_empty() || answer_ok.is_empty() || repair.is_empty() {
137            continue;
138        }
139        out.push(GroundingFact {
140            quin: make_quin(place_iri, city_iri),
141            prompt_needles: needles,
142            answer_ok,
143            repair,
144            reason,
145        });
146    }
147    out
148}
149
150/// Load facts from a TSV file path. Returns count added (merge by reason).
151pub fn load_facts_from_tsv(path: &std::path::Path) -> Result<usize, String> {
152    let text =
153        std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
154    let facts = parse_facts_tsv(&text);
155    let n = facts.len();
156    for f in facts {
157        register_fact(f);
158    }
159    Ok(n)
160}
161
162fn try_load_bundled_facts() -> Option<Vec<GroundingFact>> {
163    for candidate in bundled_fact_candidates() {
164        if candidate.is_file() {
165            if let Ok(text) = std::fs::read_to_string(&candidate) {
166                let facts = parse_facts_tsv(&text);
167                if !facts.is_empty() {
168                    log::info!(
169                        "quant_graph|seed|bundled|{}|facts={}",
170                        candidate.display(),
171                        facts.len()
172                    );
173                    return Some(facts);
174                }
175            }
176        }
177    }
178    None
179}
180
181fn bundled_fact_candidates() -> Vec<std::path::PathBuf> {
182    let mut v = Vec::new();
183    if let Ok(p) = std::env::var("QUALIA_GROUNDING_FACTS") {
184        v.push(std::path::PathBuf::from(p));
185    }
186    // Crate-relative and workspace-relative paths.
187    let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
188    v.push(manifest.join("../../bundled/grounding/facts.tsv"));
189    v.push(manifest.join("../../../bundled/grounding/facts.tsv"));
190    if let Ok(cwd) = std::env::current_dir() {
191        v.push(cwd.join("bundled/grounding/facts.tsv"));
192    }
193    v
194}
195
196/// Re-seed from bundled TSV (or QUALIA_GROUNDING_FACTS), replacing current store.
197pub fn seed_facts_from_bundled() -> usize {
198    let facts = try_load_bundled_facts().unwrap_or_else(seed_defaults);
199    let n = facts.len();
200    if let Ok(mut g) = fact_store().lock() {
201        *g = facts;
202    }
203    n
204}
205
206fn fact_store() -> &'static Mutex<Vec<GroundingFact>> {
207    static STORE: OnceLock<Mutex<Vec<GroundingFact>>> = OnceLock::new();
208    STORE.get_or_init(|| Mutex::new(seed_defaults()))
209}
210
211/// Number of facts currently registered.
212pub fn fact_count() -> usize {
213    fact_store().lock().map(|g| g.len()).unwrap_or(0)
214}
215
216/// Export all fact quins (for tests / graph dump / later SPARQL seed).
217pub fn export_fact_quins(out: &mut [NQuin]) -> usize {
218    let Ok(guard) = fact_store().lock() else {
219        return 0;
220    };
221    let n = guard.len().min(out.len());
222    for (i, f) in guard.iter().take(n).enumerate() {
223        out[i] = f.quin;
224    }
225    n
226}
227
228/// Register (or replace by reason id) a grounding fact. Cold path.
229pub fn register_fact(fact: GroundingFact) {
230    if let Ok(mut g) = fact_store().lock() {
231        if let Some(i) = g.iter().position(|f| f.reason == fact.reason) {
232            g[i] = fact;
233        } else {
234            g.push(fact);
235        }
236    }
237}
238
239/// Convenience: capital-of fact from IRIs + match strings.
240pub fn register_capital_fact(
241    place_iri: &str,
242    city_iri: &str,
243    place_needle: &str,
244    city_needle: &str,
245    repair: &str,
246    reason: &str,
247) {
248    register_fact(GroundingFact {
249        quin: make_quin(place_iri, city_iri),
250        prompt_needles: vec!["capital".into(), place_needle.to_ascii_lowercase()],
251        answer_ok: vec![city_needle.to_ascii_lowercase()],
252        repair: repair.to_string(),
253        reason: reason.to_string(),
254    });
255}
256
257/// Reset store to seed defaults (tests) — re-reads bundled TSV when present.
258pub fn reset_fact_store_to_defaults() {
259    if let Ok(mut g) = fact_store().lock() {
260        *g = seed_defaults();
261    }
262}
263
264/// Apply quant-graph grounding when the mode is active; otherwise identity.
265pub fn maybe_ground_generation(prompt: &str, text: &str) -> GroundingResult {
266    if !crate::inference_modes::quant_graph_grounding_enabled() {
267        return GroundingResult {
268            text: text.to_string(),
269            repaired: false,
270            reason: None,
271            object_hash: None,
272        };
273    }
274    ground_generation(prompt, text)
275}
276
277/// Unconditional grounding pass (tests / CLI).
278pub fn ground_generation(prompt: &str, text: &str) -> GroundingResult {
279    let p = prompt.to_ascii_lowercase();
280    let a = text.to_ascii_lowercase();
281    let Ok(guard) = fact_store().lock() else {
282        return GroundingResult {
283            text: text.to_string(),
284            repaired: false,
285            reason: None,
286            object_hash: None,
287        };
288    };
289    for fact in guard.iter() {
290        if !fact.prompt_needles.iter().all(|s| p.contains(s.as_str())) {
291            continue;
292        }
293        if fact.answer_ok.iter().any(|s| a.contains(s.as_str())) {
294            return GroundingResult {
295                text: text.to_string(),
296                repaired: false,
297                reason: Some(fact.reason.clone()),
298                object_hash: Some(fact.quin.object),
299            };
300        }
301        log::info!(
302            "LLM_MODE|quant-graph|repair|{}|object={:#x}",
303            fact.reason,
304            fact.quin.object
305        );
306        return GroundingResult {
307            text: fact.repair.clone(),
308            repaired: true,
309            reason: Some(fact.reason.clone()),
310            object_hash: Some(fact.quin.object),
311        };
312    }
313    GroundingResult {
314        text: text.to_string(),
315        repaired: false,
316        reason: None,
317        object_hash: None,
318    }
319}
320
321/// Lookup object hash for a subject entity if a capital fact exists (graph API).
322pub fn lookup_capital_object(place_iri: &str) -> Option<u64> {
323    let s = q_hash(place_iri);
324    let p = q_hash(P_CAPITAL_OF);
325    let Ok(guard) = fact_store().lock() else {
326        return None;
327    };
328    guard
329        .iter()
330        .find(|f| f.quin.subject == s && f.quin.predicate == p)
331        .map(|f| f.quin.object)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::inference_modes::{set_inference_mode, InferenceMode};
338
339    #[test]
340    fn repairs_france_capital_when_wrong() {
341        reset_fact_store_to_defaults();
342        let g = ground_generation(
343            "What is the capital of France?",
344            "Question: What is the capital of France? A) Lyon B) Marseille",
345        );
346        assert!(g.repaired);
347        assert!(g.text.to_ascii_lowercase().contains("paris"));
348        assert_eq!(g.reason.as_deref(), Some("capital_france"));
349        assert!(g.object_hash.is_some());
350    }
351
352    #[test]
353    fn leaves_correct_answer() {
354        reset_fact_store_to_defaults();
355        let g = ground_generation(
356            "What is the capital of France?",
357            "The capital of France is Paris.",
358        );
359        assert!(!g.repaired);
360        assert!(g.text.contains("Paris"));
361    }
362
363    #[test]
364    fn maybe_ground_respects_mode() {
365        if std::env::var("QUALIA_INFERENCE_MODE").is_ok() {
366            return;
367        }
368        reset_fact_store_to_defaults();
369        set_inference_mode(InferenceMode::Portable);
370        let g = maybe_ground_generation("What is the capital of France?", "I do not know.");
371        assert!(!g.repaired);
372        set_inference_mode(InferenceMode::QuantGraph);
373        let g2 = maybe_ground_generation("What is the capital of France?", "I do not know.");
374        assert!(g2.repaired);
375        set_inference_mode(InferenceMode::Portable);
376    }
377
378    #[test]
379    fn fact_quins_have_valid_parity() {
380        reset_fact_store_to_defaults();
381        let mut buf = [NQuin {
382            subject: 0,
383            predicate: 0,
384            object: 0,
385            context: 0,
386            metadata: 0,
387            parity: 0,
388        }; 16];
389        let n = export_fact_quins(&mut buf);
390        assert!(n >= 3);
391        for q in &buf[..n] {
392            let fold = q.subject ^ q.predicate ^ q.object ^ q.context ^ q.metadata;
393            assert_eq!(q.parity, fold);
394            assert_eq!(q.predicate, q_hash(P_CAPITAL_OF));
395            assert_eq!(q.context, q_hash(CTX_GROUNDING));
396        }
397    }
398
399    #[test]
400    fn register_and_lookup() {
401        reset_fact_store_to_defaults();
402        register_capital_fact(
403            "https://example.org/place/Italy",
404            "https://example.org/place/Rome",
405            "italy",
406            "rome",
407            "The capital of Italy is Rome.",
408            "capital_italy",
409        );
410        assert_eq!(
411            lookup_capital_object("https://example.org/place/Italy"),
412            Some(q_hash("https://example.org/place/Rome"))
413        );
414        let g = ground_generation("capital of Italy?", "Milan maybe");
415        assert!(g.repaired);
416        assert!(g.text.contains("Rome"));
417    }
418}