Skip to main content

provable_contracts/ontology/
shapes.rs

1//! ONT-001 §3.6, §5 ONT-4b — the in-house shapes validator: the SHACL Core subset, exactly.
2//!
3//! Shapes are **authored in a contract's `shape:` block** (YAML — the only syntax the gate path parses) and applied
4//! to the graph `extract:pv-contract` produces. The subset is §3.6's table and nothing more at v1alpha1:
5//!
6//! | implemented | refused at parse (`error: shape uses unsupported <x>`, exit 3) |
7//! |---|---|
8//! | `targetClass` | `targetNode`, `targetSubjectsOf`, `targetObjectsOf` |
9//! | `minCount`, `maxCount` | the `qualifiedValueShape` family |
10//! | `datatype`, `class`, `nodeKind` | — |
11//! | `in`, `pattern`, `minLength`, `maxLength` | `languageIn`, `uniqueLang` |
12//! | `node` (one level) | recursive / cyclic shapes |
13//! | `closed`, `ignoredProperties` | — |
14//! | a single predicate `path` | sequence, alternative, inverse, `*`/`+` paths |
15//! | — | `and`/`or`/`not`/`xone`, `sparql`, and every component not in this table |
16//!
17//! A key the table does not name is REFUSED, not ignored: an ignored constraint is a shape that reports "conforms"
18//! for something it never checked, which is the vacuity R-2 exists to end. `resolves:` is ours, not SHACL — it is
19//! accepted here and checked by the extractor (§3.6), so a shape may carry it.
20//!
21//! Prefixes: `ont:` → `https://ont.paiml.dev/v1alpha1/`, `xsd:`, `rdf:`, `prov:` → their namespaces, and any other
22//! `p:name` → `https://ont.paiml.dev/v1alpha1/p/name` (the per-entity-type vocabularies of §3.7: `readme:`, `model:`).
23//! An IRI written in full (`http…`) is taken as is.
24//!
25//! The report is `sh:ValidationReport`-shaped: one result per (focus node, shape, path, component), each naming the
26//! focus node and the shape that fired, so a corpus violation is a line a person can act on. Severity is
27//! `Violation` unless the property shape says `severity: warning`.
28
29use std::collections::{BTreeMap, BTreeSet};
30
31use crate::ontology::rdf::{ont, Graph, Term, RDF_TYPE};
32
33pub const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema#";
34pub const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
35pub const PROV_NS: &str = "http://www.w3.org/ns/prov#";
36
37/// Why a `shape:` block could not be read. Both are the DECLARATION's fault (exit 3), never a corpus verdict.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ShapeError {
40    /// A component outside §3.6's implemented set.
41    Unsupported { shape: String, component: String },
42    /// A component in the set, written wrong (a non-integer `minCount`, a `pattern` that does not compile, …).
43    Malformed { shape: String, what: String },
44}
45
46impl std::fmt::Display for ShapeError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::Unsupported { shape, component } => {
50                write!(f, "shape {shape} uses unsupported {component}")
51            }
52            Self::Malformed { shape, what } => write!(f, "shape {shape} is malformed: {what}"),
53        }
54    }
55}
56
57impl std::error::Error for ShapeError {}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum NodeKind {
61    Iri,
62    Literal,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66pub enum Severity {
67    Warning,
68    Violation,
69}
70
71/// `xsd:string`, spelled once.
72pub const XSD_STRING_IRI: &str = "http://www.w3.org/2001/XMLSchema#string";
73
74/// One entry of a `sh:in` list, as a TERM: its lexical form and the datatype the YAML scalar gave it.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct InEntry {
77    pub lexical: String,
78    pub datatype: String,
79}
80
81impl InEntry {
82    /// Does `value` (a literal's lexical form and datatype) equal this term?
83    #[must_use]
84    pub fn matches_literal(&self, value: &str, datatype: &str) -> bool {
85        self.lexical == value && self.datatype == datatype
86    }
87    /// Does `iri` equal this entry, taken as an IRI (prefixed or written in full)?
88    #[must_use]
89    pub fn matches_iri(&self, iri: &str) -> bool {
90        self.lexical == iri || expand(&self.lexical) == iri
91    }
92}
93
94impl std::fmt::Display for InEntry {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        if self.datatype == XSD_STRING_IRI {
97            write!(f, "{:?}", self.lexical)
98        } else {
99            write!(f, "{:?}^^{}", self.lexical, short(&self.datatype))
100        }
101    }
102}
103
104/// One `properties[]` entry: a single-predicate path and its constraints.
105#[derive(Debug, Clone)]
106pub struct PropertyShape {
107    pub path: String,
108    pub min_count: Option<usize>,
109    pub max_count: Option<usize>,
110    pub datatype: Option<String>,
111    pub class: Option<String>,
112    pub node_kind: Option<NodeKind>,
113    pub r#in: Option<Vec<InEntry>>,
114    pub pattern: Option<(String, regex::Regex)>,
115    pub min_length: Option<usize>,
116    pub max_length: Option<usize>,
117    pub node: Option<Box<NodeShape>>,
118    pub resolves: Option<String>,
119    pub severity: Severity,
120}
121
122/// A node shape: a target class, an open/closed switch, and its property shapes.
123#[derive(Debug, Clone)]
124pub struct NodeShape {
125    /// The contract that declares it (its stem); nested `node` shapes are `<stem>/node`.
126    pub id: String,
127    pub target_class: String,
128    pub closed: bool,
129    pub ignored_properties: Vec<String>,
130    pub properties: Vec<PropertyShape>,
131}
132
133/// One `sh:ValidationResult`.
134#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
135pub struct ValidationResult {
136    pub severity: Severity,
137    pub focus: String,
138    pub shape: String,
139    pub path: Option<String>,
140    pub component: &'static str,
141    pub message: String,
142}
143
144/// The `sh:ValidationReport`: `conforms` iff no result has `Violation` severity.
145#[derive(Debug, Clone, Default)]
146pub struct Report {
147    pub results: Vec<ValidationResult>,
148    pub focus_nodes_n: usize,
149}
150
151impl Report {
152    #[must_use]
153    pub fn violations(&self) -> usize {
154        self.results
155            .iter()
156            .filter(|r| r.severity == Severity::Violation)
157            .count()
158    }
159    #[must_use]
160    pub fn warnings(&self) -> usize {
161        self.results
162            .iter()
163            .filter(|r| r.severity == Severity::Warning)
164            .count()
165    }
166    #[must_use]
167    pub fn conforms(&self) -> bool {
168        self.violations() == 0
169    }
170}
171
172/// Expand a prefixed name to an IRI (see the module doc for the rule).
173#[must_use]
174pub fn expand(name: &str) -> String {
175    if name.starts_with("http://") || name.starts_with("https://") {
176        return name.to_string();
177    }
178    match name.split_once(':') {
179        Some(("ont", local)) => ont(local),
180        Some(("xsd", local)) => format!("{XSD_NS}{local}"),
181        Some(("rdf", local)) => format!("{RDF_NS}{local}"),
182        Some(("prov", local)) => format!("{PROV_NS}{local}"),
183        Some((prefix, local)) => format!("{}{prefix}/{local}", crate::ontology::rdf::ONT_BASE),
184        None => ont(name),
185    }
186}
187
188const NODE_KEYS: &[&str] = &["targetClass", "closed", "ignoredProperties", "properties"];
189const PROPERTY_KEYS: &[&str] = &[
190    "path",
191    "minCount",
192    "maxCount",
193    "datatype",
194    "class",
195    "nodeKind",
196    "in",
197    "pattern",
198    "minLength",
199    "maxLength",
200    "node",
201    "resolves",
202    "severity",
203];
204
205/// Read a contract's `shape:` block. `None` when the contract has no `shape:`. The target class defaults to
206/// `ont:Contract` when the contract's `entity.type` is `pv-contract`; otherwise `targetClass` is required.
207pub fn parse_shape(stem: &str, doc: &serde_yaml::Value) -> Result<Option<NodeShape>, ShapeError> {
208    let Some(block) = doc.get("shape") else {
209        return Ok(None);
210    };
211    let Some(map) = block.as_mapping() else {
212        return Err(ShapeError::Malformed {
213            shape: stem.to_string(),
214            what: "`shape:` is not a mapping".into(),
215        });
216    };
217    parse_node_shape(stem, map, default_target(doc), 0).map(Some)
218}
219
220/// Every shape a contract declares: its `shape:` block (id = the stem) and each entry of its `shapes:` list
221/// (id = the entry's own `id`, required, so a contract may hold several shapes that are armed one by one —
222/// ONT-4c1's `ladder-measured` and `ladder-green`). An entry without `id`, or an `id` that repeats within the
223/// contract, is malformed.
224pub fn parse_shapes(stem: &str, doc: &serde_yaml::Value) -> Result<Vec<NodeShape>, ShapeError> {
225    let mut out = Vec::new();
226    if let Some(s) = parse_shape(stem, doc)? {
227        out.push(s);
228    }
229    let Some(list) = doc.get("shapes") else {
230        return Ok(out);
231    };
232    let seq = list.as_sequence().ok_or_else(|| ShapeError::Malformed {
233        shape: stem.to_string(),
234        what: "`shapes:` is not a list".into(),
235    })?;
236    for (i, entry) in seq.iter().enumerate() {
237        let map = entry.as_mapping().ok_or_else(|| ShapeError::Malformed {
238            shape: stem.to_string(),
239            what: format!("shapes[{i}] is not a mapping"),
240        })?;
241        let id = map
242            .get("id")
243            .and_then(serde_yaml::Value::as_str)
244            .ok_or_else(|| ShapeError::Malformed {
245                shape: stem.to_string(),
246                what: format!("shapes[{i}] has no `id`"),
247            })?;
248        if out.iter().any(|s| s.id == id) {
249            return Err(ShapeError::Malformed {
250                shape: stem.to_string(),
251                what: format!("shape id `{id}` repeats"),
252            });
253        }
254        let mut body = map.clone();
255        body.remove(serde_yaml::Value::String("id".into()));
256        out.push(parse_node_shape(id, &body, default_target(doc), 0)?);
257    }
258    Ok(out)
259}
260
261fn default_target(doc: &serde_yaml::Value) -> Option<String> {
262    doc.get("entity")
263        .and_then(|e| e.get("type"))
264        .and_then(serde_yaml::Value::as_str)
265        .filter(|t| *t == "pv-contract")
266        .map(|_| ont("Contract"))
267}
268
269fn parse_node_shape(
270    id: &str,
271    map: &serde_yaml::Mapping,
272    default_target: Option<String>,
273    depth: usize,
274) -> Result<NodeShape, ShapeError> {
275    for key in map.keys() {
276        let k = key.as_str().unwrap_or("?");
277        if !NODE_KEYS.contains(&k) {
278            return Err(ShapeError::Unsupported {
279                shape: id.to_string(),
280                component: k.to_string(),
281            });
282        }
283    }
284    let target_class = match map.get("targetClass").and_then(serde_yaml::Value::as_str) {
285        Some(t) => expand(t),
286        None => match (depth, default_target) {
287            (0, Some(t)) => t,
288            (0, None) => {
289                return Err(ShapeError::Malformed {
290                    shape: id.to_string(),
291                    what: "no `targetClass`, and the contract's entity is not pv-contract".into(),
292                })
293            }
294            // a nested `node` shape applies to the value, whatever its class
295            (_, _) => String::new(),
296        },
297    };
298    let closed = match map.get("closed") {
299        None => false,
300        Some(v) => v.as_bool().ok_or_else(|| ShapeError::Malformed {
301            shape: id.to_string(),
302            what: "`closed` is not a bool".into(),
303        })?,
304    };
305    let ignored_properties = match map.get("ignoredProperties") {
306        None => Vec::new(),
307        Some(v) => v
308            .as_sequence()
309            .ok_or_else(|| ShapeError::Malformed {
310                shape: id.to_string(),
311                what: "`ignoredProperties` is not a list".into(),
312            })?
313            .iter()
314            .filter_map(serde_yaml::Value::as_str)
315            .map(expand)
316            .collect(),
317    };
318    let mut properties = Vec::new();
319    if let Some(list) = map.get("properties") {
320        let seq = list.as_sequence().ok_or_else(|| ShapeError::Malformed {
321            shape: id.to_string(),
322            what: "`properties` is not a list".into(),
323        })?;
324        for (i, p) in seq.iter().enumerate() {
325            let pm = p.as_mapping().ok_or_else(|| ShapeError::Malformed {
326                shape: id.to_string(),
327                what: format!("properties[{i}] is not a mapping"),
328            })?;
329            properties.push(parse_property(id, pm, depth)?);
330        }
331    }
332    Ok(NodeShape {
333        id: id.to_string(),
334        target_class,
335        closed,
336        ignored_properties,
337        properties,
338    })
339}
340
341fn parse_property(
342    shape: &str,
343    pm: &serde_yaml::Mapping,
344    depth: usize,
345) -> Result<PropertyShape, ShapeError> {
346    let malformed = |what: String| ShapeError::Malformed {
347        shape: shape.to_string(),
348        what,
349    };
350    for key in pm.keys() {
351        let k = key.as_str().unwrap_or("?");
352        if !PROPERTY_KEYS.contains(&k) {
353            return Err(ShapeError::Unsupported {
354                shape: shape.to_string(),
355                component: k.to_string(),
356            });
357        }
358    }
359    let path = pm
360        .get("path")
361        .and_then(serde_yaml::Value::as_str)
362        .ok_or_else(|| malformed("a property has no `path`".into()))?;
363    if path.contains(['/', '|', '^', '*', '+']) && !path.starts_with("http") {
364        return Err(ShapeError::Unsupported {
365            shape: shape.to_string(),
366            component: format!("path `{path}` (only a single predicate is a path here)"),
367        });
368    }
369    let count = |k: &str| -> Result<Option<usize>, ShapeError> {
370        match pm.get(k) {
371            None => Ok(None),
372            Some(v) => v
373                .as_u64()
374                .and_then(|n| usize::try_from(n).ok())
375                .map(Some)
376                .ok_or_else(|| malformed(format!("`{k}` is not a non-negative integer"))),
377        }
378    };
379    let iri_opt = |k: &str| pm.get(k).and_then(serde_yaml::Value::as_str).map(expand);
380    let node_kind = parse_node_kind(
381        shape,
382        pm.get("nodeKind").and_then(serde_yaml::Value::as_str),
383    )?;
384    // `sh:in` is TERM equality (SHACL §4.5.1), and a term carries its datatype. The YAML scalar's own type is
385    // what gives it one: `in: [true]` is `"true"^^xsd:boolean`, `in: [1]` is `xsd:integer`, `in: [a, b]` is
386    // `xsd:string`. This used to collapse every entry to its lexical form and compare strings, so a shape
387    // `in: ["true"]` accepted `"true"^^xsd:boolean` — which the pinned oracle refuses, and which `make oracle`
388    // caught on this row's own shapes (490 results of difference on the real corpus). An entry that expands to
389    // an IRI still matches an IRI value, because a `sh:in` over `nodeKind: IRI` is a list of IRIs.
390    let r#in = match pm.get("in") {
391        None => None,
392        Some(v) => Some(
393            v.as_sequence()
394                .ok_or_else(|| malformed("`in` is not a list".into()))?
395                .iter()
396                .map(|x| match x {
397                    serde_yaml::Value::String(s) => InEntry {
398                        lexical: s.clone(),
399                        datatype: XSD_STRING_IRI.to_string(),
400                    },
401                    serde_yaml::Value::Number(n) => InEntry {
402                        lexical: n.to_string(),
403                        datatype: if n.is_f64() {
404                            format!("{XSD_NS}double")
405                        } else {
406                            format!("{XSD_NS}integer")
407                        },
408                    },
409                    serde_yaml::Value::Bool(b) => InEntry {
410                        lexical: b.to_string(),
411                        datatype: format!("{XSD_NS}boolean"),
412                    },
413                    _ => InEntry {
414                        lexical: String::new(),
415                        datatype: XSD_STRING_IRI.to_string(),
416                    },
417                })
418                .collect(),
419        ),
420    };
421    let pattern = match pm.get("pattern").and_then(serde_yaml::Value::as_str) {
422        None => None,
423        Some(p) => Some((
424            p.to_string(),
425            regex::Regex::new(p)
426                .map_err(|e| malformed(format!("`pattern` does not compile: {e}")))?,
427        )),
428    };
429    let node = match pm.get("node") {
430        None => None,
431        Some(_) if depth >= 1 => {
432            return Err(ShapeError::Unsupported {
433                shape: shape.to_string(),
434                component: "node (nested more than one level)".into(),
435            })
436        }
437        Some(v) => {
438            let nm = v
439                .as_mapping()
440                .ok_or_else(|| malformed("`node` is not a mapping".into()))?;
441            Some(Box::new(parse_node_shape(
442                &format!("{shape}/node"),
443                nm,
444                None,
445                depth + 1,
446            )?))
447        }
448    };
449    let severity = parse_severity(
450        shape,
451        pm.get("severity").and_then(serde_yaml::Value::as_str),
452    )?;
453    Ok(PropertyShape {
454        path: expand(path),
455        min_count: count("minCount")?,
456        max_count: count("maxCount")?,
457        datatype: iri_opt("datatype"),
458        class: iri_opt("class"),
459        node_kind,
460        r#in,
461        pattern,
462        min_length: count("minLength")?,
463        max_length: count("maxLength")?,
464        node,
465        resolves: pm
466            .get("resolves")
467            .and_then(serde_yaml::Value::as_str)
468            .map(String::from),
469        severity,
470    })
471}
472
473/// `nodeKind`: `IRI` or `Literal` (with or without the `sh:` prefix); anything else is outside the subset.
474fn parse_node_kind(shape: &str, v: Option<&str>) -> Result<Option<NodeKind>, ShapeError> {
475    match v {
476        None => Ok(None),
477        Some("IRI" | "sh:IRI") => Ok(Some(NodeKind::Iri)),
478        Some("Literal" | "sh:Literal") => Ok(Some(NodeKind::Literal)),
479        Some(other) => Err(ShapeError::Unsupported {
480            shape: shape.to_string(),
481            component: format!("nodeKind {other}"),
482        }),
483    }
484}
485
486/// `severity`: `violation` (the default) or `warning`; anything else is outside the subset.
487fn parse_severity(shape: &str, v: Option<&str>) -> Result<Severity, ShapeError> {
488    match v {
489        None | Some("violation" | "Violation") => Ok(Severity::Violation),
490        Some("warning" | "Warning") => Ok(Severity::Warning),
491        Some(other) => Err(ShapeError::Unsupported {
492            shape: shape.to_string(),
493            component: format!("severity {other}"),
494        }),
495    }
496}
497
498/// `rdfs:subClassOf`.
499pub const RDFS_SUBCLASS_OF: &str = "http://www.w3.org/2000/01/rdf-schema#subClassOf";
500
501/// The SHACL instance relation: `node rdf:type T` for some `T` that is `class` or an `rdfs:subClassOf`-ancestor
502/// of it (SHACL §2.1.1, the W3C `class-001` cases). A graph without `subClassOf` triples — every graph the
503/// extractors emit today — reduces to a direct `rdf:type` test.
504#[must_use]
505pub fn is_instance(graph: &Graph, node: &str, class: &str) -> bool {
506    graph
507        .objects(node, RDF_TYPE)
508        .iter()
509        .filter_map(|t| t.as_iri())
510        .any(|t| t == class || is_subclass_of(graph, t, class))
511}
512
513/// `sub rdfs:subClassOf* class`, cycle-safe.
514fn is_subclass_of(graph: &Graph, sub: &str, class: &str) -> bool {
515    let mut seen: BTreeSet<String> = BTreeSet::new();
516    let mut stack = vec![sub.to_string()];
517    while let Some(c) = stack.pop() {
518        if !seen.insert(c.clone()) {
519            continue;
520        }
521        for sup in graph.objects(&c, RDFS_SUBCLASS_OF) {
522            if let Some(s) = sup.as_iri() {
523                if s == class {
524                    return true;
525                }
526                stack.push(s.to_string());
527            }
528        }
529    }
530    false
531}
532
533/// The focus nodes of a class: its instances under [`is_instance`] (subclass instances included), in byte order.
534/// One pass: the classes at or below `class` (the inverse `subClassOf` closure), then every `rdf:type` triple
535/// whose object is one of them.
536#[must_use]
537pub fn instances_closed(graph: &Graph, class: &str) -> Vec<String> {
538    let mut subs: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
539    for t in graph.iter() {
540        if t.predicate == RDFS_SUBCLASS_OF {
541            if let Some(sup) = t.object.as_iri() {
542                subs.entry(sup).or_default().push(t.subject.as_str());
543            }
544        }
545    }
546    let mut at_or_below: BTreeSet<&str> = BTreeSet::new();
547    let mut stack = vec![class];
548    while let Some(c) = stack.pop() {
549        if !at_or_below.insert(c) {
550            continue;
551        }
552        if let Some(children) = subs.get(c) {
553            stack.extend(children.iter().copied());
554        }
555    }
556    let out: BTreeSet<String> = graph
557        .iter()
558        .filter(|t| {
559            t.predicate == RDF_TYPE && t.object.as_iri().is_some_and(|o| at_or_below.contains(o))
560        })
561        .map(|t| t.subject.clone())
562        .collect();
563    out.into_iter().collect()
564}
565
566/// Validate `graph` against `shapes`. Focus nodes of a shape are the instances of its target class.
567#[must_use]
568pub fn validate(graph: &Graph, shapes: &[NodeShape]) -> Report {
569    let mut report = Report::default();
570    let mut focus_seen: BTreeSet<String> = BTreeSet::new();
571    for shape in shapes {
572        for focus in instances_closed(graph, &shape.target_class) {
573            let focus = focus.as_str();
574            focus_seen.insert(focus.to_string());
575            validate_focus(graph, shape, focus, &mut report.results);
576        }
577    }
578    report.focus_nodes_n = focus_seen.len();
579    report.results.sort();
580    report.results.dedup();
581    report
582}
583
584fn validate_focus(graph: &Graph, shape: &NodeShape, focus: &str, out: &mut Vec<ValidationResult>) {
585    let mut push =
586        |severity: Severity, path: Option<&str>, component: &'static str, message: String| {
587            out.push(ValidationResult {
588                severity,
589                focus: focus.to_string(),
590                shape: shape.id.clone(),
591                path: path.map(String::from),
592                component,
593                message,
594            });
595        };
596    for p in &shape.properties {
597        let values = graph.objects(focus, &p.path);
598        if let Some(min) = p.min_count {
599            if values.len() < min {
600                push(
601                    p.severity,
602                    Some(&p.path),
603                    "minCount",
604                    format!(
605                        "has {} value(s) of {}, minCount is {min}",
606                        values.len(),
607                        short(&p.path)
608                    ),
609                );
610            }
611        }
612        if let Some(max) = p.max_count {
613            if values.len() > max {
614                // name the values (up to five): a `maxCount 0` on a materialized edge — `missingGreenHost`,
615                // `receiptHexMismatch` (ONT-4c1) — is only actionable when the message says WHICH host, WHICH file
616                let named: Vec<String> = values
617                    .iter()
618                    .take(5)
619                    .map(|v| match v {
620                        Term::Iri(i) => short(i),
621                        Term::Literal { value, .. } => value.clone(),
622                    })
623                    .collect();
624                push(
625                    p.severity,
626                    Some(&p.path),
627                    "maxCount",
628                    format!(
629                        "has {} value(s) of {}, maxCount is {max}: {}",
630                        values.len(),
631                        short(&p.path),
632                        named.join(", ")
633                    ),
634                );
635            }
636        }
637        for v in values {
638            check_value(graph, p, v, &mut push);
639        }
640    }
641    if shape.closed {
642        let allowed: BTreeSet<&str> = shape
643            .properties
644            .iter()
645            .map(|p| p.path.as_str())
646            .chain(shape.ignored_properties.iter().map(String::as_str))
647            .chain(std::iter::once(RDF_TYPE))
648            .collect();
649        for pred in graph.predicates_of(focus) {
650            if !allowed.contains(pred) {
651                push(
652                    Severity::Violation,
653                    Some(pred),
654                    "closed",
655                    format!(
656                        "carries {}, which the closed shape does not declare",
657                        short(pred)
658                    ),
659                );
660            }
661        }
662    }
663}
664
665fn check_value(
666    graph: &Graph,
667    p: &PropertyShape,
668    v: &Term,
669    push: &mut impl FnMut(Severity, Option<&str>, &'static str, String),
670) {
671    check_kind_and_type(graph, p, v, push);
672    check_lexical(p, v, push);
673    if let Some(inner) = &p.node {
674        match v.as_iri() {
675            Some(i) => {
676                // One `sh:NodeConstraintComponent` result per VALUE that fails the nested shape, on the outer
677                // path, with the nested findings as its detail (SHACL §4.6.2; W3C property/node-001, -002).
678                let mut nested = Vec::new();
679                validate_focus(graph, inner, i, &mut nested);
680                let violations: Vec<String> = nested
681                    .iter()
682                    .filter(|r| r.severity == Severity::Violation)
683                    .map(|r| r.message.clone())
684                    .collect();
685                if !violations.is_empty() {
686                    push(
687                        p.severity,
688                        Some(&p.path),
689                        "node",
690                        format!(
691                            "value {i} fails the nested shape: {}",
692                            violations.join("; ")
693                        ),
694                    );
695                }
696            }
697            None => push(
698                p.severity,
699                Some(&p.path),
700                "node",
701                format!("{v} is a literal; `node` needs an IRI"),
702            ),
703        }
704    }
705}
706
707/// Is `value` a well-formed lexical form of the XSD `datatype`? A literal typed `xsd:byte` with the lexical
708/// form `300` (or `c`) is ill-formed and violates `sh:datatype` (SHACL §4.1.2; W3C `datatype-ill-formed`). The
709/// types checked are the ones the corpus and the vendored cases use; any other datatype is taken as
710/// well-formed, because refusing what is not understood would be a verdict about a value never measured.
711#[must_use]
712pub fn well_formed(value: &str, datatype: &str) -> bool {
713    let Some(local) = datatype.strip_prefix(XSD_NS) else {
714        return true;
715    };
716    let int_in = |lo: i128, hi: i128| value.parse::<i128>().is_ok_and(|n| n >= lo && n <= hi);
717    match local {
718        "string" | "anyURI" => true,
719        "boolean" => matches!(value, "true" | "false" | "1" | "0"),
720        "integer" => value.parse::<i128>().is_ok(),
721        "long" => int_in(i128::from(i64::MIN), i128::from(i64::MAX)),
722        "int" => int_in(i128::from(i32::MIN), i128::from(i32::MAX)),
723        "short" => int_in(i128::from(i16::MIN), i128::from(i16::MAX)),
724        "byte" => int_in(i128::from(i8::MIN), i128::from(i8::MAX)),
725        "nonNegativeInteger" => int_in(0, i128::MAX),
726        "positiveInteger" => int_in(1, i128::MAX),
727        "nonPositiveInteger" => int_in(i128::MIN, 0),
728        "negativeInteger" => int_in(i128::MIN, -1),
729        "unsignedLong" => int_in(0, i128::from(u64::MAX)),
730        "unsignedInt" => int_in(0, i128::from(u32::MAX)),
731        "unsignedShort" => int_in(0, i128::from(u16::MAX)),
732        "unsignedByte" => int_in(0, i128::from(u8::MAX)),
733        "decimal" => {
734            !value.is_empty()
735                && value
736                    .strip_prefix(['+', '-'])
737                    .unwrap_or(value)
738                    .chars()
739                    .all(|c| c.is_ascii_digit() || c == '.')
740                && value.chars().filter(|c| *c == '.').count() <= 1
741                && value.chars().any(|c| c.is_ascii_digit())
742        }
743        "double" | "float" => {
744            matches!(value, "INF" | "-INF" | "NaN") || value.parse::<f64>().is_ok()
745        }
746        "date" => is_date(value),
747        "dateTime" => value
748            .split_once('T')
749            .is_some_and(|(d, t)| is_date(d) && t.len() >= 8 && t.as_bytes()[2] == b':'),
750        _ => true,
751    }
752}
753
754/// `YYYY-MM-DD` with an optional timezone suffix.
755fn is_date(s: &str) -> bool {
756    let core = s.split(['Z', '+']).next().unwrap_or(s);
757    let core = if core.len() > 10 { &core[..10] } else { core };
758    let b = core.as_bytes();
759    b.len() == 10
760        && b[4] == b'-'
761        && b[7] == b'-'
762        && [0, 1, 2, 3, 5, 6, 8, 9]
763            .iter()
764            .all(|&i| b[i].is_ascii_digit())
765        && (1..=12).contains(&core[5..7].parse::<u8>().unwrap_or(0))
766        && (1..=31).contains(&core[8..10].parse::<u8>().unwrap_or(0))
767}
768
769/// `nodeKind`, `datatype`, `class` — the constraints about what KIND of term the value is.
770fn check_kind_and_type(
771    graph: &Graph,
772    p: &PropertyShape,
773    v: &Term,
774    push: &mut impl FnMut(Severity, Option<&str>, &'static str, String),
775) {
776    let path = Some(p.path.as_str());
777    if let Some(kind) = p.node_kind {
778        let ok = match kind {
779            NodeKind::Iri => v.as_iri().is_some(),
780            NodeKind::Literal => v.as_literal().is_some(),
781        };
782        if !ok {
783            push(
784                p.severity,
785                path,
786                "nodeKind",
787                format!("{v} is not of nodeKind {kind:?}"),
788            );
789        }
790    }
791    if let Some(dt) = &p.datatype {
792        match v.as_literal() {
793            Some((value, actual)) if actual == dt && well_formed(value, dt) => {}
794            Some((value, actual)) if actual == dt => push(
795                p.severity,
796                path,
797                "datatype",
798                format!("\"{value}\" is not a well-formed {}", short(dt)),
799            ),
800            _ => push(
801                p.severity,
802                path,
803                "datatype",
804                format!("{v} is not a {}", short(dt)),
805            ),
806        }
807    }
808    if let Some(class) = &p.class {
809        let ok = v.as_iri().is_some_and(|i| is_instance(graph, i, class));
810        if !ok {
811            push(
812                p.severity,
813                path,
814                "class",
815                format!("{v} is not an instance of {}", short(class)),
816            );
817        }
818    }
819}
820
821/// `in`, `pattern`, `minLength`, `maxLength` — the constraints on the value's lexical form.
822fn check_lexical(
823    p: &PropertyShape,
824    v: &Term,
825    push: &mut impl FnMut(Severity, Option<&str>, &'static str, String),
826) {
827    let path = Some(p.path.as_str());
828    let lexical: &str = match v {
829        Term::Iri(i) => i.as_str(),
830        Term::Literal { value, .. } => value.as_str(),
831    };
832    if let Some(allowed) = &p.r#in {
833        let ok = match v {
834            Term::Iri(i) => allowed.iter().any(|a| a.matches_iri(i)),
835            Term::Literal { value, datatype } => {
836                allowed.iter().any(|a| a.matches_literal(value, datatype))
837            }
838        };
839        if !ok {
840            // The PATH is in the message, not only in the result's `path` field: a reader who gets one line
841            // ("quadratic is not one of …") cannot act on it without being told which property said it, and a
842            // shape with nine properties produces nine indistinguishable lines (measured on apex's EV-21).
843            // The value and the list are TERMS (ONT-4b2: `sh:in` is term equality, so `"1"^^xsd:integer` and
844            // `"1"` are different entries), rendered as terms, not as bare lexical forms.
845            let listed: Vec<String> = allowed.iter().map(ToString::to_string).collect();
846            push(
847                p.severity,
848                path,
849                "in",
850                format!(
851                    "{}: {} is not one of [{}]",
852                    short(&p.path),
853                    term_short(v),
854                    listed.join(", ")
855                ),
856            );
857        }
858    }
859    if let Some((src, re)) = &p.pattern {
860        if !re.is_match(lexical) {
861            push(
862                p.severity,
863                path,
864                "pattern",
865                format!("{}: {lexical:?} does not match /{src}/", short(&p.path)),
866            );
867        }
868    }
869    let len = lexical.chars().count();
870    if p.min_length.is_some_and(|m| len < m) {
871        push(
872            p.severity,
873            path,
874            "minLength",
875            format!("{}: length {len} is below minLength", short(&p.path)),
876        );
877    }
878    if p.max_length.is_some_and(|m| len > m) {
879        push(
880            p.severity,
881            path,
882            "maxLength",
883            format!("{}: length {len} is above maxLength", short(&p.path)),
884        );
885    }
886}
887
888/// A term as a message says it: `"ok"` for an xsd:string literal, `"300"^^xsd:byte` for any other typed one,
889/// `ont:id` for an IRI. The datatype is shown only when it carries information — `sh:in` is term equality, so
890/// a message that hid the datatype would name two different terms the same way.
891#[must_use]
892pub fn term_short(v: &Term) -> String {
893    match v {
894        Term::Iri(i) => short(i),
895        Term::Literal { value, datatype } if datatype == XSD_STRING_IRI => format!("{value:?}"),
896        Term::Literal { value, datatype } => format!("{value:?}^^{}", short(datatype)),
897    }
898}
899
900/// `<https://ont.paiml.dev/v1alpha1/id>` → `ont:id`, for messages.
901#[must_use]
902pub fn short(iri: &str) -> String {
903    for (ns, prefix) in [
904        (crate::ontology::rdf::ONT_BASE, "ont"),
905        (XSD_NS, "xsd"),
906        (RDF_NS, "rdf"),
907        (PROV_NS, "prov"),
908    ] {
909        if let Some(rest) = iri.strip_prefix(ns) {
910            return format!("{prefix}:{rest}");
911        }
912    }
913    iri.to_string()
914}
915
916/// The shapes as SHACL Turtle, for the oracle and for anyone running a real processor (§3.6). Deterministic.
917#[must_use]
918pub fn to_turtle(shapes: &[NodeShape]) -> String {
919    let mut out = String::from(
920        "@prefix sh: <http://www.w3.org/ns/shacl#> .\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n@prefix ont: <https://ont.paiml.dev/v1alpha1/> .\n\n",
921    );
922    for s in shapes {
923        out.push_str(&turtle_node(
924            s,
925            &format!("<{}shape/{}>", crate::ontology::rdf::ONT_BASE, s.id),
926        ));
927    }
928    out
929}
930
931fn turtle_node(s: &NodeShape, subject: &str) -> String {
932    let mut o = format!("{subject} a sh:NodeShape ;\n");
933    if !s.target_class.is_empty() {
934        o.push_str(&format!("    sh:targetClass <{}> ;\n", s.target_class));
935    }
936    if s.closed {
937        o.push_str("    sh:closed true ;\n");
938        if !s.ignored_properties.is_empty() {
939            let list: Vec<String> = s
940                .ignored_properties
941                .iter()
942                .map(|p| format!("<{p}>"))
943                .collect();
944            o.push_str(&format!(
945                "    sh:ignoredProperties ( {} ) ;\n",
946                list.join(" ")
947            ));
948        }
949    }
950    for p in &s.properties {
951        o.push_str(&turtle_property(p));
952    }
953    o.push_str(".\n\n");
954    for inner in s.properties.iter().filter_map(|p| p.node.as_deref()) {
955        o.push_str(&turtle_node(
956            inner,
957            &format!("<{}shape/{}>", crate::ontology::rdf::ONT_BASE, inner.id),
958        ));
959    }
960    o
961}
962
963/// One `sh:property [ … ] ;` block. Every implemented component has a line; nothing else is emitted.
964fn turtle_property(p: &PropertyShape) -> String {
965    let mut o = String::from("    sh:property [\n");
966    let mut line = |s: String| o.push_str(&format!("        {s} ;\n"));
967    line(format!("sh:path <{}>", p.path));
968    if let Some(n) = p.min_count {
969        line(format!("sh:minCount {n}"));
970    }
971    if let Some(n) = p.max_count {
972        line(format!("sh:maxCount {n}"));
973    }
974    if let Some(d) = &p.datatype {
975        line(format!("sh:datatype <{d}>"));
976    }
977    if let Some(c) = &p.class {
978        line(format!("sh:class <{c}>"));
979    }
980    if let Some(k) = p.node_kind {
981        line(format!(
982            "sh:nodeKind sh:{}",
983            match k {
984                NodeKind::Iri => "IRI",
985                NodeKind::Literal => "Literal",
986            }
987        ));
988    }
989    if let Some(list) = &p.r#in {
990        // Typed, because `sh:in` is term equality: an untyped `"true"` is an xsd:string and would not match
991        // the xsd:boolean the extractor writes — the difference `make oracle` measures.
992        let items: Vec<String> = list
993            .iter()
994            .map(|v| {
995                if v.datatype == XSD_STRING_IRI {
996                    format!("\"{}\"", v.lexical)
997                } else {
998                    format!("\"{}\"^^<{}>", v.lexical, v.datatype)
999                }
1000            })
1001            .collect();
1002        line(format!("sh:in ( {} )", items.join(" ")));
1003    }
1004    if let Some((src, _)) = &p.pattern {
1005        line(format!(
1006            "sh:pattern \"{}\"",
1007            src.replace('\\', "\\\\").replace('"', "\\\"")
1008        ));
1009    }
1010    if let Some(n) = p.min_length {
1011        line(format!("sh:minLength {n}"));
1012    }
1013    if let Some(n) = p.max_length {
1014        line(format!("sh:maxLength {n}"));
1015    }
1016    if p.severity == Severity::Warning {
1017        line("sh:severity sh:Warning".to_string());
1018    }
1019    if let Some(inner) = &p.node {
1020        line(format!(
1021            "sh:node <{}shape/{}>",
1022            crate::ontology::rdf::ONT_BASE,
1023            inner.id
1024        ));
1025    }
1026    o.push_str("    ] ;\n");
1027    o
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033    use crate::ontology::rdf::{iri, Term};
1034
1035    fn shape(yaml: &str) -> NodeShape {
1036        let doc: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1037        parse_shape("t", &doc).unwrap().unwrap()
1038    }
1039
1040    fn graph_with(id: &str, kind: Option<&str>) -> Graph {
1041        let mut g = Graph::new();
1042        let s = iri("contract", id);
1043        g.insert(s.clone(), RDF_TYPE, Term::iri(ont("Contract")));
1044        g.insert(s.clone(), ont("id"), Term::string(id));
1045        if let Some(k) = kind {
1046            g.insert(s, ont("kind"), Term::string(k));
1047        }
1048        g
1049    }
1050
1051    const BASE: &str = "entity: {type: pv-contract}\nshape:\n  properties:\n    - {path: ont:id, minCount: 1, maxCount: 1, pattern: '^[a-z0-9-]+$'}\n    - {path: ont:kind, maxCount: 1, in: [kernel, pattern]}\n";
1052
1053    #[test]
1054    fn a_conforming_focus_node_yields_no_result() {
1055        let r = validate(&graph_with("a-1", Some("kernel")), &[shape(BASE)]);
1056        assert!(r.conforms(), "{:?}", r.results);
1057        assert_eq!(r.focus_nodes_n, 1);
1058    }
1059
1060    #[test]
1061    fn each_implemented_component_fires_and_names_focus_and_shape() {
1062        let r = validate(&graph_with("Bad Id", Some("novel")), &[shape(BASE)]);
1063        let mut comps: Vec<&str> = r.results.iter().map(|x| x.component).collect();
1064        comps.sort_unstable();
1065        assert_eq!(comps, vec!["in", "pattern"], "{:?}", r.results);
1066        assert!(r.results[0].focus.ends_with("/contract/Bad%20Id"));
1067        assert_eq!(r.results[0].shape, "t");
1068        let mut g = Graph::new();
1069        g.insert(iri("contract", "x"), RDF_TYPE, Term::iri(ont("Contract"))); // no ont:id at all
1070        let r = validate(&g, &[shape(BASE)]);
1071        assert_eq!(r.results[0].component, "minCount");
1072    }
1073
1074    #[test]
1075    fn closed_rejects_an_undeclared_predicate_and_ignores_rdf_type() {
1076        let mut g = graph_with("a", None);
1077        g.insert(iri("contract", "a"), ont("extra"), Term::string("x"));
1078        let s = shape("entity: {type: pv-contract}\nshape:\n  closed: true\n  properties:\n    - {path: ont:id}\n");
1079        let r = validate(&g, &[s]);
1080        assert_eq!(r.violations(), 1);
1081        assert_eq!(r.results[0].component, "closed");
1082    }
1083
1084    #[test]
1085    fn datatype_class_nodekind_and_lengths_fire() {
1086        let mut g = graph_with("a", None);
1087        let a = iri("contract", "a");
1088        g.insert(a.clone(), ont("n"), Term::string("7"));
1089        g.insert(a.clone(), ont("dep"), Term::string("not-an-iri"));
1090        let s = shape("entity: {type: pv-contract}\nshape:\n  properties:\n    - {path: ont:n, datatype: xsd:integer, maxLength: 0}\n    - {path: ont:dep, nodeKind: IRI, class: ont:Contract}\n");
1091        let r = validate(&g, &[s]);
1092        let mut comps: Vec<&str> = r.results.iter().map(|x| x.component).collect();
1093        comps.sort_unstable();
1094        assert_eq!(
1095            comps,
1096            vec!["class", "datatype", "maxLength", "nodeKind"],
1097            "{:?}",
1098            r.results
1099        );
1100    }
1101
1102    #[test]
1103    fn a_warning_only_report_conforms_but_counts_the_warning() {
1104        let g = graph_with("a", Some("novel"));
1105        let s = shape("entity: {type: pv-contract}\nshape:\n  properties:\n    - {path: ont:kind, in: [kernel], severity: warning}\n");
1106        let r = validate(&g, &[s]);
1107        assert!(r.conforms());
1108        assert_eq!(r.warnings(), 1);
1109    }
1110
1111    #[test]
1112    fn node_one_level_validates_the_value_and_two_levels_are_refused() {
1113        let mut g = graph_with("a", None);
1114        let a = iri("contract", "a");
1115        let b = iri("contract", "b");
1116        g.insert(a.clone(), ont("depends_on"), Term::iri(b.clone()));
1117        g.insert(b.clone(), RDF_TYPE, Term::iri(ont("Contract")));
1118        let s = shape("entity: {type: pv-contract}\nshape:\n  properties:\n    - {path: ont:depends_on, node: {properties: [{path: ont:id, minCount: 1}]}}\n");
1119        let r = validate(&g, &[s]);
1120        assert_eq!(r.results.len(), 1, "{:?}", r.results);
1121        assert_eq!(r.results[0].component, "node");
1122        let doc: serde_yaml::Value = serde_yaml::from_str("entity: {type: pv-contract}\nshape:\n  properties:\n    - {path: ont:x, node: {properties: [{path: ont:y, node: {properties: []}}]}}\n").unwrap();
1123        assert!(matches!(
1124            parse_shape("t", &doc),
1125            Err(ShapeError::Unsupported { .. })
1126        ));
1127    }
1128
1129    #[test]
1130    fn unsupported_components_are_refused_at_parse_by_name() {
1131        for (yaml, want) in [
1132            ("shape:\n  targetNode: x\n", "targetNode"),
1133            ("entity: {type: pv-contract}\nshape:\n  properties: [{path: ont:x, qualifiedValueShape: {}}]\n", "qualifiedValueShape"),
1134            ("entity: {type: pv-contract}\nshape:\n  properties: [{path: ont:x, languageIn: [en]}]\n", "languageIn"),
1135            ("entity: {type: pv-contract}\nshape:\n  properties: [{path: 'ont:a/ont:b'}]\n", "path `ont:a/ont:b`"),
1136            ("entity: {type: pv-contract}\nshape:\n  or: []\n", "or"),
1137        ] {
1138            let doc: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1139            match parse_shape("t", &doc) {
1140                Err(ShapeError::Unsupported { component, .. }) => assert!(component.starts_with(want), "{component} vs {want}"),
1141                other => panic!("{yaml}: expected Unsupported, got {other:?}"),
1142            }
1143        }
1144    }
1145
1146    #[test]
1147    fn a_shape_with_no_target_and_no_pv_contract_entity_is_malformed() {
1148        let doc: serde_yaml::Value = serde_yaml::from_str("shape:\n  properties: []\n").unwrap();
1149        assert!(matches!(
1150            parse_shape("t", &doc),
1151            Err(ShapeError::Malformed { .. })
1152        ));
1153        let doc: serde_yaml::Value =
1154            serde_yaml::from_str("shape:\n  targetClass: ont:Contract\n  properties: []\n")
1155                .unwrap();
1156        assert_eq!(
1157            parse_shape("t", &doc).unwrap().unwrap().target_class,
1158            ont("Contract")
1159        );
1160    }
1161
1162    #[test]
1163    fn turtle_export_is_deterministic_and_names_every_component() {
1164        let s = shape(BASE);
1165        let t1 = to_turtle(std::slice::from_ref(&s));
1166        let t2 = to_turtle(std::slice::from_ref(&s));
1167        assert_eq!(t1, t2);
1168        for want in [
1169            "sh:NodeShape",
1170            "sh:targetClass",
1171            "sh:minCount 1",
1172            "sh:maxCount 1",
1173            "sh:pattern",
1174            "sh:in ( \"kernel\" \"pattern\" )",
1175        ] {
1176            assert!(t1.contains(want), "{want}\n{t1}");
1177        }
1178    }
1179
1180    #[test]
1181    fn prefixes_expand_by_the_stated_rule() {
1182        assert_eq!(expand("ont:id"), "https://ont.paiml.dev/v1alpha1/id");
1183        assert_eq!(
1184            expand("xsd:integer"),
1185            "http://www.w3.org/2001/XMLSchema#integer"
1186        );
1187        assert_eq!(
1188            expand("readme:kind"),
1189            "https://ont.paiml.dev/v1alpha1/readme/kind"
1190        );
1191        assert_eq!(expand("https://x/y"), "https://x/y");
1192        assert_eq!(short("https://ont.paiml.dev/v1alpha1/id"), "ont:id");
1193    }
1194}