Skip to main content

qualia_client_core/wellfair/cml_context/
graph.rs

1//! Build a CML-shaped context graph (NQuins + N3 + facet tags) from text units.
2
3use qualia_core_db::modalities::logic::deontic::{
4    compile_norm_quin, OP_FORBID, OP_OBLIGATE, OP_PERMIT,
5};
6use qualia_core_db::{q_hash, NQuin};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10use super::extract::{
11    classify_deontic, extract_cross_refs, extract_privacy_signals, extract_rights_signals,
12    extract_temporal_signals, DeonticClass, SignalHit,
13};
14
15/// One structural unit (section, heading block, or paragraph cluster).
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ContextUnit {
18    pub frag: String,
19    pub kind: String,
20    pub label: String,
21    pub text: String,
22    pub page: Option<u32>,
23    pub parent: Option<String>,
24}
25
26/// A proposed concept in the CML layer.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CmlConcept {
29    pub id: String,
30    pub label: String,
31    pub kind: String,
32    pub deontic: String,
33    pub deontic_confidence: u8,
34    pub privacy_signals: Vec<String>,
35    pub rights_signals: Vec<String>,
36    pub temporal_signals: Vec<String>,
37    pub cross_refs: Vec<String>,
38    pub summary: String,
39    pub curation: String,
40}
41
42/// Full context graph for a document or provision.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct CmlContextGraph {
45    pub document_uri: String,
46    pub title: String,
47    pub concepts: Vec<CmlConcept>,
48    /// Flattened signal tags for library facets (`privacy:consent`, `deontic:obligation`, …).
49    pub signal_tags: Vec<String>,
50    pub topics: Vec<String>,
51    pub purposes: Vec<String>,
52    /// Proposed CML as N3 (machine layer; cml:Proposed only).
53    pub n3: String,
54    /// Executable / searchable NQuins (deontic norms + descriptor-like edges).
55    #[serde(skip)]
56    pub quins: Vec<NQuin>,
57    pub deontic_norms: usize,
58    pub privacy_hits: usize,
59    pub rights_hits: usize,
60}
61
62fn lit(s: &str) -> String {
63    s.replace('\\', "\\\\")
64        .replace('"', "\\\"")
65        .replace('\n', "\\n")
66        .replace('\r', "")
67}
68
69fn fnv60_str(s: &str) -> u64 {
70    q_hash(s) & 0x0FFF_FFFF_FFFF_FFFF
71}
72
73/// Split plain text into heading-aware units (Markdown `#` / ALL-CAPS lines / blank-line blocks).
74pub fn units_from_headings(text: &str) -> Vec<ContextUnit> {
75    let mut units = Vec::new();
76    let mut cur_label = "body".to_string();
77    let mut cur_frag = "body".to_string();
78    let mut buf: Vec<&str> = Vec::new();
79    let mut n = 0u32;
80
81    let flush = |label: &str, frag: &str, buf: &mut Vec<&str>, units: &mut Vec<ContextUnit>| {
82        let body = buf.join("\n").trim().to_string();
83        buf.clear();
84        if body.is_empty() && label == "body" {
85            return;
86        }
87        units.push(ContextUnit {
88            frag: frag.to_string(),
89            kind: "section".into(),
90            label: label.to_string(),
91            text: body,
92            page: None,
93            parent: None,
94        });
95    };
96
97    for line in text.lines() {
98        let t = line.trim();
99        let is_md = t.starts_with('#');
100        let is_caps = t.len() >= 4
101            && t.len() <= 80
102            && t.chars().filter(|c| c.is_alphabetic()).count() >= 3
103            && t.chars()
104                .filter(|c| c.is_alphabetic())
105                .all(|c| c.is_uppercase());
106        if is_md || is_caps {
107            flush(&cur_label, &cur_frag, &mut buf, &mut units);
108            n += 1;
109            cur_label = t.trim_start_matches('#').trim().to_string();
110            cur_frag = format!("h-{n}");
111            continue;
112        }
113        buf.push(line);
114    }
115    flush(&cur_label, &cur_frag, &mut buf, &mut units);
116    if units.is_empty() {
117        units_from_paragraphs(text)
118    } else {
119        units
120    }
121}
122
123/// Fallback: split on blank lines into paragraph units (max 64).
124pub fn units_from_paragraphs(text: &str) -> Vec<ContextUnit> {
125    let mut units = Vec::new();
126    for (i, block) in text.split("\n\n").enumerate() {
127        let body = block.trim();
128        if body.is_empty() {
129            continue;
130        }
131        let label = body
132            .lines()
133            .next()
134            .unwrap_or("¶")
135            .chars()
136            .take(80)
137            .collect();
138        units.push(ContextUnit {
139            frag: format!("p-{}", i + 1),
140            kind: "paragraph".into(),
141            label,
142            text: body.to_string(),
143            page: None,
144            parent: None,
145        });
146        if units.len() >= 64 {
147            break;
148        }
149    }
150    if units.is_empty() && !text.trim().is_empty() {
151        units.push(ContextUnit {
152            frag: "body".into(),
153            kind: "document".into(),
154            label: "Document".into(),
155            text: text.to_string(),
156            page: None,
157            parent: None,
158        });
159    }
160    units
161}
162
163fn deontic_opcode(class: DeonticClass) -> Option<u8> {
164    match class {
165        DeonticClass::Obligation => Some(OP_OBLIGATE),
166        DeonticClass::Permission => Some(OP_PERMIT),
167        DeonticClass::Prohibition => Some(OP_FORBID),
168        DeonticClass::Right => Some(OP_PERMIT), // right modelled as strong permission bearer-side
169        DeonticClass::Undertaking => None,
170    }
171}
172
173fn summarise(text: &str, max: usize) -> String {
174    let one = Regex::new(r"\s+").unwrap().replace_all(text.trim(), " ");
175    if one.chars().count() <= max {
176        one.into_owned()
177    } else {
178        let s: String = one.chars().take(max).collect();
179        format!("{s}…")
180    }
181}
182
183/// Build CML context for a single unit (provision or paragraph).
184pub fn build_unit_context(doc_uri: &str, unit: &ContextUnit) -> CmlContextGraph {
185    build_document_context(doc_uri, &unit.label, std::slice::from_ref(unit))
186}
187
188/// Build a multi-unit CML context graph for a document.
189pub fn build_document_context(
190    doc_uri: &str,
191    title: &str,
192    units: &[ContextUnit],
193) -> CmlContextGraph {
194    let ctx_hash = fnv60_str(doc_uri);
195    let party = fnv60_str(&format!("{doc_uri}#party:addressee"));
196    let mut graph = CmlContextGraph {
197        document_uri: doc_uri.into(),
198        title: title.into(),
199        ..Default::default()
200    };
201
202    let mut n3 = String::new();
203    n3.push_str("@prefix cml: <https://ns.webcivics.net/cml/> .\n");
204    n3.push_str("@prefix values: <https://ns.webcivics.net/values/> .\n");
205    n3.push_str("@prefix skos: <http://www.w3.org/2004/02/skos/core#> .\n");
206    n3.push_str("@prefix dc: <http://purl.org/dc/terms/> .\n");
207    n3.push_str("@prefix prov: <http://www.w3.org/ns/prov#> .\n");
208    n3.push_str("@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n");
209    n3.push_str(&format!(
210        "\n<{doc_uri}> a values:Document, cml:SourceDocument ;\n    dc:title \"{}\" ;\n    cml:curationStatus cml:Proposed ;\n    cml:proposedBy <urn:qualia:cml-context:rust> .\n\n",
211        lit(title)
212    ));
213
214    let mut topics: Vec<String> = vec!["cml".into(), "context-graph".into()];
215    let mut purposes: Vec<String> = vec!["semantic".into()];
216    let mut signal_tags: Vec<String> = Vec::new();
217
218    for unit in units {
219        let body = unit.text.trim();
220        if body.is_empty()
221            && unit.kind != "part"
222            && unit.kind != "division"
223            && unit.kind != "schedule"
224        {
225            continue;
226        }
227        let (deontic, dconf) = if body.is_empty() {
228            (DeonticClass::Undertaking, 0)
229        } else {
230            classify_deontic(body)
231        };
232        let privacy = extract_privacy_signals(body);
233        let rights = extract_rights_signals(body);
234        let temporal = extract_temporal_signals(body);
235        let xrefs = extract_cross_refs(body);
236
237        let concept_id = format!("{doc_uri}#{}", unit.frag);
238        let label = if unit.label.is_empty() {
239            unit.frag.clone()
240        } else {
241            unit.label.clone()
242        };
243
244        let mut concept = CmlConcept {
245            id: concept_id.clone(),
246            label: label.clone(),
247            kind: unit.kind.clone(),
248            deontic: deontic.as_str().into(),
249            deontic_confidence: dconf,
250            privacy_signals: privacy.iter().map(|s| s.signal.clone()).collect(),
251            rights_signals: rights.iter().map(|s| s.signal.clone()).collect(),
252            temporal_signals: temporal.iter().map(|s| s.signal.clone()).collect(),
253            cross_refs: xrefs.clone(),
254            summary: summarise(body, 280),
255            curation: "cml:Proposed".into(),
256        };
257
258        // Facet tags
259        signal_tags.push(format!("deontic:{}", deontic.as_str()));
260        topics.push(format!("deontic:{}", deontic.as_str()));
261        for s in &privacy {
262            let tag = format!("privacy:{}", s.signal);
263            signal_tags.push(tag.clone());
264            topics.push(tag);
265            graph.privacy_hits += 1;
266        }
267        for s in &rights {
268            let tag = format!("rights:{}", s.signal);
269            signal_tags.push(tag.clone());
270            topics.push(tag);
271            graph.rights_hits += 1;
272        }
273        for s in &temporal {
274            signal_tags.push(format!("temporal:{}", s.signal));
275            topics.push(format!("temporal:{}", s.signal));
276        }
277        if !privacy.is_empty() {
278            purposes.push("privacy".into());
279            purposes.push("data-protection".into());
280        }
281        if matches!(
282            deontic,
283            DeonticClass::Obligation | DeonticClass::Prohibition | DeonticClass::Right
284        ) {
285            purposes.push("compliance".into());
286        }
287
288        // N3 concept block
289        n3.push_str(&format!(
290            "<{concept_id}> a cml:Concept ;\n    skos:prefLabel \"{}\" ;\n    cml:curationStatus cml:Proposed ;\n    cml:proposedBy <urn:qualia:cml-context:rust> ;\n    values:kind \"{}\" ;\n    values:deonticClass \"{}\" ;\n    cml:confidence \"{dconf}\"^^xsd:integer ;\n",
291            lit(&label),
292            lit(&unit.kind),
293            deontic.as_str(),
294        ));
295        if !body.is_empty() {
296            let body_for_n3 = if body.chars().count() > 8000 {
297                let s: String = body.chars().take(8000).collect();
298                format!("{s}…")
299            } else {
300                body.to_string()
301            };
302            n3.push_str(&format!(
303                "    values:originalText \"{}\" ;\n",
304                lit(&body_for_n3)
305            ));
306        }
307        for s in &privacy {
308            n3.push_str(&format!(
309                "    cml:hasSignal <urn:signal:privacy:{}> ;\n",
310                s.signal
311            ));
312        }
313        for s in &rights {
314            n3.push_str(&format!(
315                "    cml:hasSignal <urn:signal:rights:{}> ;\n",
316                s.signal
317            ));
318        }
319        for r in &xrefs {
320            n3.push_str(&format!("    dc:references \"{}\" ;\n", lit(r)));
321        }
322        n3.push_str(&format!(
323            "    skos:note \"{}\" ;\n    values:partOf <{doc_uri}> .\n\n",
324            lit(&concept.summary)
325        ));
326        n3.push_str(&format!(
327            "<{concept_id}-norm> a {} ;\n    cml:modality cml:Deontic ;\n    values:partOf <{concept_id}> ;\n    values:deonticStatus values:HeuristicDerived ;\n    cml:curationStatus cml:Proposed .\n\n",
328            deontic.cml_type()
329        ));
330
331        // Real deontic NQuin when class is actionable.
332        if let Some(op) = deontic_opcode(deontic) {
333            let action = fnv60_str(&format!("{concept_id}#action"));
334            let path = fnv60_str(&format!("q42:cml:{}", deontic.as_str()));
335            let quin = compile_norm_quin(party, op, path, action, ctx_hash, 0, false);
336            graph.quins.push(quin);
337            graph.deontic_norms += 1;
338        }
339
340        // Descriptor-like quins: topic edges for each privacy signal (searchable via library).
341        for s in privacy.iter().chain(rights.iter()).chain(temporal.iter()) {
342            graph.quins.push(signal_quin(doc_uri, unit, s));
343        }
344
345        // Drop empty summary concept noise for pure structural markers without text.
346        if body.is_empty() {
347            concept.summary = format!("{} {}", unit.kind, label);
348        }
349        graph.concepts.push(concept);
350    }
351
352    // Instrument-level rollup purposes
353    if graph.privacy_hits > 0 {
354        topics.push("gdpr-family".into());
355        topics.push("privacy".into());
356    }
357    if graph.deontic_norms > 0 {
358        topics.push("deontic".into());
359        topics.push("normative".into());
360    }
361
362    topics.sort();
363    topics.dedup();
364    purposes.sort();
365    purposes.dedup();
366    signal_tags.sort();
367    signal_tags.dedup();
368
369    graph.topics = topics;
370    graph.purposes = purposes;
371    graph.signal_tags = signal_tags;
372    graph.n3 = n3;
373    graph
374}
375
376fn signal_quin(doc_uri: &str, unit: &ContextUnit, hit: &SignalHit) -> NQuin {
377    // Lightweight edge: subject = unit, predicate = signal family, object = signal name hash.
378    let subject = fnv60_str(&format!("{doc_uri}#{}", unit.frag));
379    let predicate = fnv60_str(&format!("urn:qualia:cml:signal:{}", hit.family));
380    let object = fnv60_str(&format!(
381        "urn:qualia:cml:signal:{}:{}",
382        hit.family, hit.signal
383    ));
384    let context = fnv60_str(doc_uri);
385    let metadata = hit.confidence as u64;
386    NQuin {
387        subject,
388        predicate,
389        object,
390        context,
391        metadata,
392        parity: subject ^ predicate ^ object ^ context,
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn builds_graph_with_privacy_and_deontic() {
402        let units = vec![ContextUnit {
403            frag: "sec-1".into(),
404            kind: "section".into(),
405            label: "1 Processing".into(),
406            text: "The controller shall not process personal data without consent. \
407                   The data subject has a right to erasure."
408                .into(),
409            page: Some(1),
410            parent: None,
411        }];
412        let g = build_document_context("urn:doc:privacy-act", "Privacy Act Demo", &units);
413        assert_eq!(g.concepts.len(), 1);
414        assert!(g.deontic_norms >= 1);
415        assert!(g.privacy_hits >= 2);
416        assert!(g.n3.contains("values:originalText"));
417        assert!(g.n3.contains("cml:Proposed"));
418        assert!(g.signal_tags.iter().any(|t| t.starts_with("privacy:")));
419        assert!(g
420            .topics
421            .iter()
422            .any(|t| t == "gdpr-family" || t.starts_with("privacy:")));
423        assert!(!g.quins.is_empty());
424    }
425
426    #[test]
427    fn heading_split_produces_units() {
428        let text = "# Title\nIntro line.\n\n# Duties\nA person must comply.\n";
429        let u = units_from_headings(text);
430        assert!(u.len() >= 2);
431        assert!(u.iter().any(|x| x.text.contains("must comply")));
432    }
433}