Skip to main content

qualia_core_db/modalities/logic/shacl/
validate.rs

1//! Comprehensive graph-level SHACL validation.
2//!
3//! [`ShaclCompiler`](super::shacl_compiler) lowers a shape to `SlgOpcode`s for the
4//! firewall (LLM-rule gating). This module is the complementary **data validator**:
5//! it interprets the full [`ShaclConstraint`](super::shacl_types::ShaclConstraint)
6//! vocabulary against a graph of [`NQuin`]s and produces a real
7//! [`ValidationReport`] — the path a "validate this data against this shape"
8//! request (e.g. the docs playground, ingestion-time conformance) actually takes.
9//!
10//! ## Values are hashes — string constraints need a resolver
11//!
12//! Quin object fields hold either an **inline-typed literal** (numeric/boolean,
13//! decodable here via [`frame_layout`]) or the **`q_hash` of a lexical value**
14//! (IRIs and strings — one-way). Numeric/cardinality/value-type/structural
15//! constraints are therefore fully enforceable on the bare graph. String-shaped
16//! constraints (`sh:pattern`, `sh:minLength`/`maxLength`, `sh:languageIn`,
17//! `sh:uniqueLang`) need the original lexical form, so the caller supplies a
18//! `resolve: hash -> Option<String>`. When a value cannot be resolved, a
19//! string-shaped constraint reports a `Violation` (the value cannot be shown to
20//! conform) rather than silently passing — fail closed.
21//!
22//! Cold path: `ValidationReport` is `Vec`-backed (allocation is expected here, as
23//! in the existing `sparql_shacl` validator); the zero-heap invariant covers the
24//! hot path, not shape validation.
25
26use super::shacl_types::{
27    CompiledShape, NodeKindType, PropertyPath, ShaclConstraint, ShaclSeverity, ValidationReport,
28    ValidationResult,
29};
30use crate::frame_layout::{
31    object_tag, unpack_float_object, INLINE_TAG_BOOLEAN, INLINE_TAG_DECIMAL, INLINE_TAG_FLOAT,
32    INLINE_TAG_INTEGER, INLINE_VALUE_MASK, MSB_FLAG,
33};
34use crate::{q_hash, NQuin};
35
36/// A lexical-value resolver: maps a value hash back to its original string when
37/// available (used only by string-shaped constraints).
38pub type Resolver<'r> = &'r dyn Fn(u64) -> Option<String>;
39
40/// Decode an inline-typed object field to an `f64`, or `None` if the object is a
41/// pointer / IRI hash / un-typed (i.e. not a comparable number).
42pub fn object_as_f64(object: u64) -> Option<f64> {
43    if object & MSB_FLAG != 0 {
44        return None; // topological pointer, not a literal
45    }
46    match object_tag(object) {
47        INLINE_TAG_FLOAT => Some(unpack_float_object(object) as f64),
48        INLINE_TAG_INTEGER => {
49            let mut n = (object & INLINE_VALUE_MASK) as i64;
50            if n & (1i64 << 59) != 0 {
51                n |= !((1i64 << 60) - 1); // sign-extend 60-bit two's complement
52            }
53            Some(n as f64)
54        }
55        INLINE_TAG_DECIMAL => {
56            let mut raw = (object & INLINE_VALUE_MASK) as i64;
57            if raw & (1i64 << 59) != 0 {
58                raw |= !((1i64 << 60) - 1);
59            }
60            Some(raw as f64 / 1_000_000.0) // fixed-point ×10⁶
61        }
62        INLINE_TAG_BOOLEAN => Some(if object & 1 != 0 { 1.0 } else { 0.0 }),
63        _ => None,
64    }
65}
66
67/// The SHACL node kind of an object value (0=BlankNode, 1=IRI, 2=Literal).
68fn node_kind_of(object: u64) -> u8 {
69    if object & MSB_FLAG != 0 {
70        1 // did:q42 topological pointer — an IRI
71    } else if object_tag(object) != 0 {
72        2 // inline-typed literal
73    } else {
74        1 // plain IRI hash
75    }
76}
77
78fn node_kind_matches(object: u64, want: NodeKindType) -> bool {
79    let k = node_kind_of(object); // 0=blank,1=iri,2=literal
80    match want {
81        NodeKindType::BlankNode => k == 0,
82        NodeKindType::Iri => k == 1,
83        NodeKindType::Literal => k == 2,
84        NodeKindType::BlankNodeOrIri => k == 0 || k == 1,
85        NodeKindType::BlankNodeOrLiteral => k == 0 || k == 2,
86        NodeKindType::IriOrLiteral => k == 1 || k == 2,
87    }
88}
89
90/// `sh:datatype` IRI → inline tag.
91fn datatype_tag(dt: &str) -> Option<u64> {
92    let h = q_hash(dt);
93    if h == q_hash("xsd:string") {
94        Some(0)
95    } else if h == q_hash("xsd:integer") {
96        Some(INLINE_TAG_INTEGER)
97    } else if h == q_hash("xsd:decimal") {
98        Some(INLINE_TAG_DECIMAL)
99    } else if h == q_hash("xsd:float") || h == q_hash("xsd:double") {
100        Some(INLINE_TAG_FLOAT)
101    } else if h == q_hash("xsd:boolean") {
102        Some(INLINE_TAG_BOOLEAN)
103    } else {
104        None
105    }
106}
107
108/// A readable label for a node: the resolved lexical value, else the decoded
109/// inline numeric/boolean value, else a hash tag.
110fn label(node: u64, resolve: Resolver) -> String {
111    if let Some(s) = resolve(node) {
112        return s;
113    }
114    if node & MSB_FLAG == 0 {
115        match object_tag(node) {
116            INLINE_TAG_BOOLEAN => return (node & 1 != 0).to_string(),
117            INLINE_TAG_FLOAT | INLINE_TAG_INTEGER | INLINE_TAG_DECIMAL => {
118                if let Some(n) = object_as_f64(node) {
119                    return if n.fract() == 0.0 && n.abs() < 1e15 {
120                        (n as i64).to_string()
121                    } else {
122                        n.to_string()
123                    };
124                }
125            }
126            _ => {}
127        }
128    }
129    format!("node:{node:016x}")
130}
131
132const RDF_TYPE_KEYS: [&str; 2] = ["rdf:type", "a"];
133
134/// Engine binding shapes + graph for a validation pass.
135pub struct ShaclEngine<'a> {
136    pub quins: &'a [NQuin],
137    /// Registry used to resolve `sh:node` / `and` / `or` / `not` / `xone` references
138    /// (referenced shapes are looked up by their `shape_class`).
139    pub shapes: &'a [CompiledShape],
140}
141
142impl<'a> ShaclEngine<'a> {
143    pub fn new(quins: &'a [NQuin], shapes: &'a [CompiledShape]) -> Self {
144        Self { quins, shapes }
145    }
146
147    /// Validate every shape against its target nodes.
148    pub fn validate(&self, resolve: Resolver) -> ValidationReport {
149        let mut results = Vec::new();
150        for shape in self.shapes {
151            for focus in self.target_nodes(shape) {
152                self.validate_focus(focus, shape, resolve, &mut results);
153            }
154        }
155        ValidationReport {
156            conforms: !results
157                .iter()
158                .any(|r| r.severity == ShaclSeverity::Violation),
159            results,
160        }
161    }
162
163    /// Validate a single focus node against one shape.
164    pub fn validate_focus(
165        &self,
166        focus: u64,
167        shape: &CompiledShape,
168        resolve: Resolver,
169        out: &mut Vec<ValidationResult>,
170    ) {
171        let path_set = !shape.property_path.is_empty();
172        let values: Vec<u64> = if path_set {
173            self.values_at(focus, &shape.property_path)
174        } else {
175            vec![focus]
176        };
177        let path = if path_set {
178            Some(shape.property_path.clone())
179        } else {
180            None
181        };
182        for c in &shape.constraints {
183            self.check(focus, &path, &values, c, shape.severity, resolve, out);
184        }
185    }
186
187    /// Objects of `(focus, <path>)`.
188    fn values_at(&self, focus: u64, path: &str) -> Vec<u64> {
189        let p = q_hash(path);
190        self.quins
191            .iter()
192            .filter(|q| q.subject == focus && q.predicate == p)
193            .map(|q| q.object)
194            .collect()
195    }
196
197    /// Whether `node` is an `rdf:type` instance of `class_hash`.
198    fn is_a(&self, node: u64, class_hash: u64) -> bool {
199        let type_keys = RDF_TYPE_KEYS.map(q_hash);
200        self.quins.iter().any(|q| {
201            q.subject == node && type_keys.contains(&q.predicate) && q.object == class_hash
202        })
203    }
204
205    /// Look up a referenced shape by its `shape_class` name.
206    fn shape_named(&self, name: &str) -> Option<&CompiledShape> {
207        let h = q_hash(name);
208        self.shapes.iter().find(|s| q_hash(&s.shape_class) == h)
209    }
210
211    /// `true` if `focus` conforms to the named shape (no Violations).
212    fn focus_conforms(&self, focus: u64, name: &str, resolve: Resolver) -> bool {
213        match self.shape_named(name) {
214            Some(s) => {
215                let mut tmp = Vec::new();
216                self.validate_focus(focus, s, resolve, &mut tmp);
217                !tmp.iter().any(|r| r.severity == ShaclSeverity::Violation)
218            }
219            // An unresolved shape reference cannot be shown to hold → fail closed.
220            None => false,
221        }
222    }
223
224    #[allow(clippy::too_many_arguments)]
225    fn check(
226        &self,
227        focus: u64,
228        path: &Option<String>,
229        values: &[u64],
230        c: &ShaclConstraint,
231        severity: ShaclSeverity,
232        resolve: Resolver,
233        out: &mut Vec<ValidationResult>,
234    ) {
235        let mut violate = |component: &str, value: Option<u64>, msg: String| {
236            out.push(ValidationResult {
237                severity,
238                focus_node: label(focus, resolve),
239                result_path: path.clone(),
240                message: Some(msg),
241                source_constraint: None,
242                source_constraint_component: Some(component.to_string()),
243                value: value.map(|v| label(v, resolve)),
244            });
245        };
246
247        match c {
248            // ── value range (numeric) ──────────────────────────────────────────
249            ShaclConstraint::MinInclusive(m) => {
250                for &v in values {
251                    match object_as_f64(v) {
252                        Some(x) if x >= *m => {}
253                        _ => violate(
254                            "sh:MinInclusiveConstraintComponent",
255                            Some(v),
256                            format!("value < minInclusive {m} (or not numeric)"),
257                        ),
258                    }
259                }
260            }
261            ShaclConstraint::MaxInclusive(m) => {
262                for &v in values {
263                    match object_as_f64(v) {
264                        Some(x) if x <= *m => {}
265                        _ => violate(
266                            "sh:MaxInclusiveConstraintComponent",
267                            Some(v),
268                            format!("value > maxInclusive {m} (or not numeric)"),
269                        ),
270                    }
271                }
272            }
273            ShaclConstraint::MinExclusive(m) => {
274                for &v in values {
275                    match object_as_f64(v) {
276                        Some(x) if x > *m => {}
277                        _ => violate(
278                            "sh:MinExclusiveConstraintComponent",
279                            Some(v),
280                            format!("value <= minExclusive {m} (or not numeric)"),
281                        ),
282                    }
283                }
284            }
285            ShaclConstraint::MaxExclusive(m) => {
286                for &v in values {
287                    match object_as_f64(v) {
288                        Some(x) if x < *m => {}
289                        _ => violate(
290                            "sh:MaxExclusiveConstraintComponent",
291                            Some(v),
292                            format!("value >= maxExclusive {m} (or not numeric)"),
293                        ),
294                    }
295                }
296            }
297            ShaclConstraint::DatatypeRange {
298                min_inclusive,
299                max_inclusive,
300                min_exclusive,
301                max_exclusive,
302            } => {
303                for &v in values {
304                    let x = object_as_f64(v);
305                    let ok = match x {
306                        Some(x) => {
307                            min_inclusive.map_or(true, |b| x >= b)
308                                && max_inclusive.map_or(true, |b| x <= b)
309                                && min_exclusive.map_or(true, |b| x > b)
310                                && max_exclusive.map_or(true, |b| x < b)
311                        }
312                        None => false,
313                    };
314                    if !ok {
315                        violate(
316                            "sh:DatatypeRange",
317                            Some(v),
318                            "value outside datatype range".into(),
319                        );
320                    }
321                }
322            }
323
324            // ── cardinality ────────────────────────────────────────────────────
325            ShaclConstraint::MinCount(n) => {
326                if (values.len() as u32) < *n {
327                    violate(
328                        "sh:MinCountConstraintComponent",
329                        None,
330                        format!("{} value(s) < minCount {n}", values.len()),
331                    );
332                }
333            }
334            ShaclConstraint::MaxCount(n) => {
335                if (values.len() as u32) > *n {
336                    violate(
337                        "sh:MaxCountConstraintComponent",
338                        None,
339                        format!("{} value(s) > maxCount {n}", values.len()),
340                    );
341                }
342            }
343
344            // ── value type ─────────────────────────────────────────────────────
345            ShaclConstraint::Class(class) => {
346                let ch = q_hash(class);
347                for &v in values {
348                    if !self.is_a(v, ch) {
349                        violate(
350                            "sh:ClassConstraintComponent",
351                            Some(v),
352                            format!("value is not an instance of {class}"),
353                        );
354                    }
355                }
356            }
357            ShaclConstraint::DataType(dt) => {
358                if let Some(tag) = datatype_tag(dt) {
359                    for &v in values {
360                        let ok = if v & MSB_FLAG != 0 {
361                            false
362                        } else if tag == 0 {
363                            object_tag(v) == 0 // xsd:string == untyped hash
364                        } else {
365                            object_tag(v) == tag
366                        };
367                        if !ok {
368                            violate(
369                                "sh:DatatypeConstraintComponent",
370                                Some(v),
371                                format!("value is not a {dt}"),
372                            );
373                        }
374                    }
375                }
376            }
377            ShaclConstraint::NodeKind(kind_str) => {
378                let want = parse_node_kind(kind_str);
379                if let Some(want) = want {
380                    for &v in values {
381                        if !node_kind_matches(v, want) {
382                            violate(
383                                "sh:NodeKindConstraintComponent",
384                                Some(v),
385                                format!("value is not nodeKind {kind_str}"),
386                            );
387                        }
388                    }
389                }
390            }
391            ShaclConstraint::NodeKindStrict(kind) => {
392                for &v in values {
393                    if !node_kind_matches(v, *kind) {
394                        violate(
395                            "sh:NodeKindConstraintComponent",
396                            Some(v),
397                            format!("value is not nodeKind {kind:?}"),
398                        );
399                    }
400                }
401            }
402
403            // ── value (membership / exact) ───────────────────────────────────────
404            ShaclConstraint::In(allowed) => {
405                let set: Vec<u64> = allowed.iter().map(|s| q_hash(s)).collect();
406                for &v in values {
407                    if !set.contains(&v) {
408                        violate(
409                            "sh:InConstraintComponent",
410                            Some(v),
411                            "value not in the allowed set".into(),
412                        );
413                    }
414                }
415            }
416            ShaclConstraint::HasValue(expected) => {
417                let e = q_hash(expected);
418                if !values.contains(&e) {
419                    violate(
420                        "sh:HasValueConstraintComponent",
421                        None,
422                        format!("required value {expected} absent"),
423                    );
424                }
425            }
426
427            // ── string-shaped (need the resolver) ────────────────────────────────
428            ShaclConstraint::MinLength(n) => {
429                for &v in values {
430                    match resolve(v) {
431                        Some(s) if s.chars().count() as u32 >= *n => {}
432                        _ => violate(
433                            "sh:MinLengthConstraintComponent",
434                            Some(v),
435                            format!("length < minLength {n} (or unresolvable)"),
436                        ),
437                    }
438                }
439            }
440            ShaclConstraint::MaxLength(n) => {
441                for &v in values {
442                    match resolve(v) {
443                        Some(s) if s.chars().count() as u32 <= *n => {}
444                        _ => violate(
445                            "sh:MaxLengthConstraintComponent",
446                            Some(v),
447                            format!("length > maxLength {n} (or unresolvable)"),
448                        ),
449                    }
450                }
451            }
452            ShaclConstraint::Pattern(pat) => match regex::Regex::new(pat) {
453                Ok(re) => {
454                    for &v in values {
455                        match resolve(v) {
456                            Some(s) if re.is_match(&s) => {}
457                            _ => violate(
458                                "sh:PatternConstraintComponent",
459                                Some(v),
460                                format!("value does not match /{pat}/ (or unresolvable)"),
461                            ),
462                        }
463                    }
464                }
465                Err(_) => violate(
466                    "sh:PatternConstraintComponent",
467                    None,
468                    format!("invalid regex pattern /{pat}/"),
469                ),
470            },
471            ShaclConstraint::LanguageIn(langs) => {
472                for &v in values {
473                    let ok = resolve(v)
474                        .map(|s| langs.iter().any(|l| lang_tag_matches(&s, l)))
475                        .unwrap_or(false);
476                    if !ok {
477                        violate(
478                            "sh:LanguageInConstraintComponent",
479                            Some(v),
480                            "value language tag not in languageIn (or unresolvable)".into(),
481                        );
482                    }
483                }
484            }
485            ShaclConstraint::UniqueLang => {
486                let mut seen: Vec<String> = Vec::new();
487                for &v in values {
488                    if let Some(tag) = resolve(v).and_then(|s| lang_tag_of(&s)) {
489                        if seen.contains(&tag) {
490                            violate(
491                                "sh:UniqueLangConstraintComponent",
492                                Some(v),
493                                format!("duplicate language tag @{tag}"),
494                            );
495                        } else {
496                            seen.push(tag);
497                        }
498                    }
499                }
500            }
501
502            // ── property-pair comparison ─────────────────────────────────────────
503            ShaclConstraint::Equals(other) => {
504                let theirs = self.values_at(focus, other);
505                if !same_set(values, &theirs) {
506                    violate(
507                        "sh:EqualsConstraintComponent",
508                        None,
509                        format!("value set != values of {other}"),
510                    );
511                }
512            }
513            ShaclConstraint::LessThan(other) => {
514                self.compare_pair(
515                    focus,
516                    values,
517                    other,
518                    severity,
519                    path,
520                    resolve,
521                    out,
522                    "sh:LessThanConstraintComponent",
523                    |a, b| a < b,
524                );
525            }
526            ShaclConstraint::LessThanOrEquals(other) => {
527                self.compare_pair(
528                    focus,
529                    values,
530                    other,
531                    severity,
532                    path,
533                    resolve,
534                    out,
535                    "sh:LessThanOrEqualsConstraintComponent",
536                    |a, b| a <= b,
537                );
538            }
539            ShaclConstraint::GreaterThan(other) => {
540                self.compare_pair(
541                    focus,
542                    values,
543                    other,
544                    severity,
545                    path,
546                    resolve,
547                    out,
548                    "sh:GreaterThanConstraintComponent",
549                    |a, b| a > b,
550                );
551            }
552            ShaclConstraint::GreaterThanOrEquals(other) => {
553                self.compare_pair(
554                    focus,
555                    values,
556                    other,
557                    severity,
558                    path,
559                    resolve,
560                    out,
561                    "sh:GreaterThanOrEqualsConstraintComponent",
562                    |a, b| a >= b,
563                );
564            }
565
566            // ── shape-based / logical ────────────────────────────────────────────
567            ShaclConstraint::Node(shape) => {
568                for &v in values {
569                    if !self.focus_conforms(v, shape, resolve) {
570                        violate(
571                            "sh:NodeConstraintComponent",
572                            Some(v),
573                            format!("value does not conform to shape {shape}"),
574                        );
575                    }
576                }
577            }
578            ShaclConstraint::And(shapes) => {
579                for &v in values {
580                    if !shapes.iter().all(|s| self.focus_conforms(v, s, resolve)) {
581                        violate(
582                            "sh:AndConstraintComponent",
583                            Some(v),
584                            "value fails one or more sh:and shapes".into(),
585                        );
586                    }
587                }
588            }
589            ShaclConstraint::Or(shapes) => {
590                for &v in values {
591                    if !shapes.iter().any(|s| self.focus_conforms(v, s, resolve)) {
592                        violate(
593                            "sh:OrConstraintComponent",
594                            Some(v),
595                            "value conforms to none of the sh:or shapes".into(),
596                        );
597                    }
598                }
599            }
600            ShaclConstraint::Not(shape) => {
601                for &v in values {
602                    if self.focus_conforms(v, shape, resolve) {
603                        violate(
604                            "sh:NotConstraintComponent",
605                            Some(v),
606                            format!("value conforms to negated shape {shape}"),
607                        );
608                    }
609                }
610            }
611            ShaclConstraint::Xone(shapes) => {
612                for &v in values {
613                    let n = shapes
614                        .iter()
615                        .filter(|s| self.focus_conforms(v, s, resolve))
616                        .count();
617                    if n != 1 {
618                        violate(
619                            "sh:XoneConstraintComponent",
620                            Some(v),
621                            format!("value conforms to {n} sh:xone shapes (need exactly 1)"),
622                        );
623                    }
624                }
625            }
626            ShaclConstraint::Closed { ignored_properties } => {
627                let mut allowed: Vec<u64> = ignored_properties.iter().map(|s| q_hash(s)).collect();
628                if let Some(p) = path {
629                    allowed.push(q_hash(p));
630                }
631                let type_keys = RDF_TYPE_KEYS.map(q_hash);
632                for q in self.quins.iter().filter(|q| q.subject == focus) {
633                    if !allowed.contains(&q.predicate) && !type_keys.contains(&q.predicate) {
634                        violate(
635                            "sh:ClosedConstraintComponent",
636                            Some(q.object),
637                            format!("closed shape: unexpected predicate {:016x}", q.predicate),
638                        );
639                    }
640                }
641            }
642
643            // ── property-path wrapper / qualifier ────────────────────────────────
644            ShaclConstraint::PropertyPath {
645                path: pp,
646                constraint,
647            } => {
648                let pvals = self.values_for_path(focus, pp);
649                let pstr = property_path_label(pp);
650                self.check(
651                    focus,
652                    &Some(pstr),
653                    &pvals,
654                    constraint,
655                    severity,
656                    resolve,
657                    out,
658                );
659            }
660            ShaclConstraint::QualifierValue { path: pp, value } => {
661                let pvals = self.values_for_path(focus, pp);
662                if !pvals.contains(&q_hash(value)) {
663                    violate(
664                        "sh:QualifiedValueShapeConstraintComponent",
665                        None,
666                        format!("qualified value {value} absent at path"),
667                    );
668                }
669            }
670
671            // ── Qualia-native modality constraints ───────────────────────────────
672            // These two have a direct data semantics over the value quins'
673            // confidence/truth degree (frame_layout::truth_degree in metadata).
674            ShaclConstraint::EpistemicConstraint {
675                certainty_threshold,
676            } => {
677                self.check_truth_degree(
678                    focus,
679                    path,
680                    *certainty_threshold,
681                    severity,
682                    resolve,
683                    out,
684                    "q42:EpistemicConstraintComponent",
685                );
686            }
687            ShaclConstraint::ProbabilisticConstraint {
688                confidence_threshold,
689            } => {
690                self.check_truth_degree(
691                    focus,
692                    path,
693                    *confidence_threshold,
694                    severity,
695                    resolve,
696                    out,
697                    "q42:ProbabilisticConstraintComponent",
698                );
699            }
700            // The remaining native constraints are modality *computations* (deontic
701            // evaluation, LTL traces, ASP stable models, calculus, graph, diffusion,
702            // linear-resource, control feedback, interval/Allen, paraconsistent
703            // isolation, argumentation, dialectical synthesis). They are enforced by
704            // their modality engine through the compiled opcode path
705            // (`shacl_compiler` → `webizen` VM), not by per-value data validation, so
706            // there is nothing for the data validator to check here.
707            ShaclConstraint::DeonticPolicy { .. }
708            | ShaclConstraint::DeonticObligate
709            | ShaclConstraint::DeonticPermit
710            | ShaclConstraint::DeonticForbid
711            | ShaclConstraint::DeonticNotExpired { .. }
712            | ShaclConstraint::EpistemicKnowledge { .. }
713            | ShaclConstraint::EpistemicBelief { .. }
714            | ShaclConstraint::CommonKnowledge
715            | ShaclConstraint::LtlConstraint { .. }
716            | ShaclConstraint::ParaconsistentConstraint { .. }
717            | ShaclConstraint::CalculusConstraint { .. }
718            | ShaclConstraint::GraphConstraint { .. }
719            | ShaclConstraint::ArgumentationConstraint { .. }
720            | ShaclConstraint::DialecticalConstraint { .. }
721            | ShaclConstraint::EconVaRPositive
722            | ShaclConstraint::EconConvergedModel
723            | ShaclConstraint::EconPositivePrice
724            | ShaclConstraint::EconRiskBelowThreshold { .. }
725            | ShaclConstraint::EconWelfareAboveFloor { .. }
726            | ShaclConstraint::AspConstraint { .. }
727            | ShaclConstraint::DiffusionConstraint { .. }
728            | ShaclConstraint::LinearLogicConstraint { .. }
729            | ShaclConstraint::ControlFeedbackConstraint { .. }
730            | ShaclConstraint::IntervalArithmeticConstraint { .. } => {}
731        }
732    }
733
734    /// Values reachable from `focus` along a `PropertyPath` expression.
735    fn values_for_path(&self, focus: u64, path: &PropertyPath) -> Vec<u64> {
736        match path {
737            PropertyPath::Predicate(p) => self.values_at(focus, p),
738            PropertyPath::Inverse(inner) => {
739                if let PropertyPath::Predicate(p) = inner.as_ref() {
740                    let ph = q_hash(p);
741                    self.quins
742                        .iter()
743                        .filter(|q| q.object == focus && q.predicate == ph)
744                        .map(|q| q.subject)
745                        .collect()
746                } else {
747                    Vec::new()
748                }
749            }
750            PropertyPath::Sequence(steps) => {
751                let mut frontier = vec![focus];
752                for step in steps {
753                    let mut next = Vec::new();
754                    for f in frontier {
755                        next.extend(self.values_for_path(f, step));
756                    }
757                    frontier = next;
758                }
759                frontier
760            }
761            PropertyPath::Alternative(alts) => {
762                let mut out = Vec::new();
763                for a in alts {
764                    out.extend(self.values_for_path(focus, a));
765                }
766                out
767            }
768            PropertyPath::ZeroOrMore(inner) => {
769                let mut seen = vec![focus];
770                let mut frontier = vec![focus];
771                while let Some(f) = frontier.pop() {
772                    for v in self.values_for_path(f, inner) {
773                        if !seen.contains(&v) {
774                            seen.push(v);
775                            frontier.push(v);
776                        }
777                    }
778                }
779                seen
780            }
781            PropertyPath::OneOrMore(inner) => {
782                let mut seen = Vec::new();
783                let mut frontier = self.values_for_path(focus, inner);
784                while let Some(f) = frontier.pop() {
785                    if !seen.contains(&f) {
786                        seen.push(f);
787                        frontier.extend(self.values_for_path(f, inner));
788                    }
789                }
790                seen
791            }
792            PropertyPath::ZeroOrOne(inner) => {
793                let mut out = vec![focus];
794                out.extend(self.values_for_path(focus, inner));
795                out
796            }
797        }
798    }
799
800    #[allow(clippy::too_many_arguments)]
801    fn compare_pair(
802        &self,
803        focus: u64,
804        values: &[u64],
805        other: &str,
806        severity: ShaclSeverity,
807        path: &Option<String>,
808        resolve: Resolver,
809        out: &mut Vec<ValidationResult>,
810        component: &str,
811        cmp: fn(f64, f64) -> bool,
812    ) {
813        let theirs = self.values_at(focus, other);
814        for &a in values {
815            for &b in &theirs {
816                let ok = match (object_as_f64(a), object_as_f64(b)) {
817                    (Some(x), Some(y)) => cmp(x, y),
818                    _ => false,
819                };
820                if !ok {
821                    out.push(ValidationResult {
822                        severity,
823                        focus_node: label(focus, resolve),
824                        result_path: path.clone(),
825                        message: Some(format!("comparison vs {other} failed")),
826                        source_constraint: None,
827                        source_constraint_component: Some(component.to_string()),
828                        value: Some(label(a, resolve)),
829                    });
830                }
831            }
832        }
833    }
834
835    #[allow(clippy::too_many_arguments)]
836    fn check_truth_degree(
837        &self,
838        focus: u64,
839        path: &Option<String>,
840        threshold: f32,
841        severity: ShaclSeverity,
842        resolve: Resolver,
843        out: &mut Vec<ValidationResult>,
844        component: &str,
845    ) {
846        let p = path.as_ref().map(|s| q_hash(s));
847        for q in self.quins.iter().filter(|q| q.subject == focus) {
848            if let Some(ph) = p {
849                if q.predicate != ph {
850                    continue;
851                }
852            }
853            if crate::frame_layout::truth_degree(q.metadata) < threshold {
854                out.push(ValidationResult {
855                    severity,
856                    focus_node: label(focus, resolve),
857                    result_path: path.clone(),
858                    message: Some(format!("truth/confidence below {threshold}")),
859                    source_constraint: None,
860                    source_constraint_component: Some(component.to_string()),
861                    value: Some(label(q.object, resolve)),
862                });
863            }
864        }
865    }
866
867    /// Target nodes for a shape (class-based: `shape_class` is treated as the
868    /// `sh:targetClass`; falls back to "every subject" when no instances exist so
869    /// node shapes still validate explicitly-supplied focus nodes).
870    fn target_nodes(&self, shape: &CompiledShape) -> Vec<u64> {
871        let class = q_hash(&shape.shape_class);
872        let type_keys = RDF_TYPE_KEYS.map(q_hash);
873        let mut nodes: Vec<u64> = self
874            .quins
875            .iter()
876            .filter(|q| type_keys.contains(&q.predicate) && q.object == class)
877            .map(|q| q.subject)
878            .collect();
879        nodes.sort_unstable();
880        nodes.dedup();
881        nodes
882    }
883}
884
885fn parse_node_kind(s: &str) -> Option<NodeKindType> {
886    match s.trim_start_matches("sh:") {
887        "BlankNode" => Some(NodeKindType::BlankNode),
888        "IRI" => Some(NodeKindType::Iri),
889        "Literal" => Some(NodeKindType::Literal),
890        "BlankNodeOrIRI" => Some(NodeKindType::BlankNodeOrIri),
891        "BlankNodeOrLiteral" => Some(NodeKindType::BlankNodeOrLiteral),
892        "IRIOrLiteral" => Some(NodeKindType::IriOrLiteral),
893        _ => None,
894    }
895}
896
897fn property_path_label(p: &PropertyPath) -> String {
898    match p {
899        PropertyPath::Predicate(s) => s.clone(),
900        PropertyPath::Inverse(i) => format!("^{}", property_path_label(i)),
901        PropertyPath::Sequence(s) => s
902            .iter()
903            .map(property_path_label)
904            .collect::<Vec<_>>()
905            .join("/"),
906        PropertyPath::Alternative(s) => s
907            .iter()
908            .map(property_path_label)
909            .collect::<Vec<_>>()
910            .join("|"),
911        PropertyPath::ZeroOrMore(i) => format!("{}*", property_path_label(i)),
912        PropertyPath::OneOrMore(i) => format!("{}+", property_path_label(i)),
913        PropertyPath::ZeroOrOne(i) => format!("{}?", property_path_label(i)),
914    }
915}
916
917fn same_set(a: &[u64], b: &[u64]) -> bool {
918    a.iter().all(|x| b.contains(x)) && b.iter().all(|x| a.contains(x))
919}
920
921/// Extract the `@lang` tag of a lexical value (e.g. `"hello"@en` → `en`).
922fn lang_tag_of(s: &str) -> Option<String> {
923    s.rsplit_once('@')
924        .map(|(_, tag)| tag.trim_matches('"').to_ascii_lowercase())
925}
926
927/// Whether a value's language tag matches `want` (BCP-47 prefix match, e.g.
928/// `en` matches `en-US`).
929fn lang_tag_matches(value: &str, want: &str) -> bool {
930    match lang_tag_of(value) {
931        Some(tag) => {
932            let want = want.to_ascii_lowercase();
933            tag == want || tag.starts_with(&format!("{want}-"))
934        }
935        None => false,
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use crate::frame_layout::{pack_float_object, INLINE_TAG_INTEGER};
943
944    fn iri(s: &str) -> u64 {
945        q_hash(s)
946    }
947    fn int_obj(n: i64) -> u64 {
948        INLINE_TAG_INTEGER | ((n as u64) & INLINE_VALUE_MASK)
949    }
950    fn quin(s: u64, p: u64, o: u64) -> NQuin {
951        NQuin {
952            subject: s,
953            predicate: p,
954            object: o,
955            context: 0,
956            metadata: 0,
957            parity: 0,
958        }
959    }
960    fn type_quin(node: u64, class: &str) -> NQuin {
961        quin(node, q_hash("rdf:type"), q_hash(class))
962    }
963    fn no_resolve() -> impl Fn(u64) -> Option<String> {
964        |_| None
965    }
966
967    fn shape(class: &str, path: &str, cs: Vec<ShaclConstraint>) -> CompiledShape {
968        let mut s = CompiledShape::new(class.to_string(), cs, ShaclSeverity::Violation);
969        s.property_path = path.to_string();
970        s
971    }
972
973    #[test]
974    fn min_inclusive_passes_and_fails_on_real_numbers() {
975        let alice = iri("ex:Alice");
976        let bob = iri("ex:Bob");
977        let quins = vec![
978            type_quin(alice, "ex:Adult"),
979            quin(alice, q_hash("ex:age"), int_obj(30)),
980            type_quin(bob, "ex:Adult"),
981            quin(bob, q_hash("ex:age"), int_obj(12)),
982        ];
983        let shapes = vec![shape(
984            "ex:Adult",
985            "ex:age",
986            vec![ShaclConstraint::MinInclusive(18.0)],
987        )];
988        let eng = ShaclEngine::new(&quins, &shapes);
989        let rep = eng.validate(&no_resolve());
990        assert!(!rep.conforms, "bob (age 12) must violate minInclusive 18");
991        assert_eq!(rep.results.len(), 1);
992        assert_eq!(
993            rep.results[0].source_constraint_component.as_deref(),
994            Some("sh:MinInclusiveConstraintComponent")
995        );
996    }
997
998    #[test]
999    fn min_max_count_use_property_value_count() {
1000        let n = iri("ex:N");
1001        let quins = vec![
1002            type_quin(n, "ex:Thing"),
1003            quin(n, q_hash("ex:p"), iri("ex:v1")),
1004            quin(n, q_hash("ex:p"), iri("ex:v2")),
1005        ];
1006        let too_few = vec![shape(
1007            "ex:Thing",
1008            "ex:p",
1009            vec![ShaclConstraint::MinCount(3)],
1010        )];
1011        assert!(
1012            !ShaclEngine::new(&quins, &too_few)
1013                .validate(&no_resolve())
1014                .conforms
1015        );
1016        let too_many = vec![shape(
1017            "ex:Thing",
1018            "ex:p",
1019            vec![ShaclConstraint::MaxCount(1)],
1020        )];
1021        assert!(
1022            !ShaclEngine::new(&quins, &too_many)
1023                .validate(&no_resolve())
1024                .conforms
1025        );
1026        let ok = vec![shape(
1027            "ex:Thing",
1028            "ex:p",
1029            vec![ShaclConstraint::MinCount(2), ShaclConstraint::MaxCount(2)],
1030        )];
1031        assert!(
1032            ShaclEngine::new(&quins, &ok)
1033                .validate(&no_resolve())
1034                .conforms
1035        );
1036    }
1037
1038    #[test]
1039    fn class_constraint_checks_rdf_type() {
1040        let n = iri("ex:N");
1041        let dog = iri("ex:Rex");
1042        let quins = vec![
1043            type_quin(n, "ex:Owner"),
1044            quin(n, q_hash("ex:pet"), dog),
1045            type_quin(dog, "ex:Cat"),
1046        ];
1047        let shapes = vec![shape(
1048            "ex:Owner",
1049            "ex:pet",
1050            vec![ShaclConstraint::Class("ex:Dog".into())],
1051        )];
1052        assert!(
1053            !ShaclEngine::new(&quins, &shapes)
1054                .validate(&no_resolve())
1055                .conforms
1056        );
1057    }
1058
1059    #[test]
1060    fn in_and_has_value() {
1061        let n = iri("ex:N");
1062        let quins = vec![
1063            type_quin(n, "ex:T"),
1064            quin(n, q_hash("ex:status"), iri("ex:active")),
1065        ];
1066        let in_ok = vec![shape(
1067            "ex:T",
1068            "ex:status",
1069            vec![ShaclConstraint::In(vec![
1070                "ex:active".into(),
1071                "ex:inactive".into(),
1072            ])],
1073        )];
1074        assert!(
1075            ShaclEngine::new(&quins, &in_ok)
1076                .validate(&no_resolve())
1077                .conforms
1078        );
1079        let in_bad = vec![shape(
1080            "ex:T",
1081            "ex:status",
1082            vec![ShaclConstraint::In(vec!["ex:archived".into()])],
1083        )];
1084        assert!(
1085            !ShaclEngine::new(&quins, &in_bad)
1086                .validate(&no_resolve())
1087                .conforms
1088        );
1089        let hv = vec![shape(
1090            "ex:T",
1091            "ex:status",
1092            vec![ShaclConstraint::HasValue("ex:active".into())],
1093        )];
1094        assert!(
1095            ShaclEngine::new(&quins, &hv)
1096                .validate(&no_resolve())
1097                .conforms
1098        );
1099    }
1100
1101    #[test]
1102    fn pattern_uses_real_regex_with_resolver() {
1103        let n = iri("ex:N");
1104        let email = iri("alice@example.org"); // value hash; resolver supplies the string
1105        let quins = vec![type_quin(n, "ex:User"), quin(n, q_hash("ex:email"), email)];
1106        let shapes = vec![shape(
1107            "ex:User",
1108            "ex:email",
1109            vec![ShaclConstraint::Pattern(r"^[^@]+@[^@]+\.[a-z]+$".into())],
1110        )];
1111        let resolve = |h: u64| {
1112            if h == email {
1113                Some("alice@example.org".to_string())
1114            } else {
1115                None
1116            }
1117        };
1118        assert!(
1119            ShaclEngine::new(&quins, &shapes)
1120                .validate(&resolve)
1121                .conforms
1122        );
1123        // A non-matching string fails.
1124        let resolve_bad = |h: u64| {
1125            if h == email {
1126                Some("not-an-email".to_string())
1127            } else {
1128                None
1129            }
1130        };
1131        assert!(
1132            !ShaclEngine::new(&quins, &shapes)
1133                .validate(&resolve_bad)
1134                .conforms
1135        );
1136        // Unresolvable → fail closed.
1137        assert!(
1138            !ShaclEngine::new(&quins, &shapes)
1139                .validate(&no_resolve())
1140                .conforms
1141        );
1142    }
1143
1144    #[test]
1145    fn min_length_with_resolver() {
1146        let n = iri("ex:N");
1147        let v = iri("ex:val");
1148        let quins = vec![type_quin(n, "ex:T"), quin(n, q_hash("ex:name"), v)];
1149        let shapes = vec![shape(
1150            "ex:T",
1151            "ex:name",
1152            vec![ShaclConstraint::MinLength(5)],
1153        )];
1154        let ok = |h: u64| {
1155            if h == v {
1156                Some("Timothy".to_string())
1157            } else {
1158                None
1159            }
1160        };
1161        assert!(ShaclEngine::new(&quins, &shapes).validate(&ok).conforms);
1162        let bad = |h: u64| {
1163            if h == v {
1164                Some("Tim".to_string())
1165            } else {
1166                None
1167            }
1168        };
1169        assert!(!ShaclEngine::new(&quins, &shapes).validate(&bad).conforms);
1170    }
1171
1172    #[test]
1173    fn logical_or_and_not_xone() {
1174        let n = iri("ex:N");
1175        let quins = vec![
1176            type_quin(n, "ex:Doc"),
1177            quin(n, q_hash("ex:title"), iri("t")),
1178        ];
1179        // Sub-shape: requires ex:title present (minCount 1).
1180        let has_title = shape(
1181            "ex:HasTitle",
1182            "ex:title",
1183            vec![ShaclConstraint::MinCount(1)],
1184        );
1185        let has_author = shape(
1186            "ex:HasAuthor",
1187            "ex:author",
1188            vec![ShaclConstraint::MinCount(1)],
1189        );
1190        // Node shape on the focus: Or(HasTitle, HasAuthor) → passes (has title).
1191        let mut or_shape = CompiledShape::new(
1192            "ex:Doc".into(),
1193            vec![ShaclConstraint::Or(vec![
1194                "ex:HasTitle".into(),
1195                "ex:HasAuthor".into(),
1196            ])],
1197            ShaclSeverity::Violation,
1198        );
1199        or_shape.property_path = String::new(); // node shape (focus itself)
1200        let shapes = vec![or_shape, has_title.clone(), has_author.clone()];
1201        assert!(
1202            ShaclEngine::new(&quins, &shapes)
1203                .validate(&no_resolve())
1204                .conforms
1205        );
1206
1207        // Not(HasAuthor) → passes (no author). Not(HasTitle) → fails.
1208        let mut not_author = CompiledShape::new(
1209            "ex:Doc".into(),
1210            vec![ShaclConstraint::Not("ex:HasAuthor".into())],
1211            ShaclSeverity::Violation,
1212        );
1213        not_author.property_path = String::new();
1214        let shapes2 = vec![not_author, has_title.clone(), has_author.clone()];
1215        assert!(
1216            ShaclEngine::new(&quins, &shapes2)
1217                .validate(&no_resolve())
1218                .conforms
1219        );
1220
1221        let mut not_title = CompiledShape::new(
1222            "ex:Doc".into(),
1223            vec![ShaclConstraint::Not("ex:HasTitle".into())],
1224            ShaclSeverity::Violation,
1225        );
1226        not_title.property_path = String::new();
1227        let shapes3 = vec![not_title, has_title, has_author];
1228        assert!(
1229            !ShaclEngine::new(&quins, &shapes3)
1230                .validate(&no_resolve())
1231                .conforms
1232        );
1233    }
1234
1235    #[test]
1236    fn closed_shape_rejects_extra_predicates() {
1237        let n = iri("ex:N");
1238        let quins = vec![
1239            type_quin(n, "ex:Strict"),
1240            quin(n, q_hash("ex:allowed"), iri("v1")),
1241            quin(n, q_hash("ex:sneaky"), iri("v2")),
1242        ];
1243        let mut s = CompiledShape::new(
1244            "ex:Strict".into(),
1245            vec![ShaclConstraint::Closed {
1246                ignored_properties: vec!["ex:allowed".into()],
1247            }],
1248            ShaclSeverity::Violation,
1249        );
1250        s.property_path = String::new();
1251        let shapes = vec![s];
1252        let rep = ShaclEngine::new(&quins, &shapes).validate(&no_resolve());
1253        assert!(!rep.conforms, "ex:sneaky is not in the allowed/ignored set");
1254    }
1255
1256    #[test]
1257    fn property_pair_less_than() {
1258        let n = iri("ex:N");
1259        let quins = vec![
1260            type_quin(n, "ex:Event"),
1261            quin(n, q_hash("ex:start"), int_obj(5)),
1262            quin(n, q_hash("ex:end"), int_obj(10)),
1263        ];
1264        let ok = vec![shape(
1265            "ex:Event",
1266            "ex:start",
1267            vec![ShaclConstraint::LessThan("ex:end".into())],
1268        )];
1269        assert!(
1270            ShaclEngine::new(&quins, &ok)
1271                .validate(&no_resolve())
1272                .conforms
1273        );
1274        // Reverse: end < start should fail.
1275        let bad = vec![shape(
1276            "ex:Event",
1277            "ex:end",
1278            vec![ShaclConstraint::LessThan("ex:start".into())],
1279        )];
1280        assert!(
1281            !ShaclEngine::new(&quins, &bad)
1282                .validate(&no_resolve())
1283                .conforms
1284        );
1285    }
1286
1287    #[test]
1288    fn object_as_f64_decodes_inline_types() {
1289        assert_eq!(object_as_f64(int_obj(42)), Some(42.0));
1290        assert_eq!(object_as_f64(int_obj(-7)), Some(-7.0));
1291        assert_eq!(object_as_f64(pack_float_object(3.5)), Some(3.5));
1292        assert_eq!(object_as_f64(q_hash("ex:iri")), None); // IRI hash is not numeric
1293    }
1294}