Skip to main content

qualia_client_core/wellfair/cml_context/
extract.rs

1//! Deterministic signal extractors over plain text (legislation, policy, general prose).
2//!
3//! These are **heuristic proposals** for the CML layer — not legal advice and not attested.
4
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7
8/// Proposed deontic class for a provision / paragraph.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum DeonticClass {
12    Obligation,
13    Permission,
14    Prohibition,
15    Right,
16    /// Neutral / descriptive / machinery — no clear duty.
17    Undertaking,
18}
19
20impl DeonticClass {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            Self::Obligation => "obligation",
24            Self::Permission => "permission",
25            Self::Prohibition => "prohibition",
26            Self::Right => "right",
27            Self::Undertaking => "undertaking",
28        }
29    }
30
31    pub fn cml_type(self) -> &'static str {
32        match self {
33            Self::Obligation => "values:Obligation",
34            Self::Permission => "values:Permission",
35            Self::Prohibition => "values:Prohibition",
36            Self::Right => "values:Right",
37            Self::Undertaking => "values:Undertaking",
38        }
39    }
40}
41
42/// A named signal hit (privacy family, rights, etc.).
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct SignalHit {
45    pub family: String,
46    pub signal: String,
47    pub confidence: u8,
48}
49
50/// Privacy / data-protection family (GDPR-like and cognates).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum PrivacySignal {
53    PersonalData,
54    SpecialCategory,
55    DataSubject,
56    Controller,
57    Processor,
58    Consent,
59    LawfulBasis,
60    PurposeLimitation,
61    DataMinimisation,
62    StorageLimitation,
63    IntegrityConfidentiality,
64    Accountability,
65    Erasure,
66    AccessRight,
67    Portability,
68    Objection,
69    Rectification,
70    Restriction,
71    AutomatedDecision,
72    Dpia,
73    CrossBorderTransfer,
74    BreachNotification,
75    Children,
76    Surveillance,
77}
78
79impl PrivacySignal {
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Self::PersonalData => "personal-data",
83            Self::SpecialCategory => "special-category-data",
84            Self::DataSubject => "data-subject",
85            Self::Controller => "controller",
86            Self::Processor => "processor",
87            Self::Consent => "consent",
88            Self::LawfulBasis => "lawful-basis",
89            Self::PurposeLimitation => "purpose-limitation",
90            Self::DataMinimisation => "data-minimisation",
91            Self::StorageLimitation => "storage-limitation",
92            Self::IntegrityConfidentiality => "integrity-confidentiality",
93            Self::Accountability => "accountability",
94            Self::Erasure => "erasure-right-to-be-forgotten",
95            Self::AccessRight => "access-right",
96            Self::Portability => "data-portability",
97            Self::Objection => "right-to-object",
98            Self::Rectification => "rectification",
99            Self::Restriction => "restriction-of-processing",
100            Self::AutomatedDecision => "automated-decision-making",
101            Self::Dpia => "dpia",
102            Self::CrossBorderTransfer => "cross-border-transfer",
103            Self::BreachNotification => "breach-notification",
104            Self::Children => "children-data",
105            Self::Surveillance => "surveillance",
106        }
107    }
108}
109
110/// Classify deontic force from English legislative / policy phrasing.
111pub fn classify_deontic(text: &str) -> (DeonticClass, u8) {
112    let t = text.to_ascii_lowercase();
113    // Order matters: stronger / more specific first.
114    let forbid = [
115        "must not",
116        "shall not",
117        "may not",
118        "is prohibited",
119        "are prohibited",
120        "it is an offence",
121        "commits an offence",
122        "is guilty of an offence",
123        "must never",
124        "shall never",
125        "is forbidden",
126        "not permitted to",
127        "unlawful to",
128    ];
129    if forbid.iter().any(|p| t.contains(p)) {
130        return (DeonticClass::Prohibition, 88);
131    }
132    let right = [
133        "has a right to",
134        "have a right to",
135        "is entitled to",
136        "are entitled to",
137        "right of access",
138        "right to erasure",
139        "right to be forgotten",
140        "right to object",
141        "right to data portability",
142        "right to rectification",
143        "right to restriction",
144        "fundamental right",
145        "human right",
146    ];
147    if right.iter().any(|p| t.contains(p)) {
148        return (DeonticClass::Right, 86);
149    }
150    let oblig = [
151        " must ",
152        " shall ",
153        "is required to",
154        "are required to",
155        "is obliged to",
156        "duty to",
157        "obligation to",
158        "must ensure",
159        "shall ensure",
160        "must provide",
161        "shall provide",
162        "must notify",
163        "shall notify",
164    ];
165    // Leading "Must" / "Shall" at sentence start
166    if t.starts_with("must ") || t.starts_with("shall ") {
167        return (DeonticClass::Obligation, 84);
168    }
169    if oblig.iter().any(|p| t.contains(p)) {
170        return (DeonticClass::Obligation, 82);
171    }
172    let permit = [
173        " may ",
174        "is permitted to",
175        "are permitted to",
176        "is authorised to",
177        "is authorized to",
178        "has power to",
179        "may elect",
180        "at the discretion",
181    ];
182    if t.starts_with("may ") {
183        return (DeonticClass::Permission, 78);
184    }
185    if permit.iter().any(|p| t.contains(p)) {
186        return (DeonticClass::Permission, 76);
187    }
188    (DeonticClass::Undertaking, 40)
189}
190
191/// Extract GDPR-like / privacy-family signals.
192pub fn extract_privacy_signals(text: &str) -> Vec<SignalHit> {
193    let t = text.to_ascii_lowercase();
194    let mut hits = Vec::new();
195    let mut push = |sig: PrivacySignal, conf: u8, needles: &[&str]| {
196        if needles.iter().any(|n| t.contains(n)) {
197            hits.push(SignalHit {
198                family: "privacy".into(),
199                signal: sig.as_str().into(),
200                confidence: conf,
201            });
202        }
203    };
204    push(
205        PrivacySignal::PersonalData,
206        90,
207        &[
208            "personal data",
209            "personal information",
210            "personally identifiable",
211            "pii",
212            "information about an individual",
213            "identifiable natural person",
214        ],
215    );
216    push(
217        PrivacySignal::SpecialCategory,
218        92,
219        &[
220            "special category",
221            "sensitive personal",
222            "racial or ethnic",
223            "political opinion",
224            "religious belief",
225            "trade union membership",
226            "genetic data",
227            "biometric data",
228            "health data",
229            "sex life",
230            "sexual orientation",
231        ],
232    );
233    push(
234        PrivacySignal::DataSubject,
235        88,
236        &["data subject", "individual concerned", "person to whom"],
237    );
238    push(
239        PrivacySignal::Controller,
240        88,
241        &[
242            "data controller",
243            "controller shall",
244            "controller must",
245            "as controller",
246        ],
247    );
248    push(
249        PrivacySignal::Processor,
250        88,
251        &[
252            "data processor",
253            "processor shall",
254            "processor must",
255            "as processor",
256        ],
257    );
258    push(
259        PrivacySignal::Consent,
260        85,
261        &[
262            "consent",
263            "freely given",
264            "informed consent",
265            "withdraw consent",
266        ],
267    );
268    push(
269        PrivacySignal::LawfulBasis,
270        87,
271        &[
272            "lawful basis",
273            "lawful ground",
274            "legitimate interest",
275            "legal obligation",
276            "vital interest",
277            "public task",
278            "contractual necessity",
279        ],
280    );
281    push(
282        PrivacySignal::PurposeLimitation,
283        84,
284        &[
285            "purpose limitation",
286            "specified purpose",
287            "compatible purpose",
288            "further processing",
289        ],
290    );
291    push(
292        PrivacySignal::DataMinimisation,
293        84,
294        &[
295            "data minimisation",
296            "data minimization",
297            "not excessive",
298            "adequate, relevant",
299        ],
300    );
301    push(
302        PrivacySignal::StorageLimitation,
303        84,
304        &[
305            "storage limitation",
306            "no longer than necessary",
307            "retention period",
308            "kept no longer",
309        ],
310    );
311    push(
312        PrivacySignal::IntegrityConfidentiality,
313        83,
314        &[
315            "integrity and confidentiality",
316            "appropriate security",
317            "technical and organisational",
318            "technical and organizational",
319            "security of processing",
320            "encryption",
321            "pseudonymisation",
322            "pseudonymization",
323        ],
324    );
325    push(
326        PrivacySignal::Accountability,
327        82,
328        &[
329            "accountability",
330            "demonstrate compliance",
331            "records of processing",
332        ],
333    );
334    push(
335        PrivacySignal::Erasure,
336        90,
337        &[
338            "right to erasure",
339            "right to be forgotten",
340            "erase personal data",
341            "delete the personal",
342            "destruction of personal",
343        ],
344    );
345    push(
346        PrivacySignal::AccessRight,
347        88,
348        &[
349            "right of access",
350            "subject access",
351            "access to personal data",
352            "copy of the personal",
353        ],
354    );
355    push(
356        PrivacySignal::Portability,
357        88,
358        &[
359            "data portability",
360            "structured, commonly used",
361            "machine-readable format",
362        ],
363    );
364    push(
365        PrivacySignal::Objection,
366        86,
367        &["right to object", "object to processing", "opt out"],
368    );
369    push(
370        PrivacySignal::Rectification,
371        86,
372        &[
373            "right to rectification",
374            "rectify",
375            "inaccurate personal data",
376        ],
377    );
378    push(
379        PrivacySignal::Restriction,
380        85,
381        &["restriction of processing", "restrict processing"],
382    );
383    push(
384        PrivacySignal::AutomatedDecision,
385        90,
386        &[
387            "automated decision",
388            "automated processing",
389            "profiling",
390            "solely automated",
391            "algorithmic decision",
392        ],
393    );
394    push(
395        PrivacySignal::Dpia,
396        91,
397        &[
398            "data protection impact assessment",
399            "dpia",
400            "privacy impact assessment",
401            "pia ",
402        ],
403    );
404    push(
405        PrivacySignal::CrossBorderTransfer,
406        89,
407        &[
408            "transfer to a third country",
409            "cross-border",
410            "cross border",
411            "adequacy decision",
412            "standard contractual clauses",
413            "binding corporate rules",
414            "overseas disclosure",
415            "overseas recipient",
416        ],
417    );
418    push(
419        PrivacySignal::BreachNotification,
420        90,
421        &[
422            "personal data breach",
423            "data breach",
424            "notify the supervisory",
425            "notify the commissioner",
426            "breach notification",
427            "security incident",
428        ],
429    );
430    push(
431        PrivacySignal::Children,
432        87,
433        &[
434            "child's personal data",
435            "children's data",
436            "under the age of 16",
437            "parental consent",
438        ],
439    );
440    push(
441        PrivacySignal::Surveillance,
442        80,
443        &[
444            "surveillance",
445            "intercept",
446            "tracking",
447            "cctv",
448            "location data",
449            "metadata retention",
450        ],
451    );
452    // Dedup by signal name (keep highest conf).
453    hits.sort_by(|a, b| a.signal.cmp(&b.signal));
454    hits.dedup_by(|a, b| {
455        if a.signal == b.signal {
456            if a.confidence < b.confidence {
457                *a = b.clone();
458            }
459            true
460        } else {
461            false
462        }
463    });
464    hits
465}
466
467/// Human-rights / civil-rights cues (broader than privacy).
468pub fn extract_rights_signals(text: &str) -> Vec<SignalHit> {
469    let t = text.to_ascii_lowercase();
470    let mut hits = Vec::new();
471    let pairs: &[(&str, &str, u8)] = &[
472        ("human-rights", "human rights", 88),
473        ("human-rights", "charter of rights", 90),
474        ("human-rights", "bill of rights", 88),
475        ("non-discrimination", "discrimination", 80),
476        ("non-discrimination", "equal treatment", 82),
477        ("due-process", "due process", 85),
478        ("due-process", "natural justice", 84),
479        ("freedom-of-expression", "freedom of expression", 88),
480        ("freedom-of-expression", "freedom of speech", 86),
481        ("privacy-as-right", "right to privacy", 90),
482        ("privacy-as-right", "respect for private life", 88),
483        ("liberty", "personal liberty", 82),
484        ("fair-trial", "fair trial", 88),
485        ("refugees", "non-refoulement", 90),
486        ("indigenous", "indigenous", 75),
487        ("indigenous", "aboriginal", 75),
488        ("disability", "disability rights", 85),
489        ("labour", "workplace right", 80),
490        ("labour", "industrial relations", 78),
491    ];
492    for (sig, needle, conf) in pairs {
493        if t.contains(needle) {
494            hits.push(SignalHit {
495                family: "rights".into(),
496                signal: (*sig).into(),
497                confidence: *conf,
498            });
499        }
500    }
501    hits.sort_by(|a, b| a.signal.cmp(&b.signal));
502    hits.dedup_by(|a, b| a.signal == b.signal);
503    hits
504}
505
506/// Temporal / LTL-ish cues (commencement, deadlines).
507pub fn extract_temporal_signals(text: &str) -> Vec<SignalHit> {
508    let t = text.to_ascii_lowercase();
509    let mut hits = Vec::new();
510    let pairs: &[(&str, &str, u8)] = &[
511        ("commencement", "commences", 80),
512        ("commencement", "comes into force", 85),
513        ("commencement", "comes into operation", 85),
514        ("royal-assent", "royal assent", 88),
515        ("proclamation", "proclamation", 82),
516        ("deadline-days", "within ", 70),
517        ("deadline-days", " not later than ", 78),
518        ("deadline-days", "no later than", 80),
519        ("sunset", "ceases to have effect", 85),
520        ("sunset", "sunsets", 80),
521        ("retrospective", "is taken to have", 82),
522        ("retrospective", "deemed to have commenced", 85),
523    ];
524    for (sig, needle, conf) in pairs {
525        if t.contains(needle) {
526            hits.push(SignalHit {
527                family: "temporal".into(),
528                signal: (*sig).into(),
529                confidence: *conf,
530            });
531        }
532    }
533    // Numeric day windows: "within 30 days"
534    if Regex::new(r"(?i)within\s+\d+\s+days")
535        .unwrap()
536        .is_match(text)
537    {
538        hits.push(SignalHit {
539            family: "temporal".into(),
540            signal: "within-n-days".into(),
541            confidence: 86,
542        });
543    }
544    hits.sort_by(|a, b| a.signal.cmp(&b.signal));
545    hits.dedup_by(|a, b| a.signal == b.signal);
546    hits
547}
548
549/// Cross-references to other provisions / instruments.
550pub fn extract_cross_refs(text: &str) -> Vec<String> {
551    let mut out = Vec::new();
552    let patterns = [
553        r"(?i)\bsection\s+(\d+[A-Za-z]{0,2}(?:\(\d+[A-Za-z]?\))?)",
554        r"(?i)\bsubsection\s+\((\d+[A-Za-z]?)\)",
555        r"(?i)\barticle\s+(\d+[A-Za-z]?)",
556        r"(?i)\bschedule\s+(\d+[A-Za-z]?)",
557        r"(?i)\bpart\s+([0-9IVXLC]+[A-Za-z]?)",
558        r"(?i)\bdivision\s+(\d+[A-Za-z]?)",
559        r"(?i)\bregulation\s+(\d+[A-Za-z]?)",
560    ];
561    for pat in patterns {
562        let re = Regex::new(pat).unwrap();
563        for cap in re.captures_iter(text) {
564            if let Some(m) = cap.get(0) {
565                let s = m.as_str().trim().to_string();
566                if !out.contains(&s) {
567                    out.push(s);
568                }
569            }
570        }
571    }
572    out.truncate(32);
573    out
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn deontic_prohibition_and_obligation() {
582        assert_eq!(
583            classify_deontic("A person must not disclose personal data.").0,
584            DeonticClass::Prohibition
585        );
586        assert_eq!(
587            classify_deontic("The controller shall ensure appropriate security.").0,
588            DeonticClass::Obligation
589        );
590        assert_eq!(
591            classify_deontic("The individual has a right to access their data.").0,
592            DeonticClass::Right
593        );
594        assert_eq!(
595            classify_deontic("The Commissioner may issue guidelines.").0,
596            DeonticClass::Permission
597        );
598    }
599
600    #[test]
601    fn privacy_gdpr_family_hits() {
602        let text = "The data controller must obtain consent before processing personal data \
603                    and honour the right to erasure. A DPIA is required for profiling.";
604        let hits = extract_privacy_signals(text);
605        let sigs: Vec<_> = hits.iter().map(|h| h.signal.as_str()).collect();
606        assert!(sigs.contains(&"controller"));
607        assert!(sigs.contains(&"consent"));
608        assert!(sigs.contains(&"personal-data"));
609        assert!(sigs.contains(&"erasure-right-to-be-forgotten"));
610        assert!(sigs.contains(&"dpia") || sigs.contains(&"automated-decision-making"));
611    }
612
613    #[test]
614    fn cross_refs_captured() {
615        let refs = extract_cross_refs("Subject to section 12(1) and Schedule 2, see Article 6.");
616        assert!(refs
617            .iter()
618            .any(|r| r.to_ascii_lowercase().contains("section")));
619        assert!(refs
620            .iter()
621            .any(|r| r.to_ascii_lowercase().contains("schedule")));
622    }
623}