Skip to main content

cimvalidation/
bag.rs

1//! Validates a dataset against a [`crate::shapes`] table.
2//!
3//! It replaced generated per-check validators that read typed struct fields.
4//! Here every value is the string the XML carried, so the checks those treated
5//! as tautologies — `sh:datatype`, `sh:nodeKind` — are real, and `sh:closed`
6//! becomes answerable at all.
7//!
8//! Which elements a run reads is a [`Source`]: each family's shapes read that
9//! family's elements. Every element holds every field the XML carried, which
10//! is what lets one interpreter serve both.
11
12
13use cimmodel::{CimDataset, Element};
14use cimmodel::base::{FastMap, FastSet, FieldMap, FieldValue};
15
16use crate::helpers;
17use crate::par::{par_concat, par_map, runs, threads_for};
18use crate::shapes::{
19    AltBranch, Branch, Check, ClosedShape, Constraint, Logic, LogicOp, NodeKind, Path, PropShape, ShapeDef, Step,
20    Target,
21};
22use crate::{Config, Violation};
23
24/// Maps a referenced mRID back to the elements pointing at it.
25///
26/// `sh:inversePath` asks "how many things point at me", which the forward
27/// fields cannot answer. Built once per call in O(associations) rather than
28/// rescanned per shape, and only over the fields some active shape asks about.
29///
30/// A plain inverse path needs only the count; an inverse step inside a
31/// [`Path::Chain`] has to continue from the elements themselves, so those
32/// fields keep the list. Keys borrow from the dataset: an owned key cost an
33/// allocation per reference at build time and another per lookup.
34#[derive(Default)]
35struct ReverseIndex<'a> {
36    /// `(target mrid, field key)` → how many elements point at it that way.
37    counts: FastMap<(&'a str, &'static str), u32>,
38    /// `(target mrid, field key)` → the elements pointing at it that way.
39    sources: FastMap<(&'a str, &'static str), Vec<&'a str>>,
40}
41
42impl<'a> ReverseIndex<'a> {
43    fn count(&self, mrid: &str, field: &'static str) -> u32 {
44        self.counts.get(&(mrid, field)).copied().unwrap_or(0)
45    }
46
47    fn sources<'s>(&'s self, mrid: &'a str, field: &'static str) -> &'s [&'a str] {
48        self.sources.get(&(mrid, field)).map_or(&[], Vec::as_slice)
49    }
50}
51
52/// Everything a check needs besides the element itself.
53struct Ctx<'a> {
54    ds: &'a CimDataset,
55    /// The other files validated with `ds`, read only for the type of an
56    /// object `ds` does not hold, and only by NC shapes ([`any_type`]).
57    set: &'a [CimDataset],
58    source: Source,
59    reverse: ReverseIndex<'a>,
60}
61
62/// What a [`Path::Chain`] collected.
63#[derive(Clone, Copy, PartialEq, Eq)]
64enum Kind {
65    Text,
66    Refs,
67}
68
69/// The values a path yields for one element, as far as a constraint needs them.
70enum Values<'a> {
71    /// Literal text values. A slice of the stored field, so a single value
72    /// costs no allocation — this runs once per property per target.
73    Text(&'a [String]),
74    /// `rdf:resource` references.
75    Refs(&'a [String]),
76    /// Only a count is available — an inverse or alternative path.
77    Count(u32),
78    Absent,
79    /// The end of a [`Path::Chain`]. Owned, because a chain gathers values
80    /// from several elements.
81    Chain(Kind, Vec<&'a str>),
82    /// A chain stepped onto an element this dataset does not hold. What lies
83    /// beyond is not known, so nothing can be said about it either way.
84    Unknown,
85}
86
87impl<'a> Values<'a> {
88    /// `None` when unknown: no cardinality can be asserted.
89    fn count(&self) -> Option<u32> {
90        Some(match self {
91            Values::Text(v) | Values::Refs(v) => v.len() as u32,
92            Values::Count(n) => *n,
93            Values::Absent => 0,
94            Values::Chain(_, v) => v.len() as u32,
95            Values::Unknown => return None,
96        })
97    }
98
99    /// Does `f` hold for any literal value?
100    fn any_text(&self, mut f: impl FnMut(&str) -> bool) -> bool {
101        match self {
102            Values::Text(vs) => vs.iter().any(|v| f(v)),
103            Values::Chain(Kind::Text, vs) => vs.iter().any(|v| f(v)),
104            _ => false,
105        }
106    }
107
108    /// Does `f` hold for any referenced mRID?
109    fn any_ref(&self, mut f: impl FnMut(&str) -> bool) -> bool {
110        match self {
111            Values::Refs(vs) => vs.iter().any(|v| f(v)),
112            Values::Chain(Kind::Refs, vs) => vs.iter().any(|v| f(v)),
113            _ => false,
114        }
115    }
116
117    fn is_text(&self) -> bool {
118        matches!(self, Values::Text(_) | Values::Chain(Kind::Text, _))
119    }
120
121    fn is_refs(&self) -> bool {
122        matches!(self, Values::Refs(_) | Values::Chain(Kind::Refs, _))
123    }
124}
125
126type Fields = FieldMap;
127
128/// Which elements a shape table reads: its own family's.
129///
130/// Not a convenience — the families' shapes would otherwise reach each other's
131/// elements. NC has `sh:targetSubjectsOf` shapes on `IdentifiedObject.name`,
132/// so reading typed elements would make every named CGMES element in a mixed
133/// dataset an NC target; and an inverse-path count would include references
134/// from the other family.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum Source {
137    /// Network Code profile elements (`nc:` classes).
138    Nc,
139    /// CGMES elements.
140    Cgmes,
141}
142
143impl Source {
144    /// The element's fields, or `None` if it is another family's.
145    fn fields(self, entry: &Element) -> Option<&Fields> {
146        self.owns(entry).then(|| entry.fields())
147    }
148
149    /// Whether `entry` belongs to this source's family. Families are told
150    /// apart by their class names: NC's carry the `nc:` prefix.
151    fn owns(self, entry: &Element) -> bool {
152        entry.type_name().starts_with("nc:") == (self == Source::Nc)
153    }
154}
155
156fn refs_of<'a>(f: &'a Fields, field: &str) -> &'a [String] {
157    match f.get(field) {
158        Some(FieldValue::Resource(s)) => std::slice::from_ref(s),
159        Some(FieldValue::ResourceList(v)) => v.as_slice(),
160        _ => &[],
161    }
162}
163
164fn field_values<'a>(f: &'a Fields, field: &str) -> Values<'a> {
165    match f.get(field) {
166        Some(FieldValue::Text(s)) => Values::Text(std::slice::from_ref(s)),
167        Some(FieldValue::TextList(v)) => Values::Text(v.as_slice()),
168        Some(FieldValue::Resource(_)) | Some(FieldValue::ResourceList(_)) => {
169            Values::Refs(refs_of(f, field))
170        }
171        None => Values::Absent,
172    }
173}
174
175/// A numeric value, or `None` if it does not parse — a typed numeric field
176/// that failed to parse is `None` and skipped the same way.
177fn number(v: &str) -> Option<f64> {
178    v.trim().parse::<f64>().ok()
179}
180
181fn resolve<'a>(ctx: &Ctx<'a>, el: &'a Fields, mrid: &'a str, path: &Path) -> Values<'a> {
182    match path {
183        Path::Forward(field) => field_values(el, field),
184        // `( X.y rdf:type )`: what the general walk below yields for it — the
185        // field's references, or its literal — without the allocations.
186        Path::Chain([Step::Forward(field), Step::Type]) => field_values(el, field),
187        Path::Inverse(field) => Values::Count(ctx.reverse.count(mrid, field)),
188        Path::Alternative(branches) => {
189            // The union across branches. Only the count is well defined: the
190            // branches can mix literals, references and inverse hops, and every
191            // NCP alternative shape asks a cardinality question.
192            let mut total = 0;
193            for b in *branches {
194                total += match b {
195                    AltBranch::Forward(field) => field_values(el, field).count().unwrap_or(0),
196                    AltBranch::Inverse(field) => ctx.reverse.count(mrid, field),
197                };
198            }
199            Values::Count(total)
200        }
201        Path::Chain(steps) => chain(ctx, el, mrid, steps),
202    }
203}
204
205/// Walk a sequence path from `mrid`.
206///
207/// Every element stepped *from* must be in the dataset and of this source,
208/// since its fields are read; otherwise the result is [`Values::Unknown`]. An
209/// inverse step never needs that — the reverse index holds what points here,
210/// and neither does a final `rdf:type` step. A literal reached before the last
211/// step ends that branch of the walk, unless `rdf:type` follows: then it is
212/// the value, the way a one-step path would report it.
213fn chain<'a>(ctx: &Ctx<'a>, focus: &'a Fields, mrid: &'a str, steps: &[Step]) -> Values<'a> {
214    let mut nodes: Vec<&'a str> = vec![mrid];
215    for (i, step) in steps.iter().enumerate() {
216        let last = i + 1 == steps.len();
217        match step {
218            Step::Forward(field) => {
219                let mut next: Vec<&'a str> = Vec::new();
220                let mut texts: Vec<&'a str> = Vec::new();
221                for &node in &nodes {
222                    let fields = if node == mrid {
223                        focus
224                    } else {
225                        match ctx.ds.entries.get(node).and_then(|e| ctx.source.fields(e)) {
226                            Some(f) => f,
227                            None => return Values::Unknown,
228                        }
229                    };
230                    match fields.get(*field) {
231                        Some(FieldValue::Text(s)) => texts.push(s),
232                        Some(FieldValue::TextList(v)) => texts.extend(v.iter().map(String::as_str)),
233                        Some(FieldValue::Resource(r)) => next.push(r),
234                        Some(FieldValue::ResourceList(v)) => next.extend(v.iter().map(String::as_str)),
235                        None => {}
236                    }
237                }
238                // A literal where the path continues to rdf:type is reported
239                // as the literal it is, so `sh:nodeKind sh:IRI` can flag it.
240                let before_type = matches!(steps.get(i + 1), Some(Step::Type));
241                if (last || before_type) && !texts.is_empty() {
242                    return Values::Chain(Kind::Text, texts);
243                }
244                if last {
245                    return Values::Chain(Kind::Refs, next);
246                }
247                nodes = next;
248            }
249            Step::Inverse(field) => {
250                let next: Vec<&'a str> =
251                    nodes.iter().flat_map(|n| ctx.reverse.sources(n, field).iter().copied()).collect();
252                if last {
253                    return Values::Chain(Kind::Refs, next);
254                }
255                nodes = next;
256            }
257            // The values are the elements reached. Nothing is read from them,
258            // so they count even when absent; the class constraints a type
259            // path carries look up the ones that are present.
260            Step::Type => return Values::Chain(Kind::Refs, nodes),
261        }
262    }
263    Values::Chain(Kind::Refs, nodes)
264}
265
266/// Is `s` a valid lexical form of this xsd type?
267///
268/// The whole reason `sh:datatype` is worth checking here: a bag holds the text
269/// as written, so a malformed integer survives decoding.
270fn datatype_ok(s: &str, xsd: &str) -> bool {
271    let t = s.trim();
272    match xsd {
273        "string" | "normalizedString" | "token" | "Name" | "NCName" | "language" => true,
274        "boolean" => matches!(t, "true" | "false" | "1" | "0"),
275        "integer" | "int" | "long" | "short" | "byte" | "nonPositiveInteger"
276        | "negativeInteger" => t.parse::<i64>().is_ok(),
277        "nonNegativeInteger" | "positiveInteger" | "unsignedInt" | "unsignedLong"
278        | "unsignedShort" | "unsignedByte" => t.parse::<u64>().is_ok(),
279        "decimal" | "float" | "double" => t.parse::<f64>().is_ok(),
280        "dateTime" | "dateTimeStamp" => helpers::is_xsd_datetime(t),
281        "date" => helpers::is_xsd_date(t),
282        "gMonthDay" => helpers::is_xsd_gmonthday(t),
283        "anyURI" => helpers::is_xsd_anyuri(t),
284        // An xsd type this does not model is not evidence of a violation.
285        _ => true,
286    }
287}
288
289fn violation(mrid: &str, class: &str, property: &str, c: &Check) -> Violation {
290    Violation {
291        object_id: mrid.to_string(),
292        rule_id: c.rule_id.to_string(),
293        class: class.to_string(),
294        property: property.to_string(),
295        message: c.message.to_string(),
296        severity: c.severity.to_string(),
297        name: c.name.to_string(),
298        description: c.description.to_string(),
299    }
300}
301
302/// A path's field key, for the `property` column of a violation: the property
303/// of the focus element the path starts from.
304fn path_label(path: &Path) -> std::borrow::Cow<'static, str> {
305    use std::borrow::Cow;
306    // An inverse step reads as SHACL writes it: `^Terminal.ConductingEquipment`
307    // on a Switch means the terminals pointing at it, not an attribute of its own.
308    let inverse = |f: &str| Cow::Owned(format!("^{f}"));
309    match path {
310        Path::Forward(f) => Cow::Borrowed(f),
311        Path::Inverse(f) => inverse(f),
312        Path::Alternative(branches) => match branches.first() {
313            Some(AltBranch::Forward(f)) => Cow::Borrowed(f),
314            Some(AltBranch::Inverse(f)) => inverse(f),
315            None => Cow::Borrowed(""),
316        },
317        Path::Chain(steps) => match steps.first() {
318            Some(Step::Forward(f)) => Cow::Borrowed(f),
319            Some(Step::Inverse(f)) => inverse(f),
320            _ => Cow::Borrowed(""),
321        },
322    }
323}
324
325/// Does any type of the referenced object `mrid` satisfy `test`? `None`
326/// when it is not found.
327///
328/// The types are those of `ds`'s element when it has one. Otherwise, for NC,
329/// every type the set's other files give it, as their merge would; for CGMES
330/// the check is silent.
331///
332/// The families' value types are written differently. NCP's list every class
333/// the association may reach in any namespace (`ps:PowerSchedule.GeneratingUnit`
334/// names the CGMES, CIM16, CIM17 and NC units), since the target is never in
335/// the referring file — a schedule points into EQ, a remedial action into
336/// another dataset. CGMES's per-profile files list only the classes of their
337/// own profile (`sv:SvStatus.ConductingEquipment` the two converters,
338/// `dl:DiagramObject.IdentifiedObject` the diagram classes): they hold on one
339/// file, where an EQ object has no type, and the cross-profile files
340/// (`sv456cpe:SvStatus.ConductingEquipment-valueType`, `sh:class
341/// ConductingEquipment`) check the type on the merged dataset. Read across
342/// the set, the CGMES ones failed some 31,000 times on the ENTSO-E test
343/// configurations, every association into EQ. An object no file holds is a
344/// dangling reference, which a cross-profile rule reports.
345fn any_type(ctx: &Ctx<'_>, mrid: &str, test: impl Fn(&cimmodel::base::ClassDef) -> bool) -> Option<bool> {
346    if let Some(e) = ctx.ds.entries.get(mrid) {
347        return Some(e.types().any(test));
348    }
349    if ctx.source != Source::Nc {
350        return None;
351    }
352    let mut held = false;
353    for e in ctx.set.iter().filter_map(|d| d.entries.get(mrid)) {
354        held = true;
355        if e.types().any(&test) {
356            return Some(true);
357        }
358    }
359    held.then_some(false)
360}
361
362/// Does `values` violate `constraint`? Unknown values violate nothing.
363fn fails(ctx: &Ctx<'_>, el: &Fields, values: &Values<'_>, constraint: &Constraint) -> bool {
364    if matches!(values, Values::Unknown) {
365        return false;
366    }
367    match constraint {
368        Constraint::MinCount(n) => values.count().is_some_and(|c| c < *n),
369        Constraint::MaxCount(n) => values.count().is_some_and(|c| c > *n),
370
371        // A literal constraint on a reference is a node-kind problem, which
372        // the shape's own sh:nodeKind check reports. Flagging it twice would
373        // double-count one mistake.
374        Constraint::Datatype(xsd) => values.any_text(|v| !datatype_ok(v, xsd)),
375
376        // An inverse or alternative path yields only a count, so there is no
377        // node to inspect.
378        Constraint::NodeKind(kind) => match kind {
379            NodeKind::Literal => values.is_refs() && values.count() != Some(0),
380            NodeKind::Iri => values.is_text() && values.count() != Some(0),
381            // A reference decodes the same whether the XML wrote an IRI or a
382            // blank node, so only a literal is certainly not one.
383            NodeKind::BlankNode => values.is_text() && values.count() != Some(0),
384        },
385
386        Constraint::In(allowed) => {
387            values.any_text(|v| !allowed.contains(&v.trim())) || values.any_ref(|r| !allowed.contains(&r))
388        }
389        Constraint::HasValue(want) => match values {
390            v if v.is_text() => !v.any_text(|t| t.trim() == *want),
391            v if v.is_refs() => !v.any_ref(|r| r == *want),
392            _ => false,
393        },
394
395        Constraint::MaxLength(n) => values.any_text(|v| v.chars().count() as u32 > *n),
396        Constraint::MinLength(n) => values.any_text(|v| (v.chars().count() as u32) < *n),
397        Constraint::Length(n) => values.any_text(|v| v.chars().count() as u32 != *n),
398
399        // An element merged from files that typed it differently has several
400        // types (`Element::types`). `sh:class` needs one of them to be an
401        // instance; `sh:in` on an `rdf:type` path tests each type, as each is
402        // a value of the path — a type outside the list fails it.
403        Constraint::Class(allowed) => {
404            values.any_ref(|r| any_type(ctx, r, |t| allowed.contains(&t.qualified)) == Some(false))
405        }
406        Constraint::RefClass(allowed) => {
407            values.any_ref(|r| any_type(ctx, r, |t| !allowed.contains(&t.qualified)) == Some(true))
408        }
409        Constraint::NotClass(forbidden) => {
410            values.any_ref(|r| any_type(ctx, r, |t| forbidden.contains(&t.qualified)) == Some(true))
411        }
412        Constraint::QualifiedIn { allowed, min } => {
413            let mut n = 0u32;
414            values.any_text(|v| {
415                n += u32::from(allowed.contains(&v.trim()));
416                false
417            });
418            values.any_ref(|r| {
419                n += u32::from(allowed.contains(&r));
420                false
421            });
422            n < *min
423        }
424
425        Constraint::MinInclusive(b) => values.any_text(|v| number(v).is_some_and(|x| x < *b)),
426        Constraint::MaxInclusive(b) => values.any_text(|v| number(v).is_some_and(|x| x > *b)),
427        Constraint::MinExclusive(b) => values.any_text(|v| number(v).is_some_and(|x| x <= *b)),
428        Constraint::MaxExclusive(b) => values.any_text(|v| number(v).is_some_and(|x| x >= *b)),
429
430        // Every pairing of this property's values with the other's, as SHACL
431        // defines it. Silent unless both sides are present.
432        Constraint::LessThan(other) => pair(values, el, other, |a, b| a < b),
433        Constraint::LessThanOrEquals(other) => pair(values, el, other, |a, b| a <= b),
434    }
435}
436
437/// `IdentifiedObject.mRID`'s field key.
438const MRID: &str = "IdentifiedObject.mRID";
439
440/// Is `prop` an mRID cardinality the element need not meet here?
441///
442/// A CGMES object's mRID is exchanged where the object is defined
443/// (`rdf:ID`), not in the files that only add to it (`rdf:about`). The SSH
444/// SHACL nevertheless requires one on its `ssh:Equipment` shape, so every
445/// SSH file that writes `inService` under `cim:Equipment` — the ENTSO-E
446/// MicroGrid and Svedala among them — fails it once per piece of equipment.
447/// Only the minimum is skipped. NC objects are defined with `rdf:about`, so
448/// this never applies to them.
449fn mrid_exempt(t: &Resolved<'_>, prop: &PropShape) -> bool {
450    t.described && prop.path == Path::Forward(MRID)
451}
452
453fn check_prop<'a>(
454    ctx: &Ctx<'a>,
455    el: &'a Fields,
456    class: &str,
457    mrid: &'a str,
458    prop: &PropShape,
459    no_min: bool,
460    out: &mut Vec<Violation>,
461) {
462    let values = resolve(ctx, el, mrid, &prop.path);
463    for c in prop.checks {
464        if no_min && matches!(c.constraint, Constraint::MinCount(_)) {
465            continue;
466        }
467        if fails(ctx, el, &values, &c.constraint) {
468            out.push(violation(mrid, class, &path_label(&prop.path), c));
469        }
470    }
471}
472
473/// Does a [`Logic`] branch conform? `None` when a path in it left the
474/// dataset, so its conformance is not known.
475fn branch_conforms<'a>(ctx: &Ctx<'a>, el: &'a Fields, mrid: &'a str, branch: &Branch) -> Option<bool> {
476    let mut holds = true;
477    for prop in branch.props {
478        let values = resolve(ctx, el, mrid, &prop.path);
479        if matches!(values, Values::Unknown) {
480            return None;
481        }
482        if prop.checks.iter().any(|c| fails(ctx, el, &values, &c.constraint)) {
483            holds = false;
484            break;
485        }
486    }
487    Some(holds != branch.negate)
488}
489
490/// Node-level `sh:and` / `sh:or` / `sh:xone`. Reported with the node shape's
491/// name as the property, which is what the generated validators report.
492fn check_logic<'a>(
493    ctx: &Ctx<'a>,
494    el: &'a Fields,
495    class: &str,
496    mrid: &'a str,
497    logic: &Logic,
498    out: &mut Vec<Violation>,
499) {
500    let mut conforming = 0usize;
501    for branch in logic.branches {
502        match branch_conforms(ctx, el, mrid, branch) {
503            Some(true) => conforming += 1,
504            Some(false) => {}
505            // An unknown branch could tip any of the three either way.
506            None => return,
507        }
508    }
509    let holds = match logic.op {
510        LogicOp::And => conforming == logic.branches.len(),
511        LogicOp::Or => conforming >= 1,
512        LogicOp::Xone => conforming == 1,
513    };
514    if !holds {
515        out.push(Violation {
516            object_id: mrid.to_string(),
517            rule_id: logic.rule_id.to_string(),
518            class: class.to_string(),
519            property: logic.name.to_string(),
520            message: logic.message.to_string(),
521            severity: logic.severity.to_string(),
522            name: logic.name.to_string(),
523            description: logic.description.to_string(),
524        });
525    }
526}
527
528/// Does any pairing of this property's numbers with `other`'s fail `holds`?
529fn pair(values: &Values, el: &Fields, other: &str, holds: impl Fn(f64, f64) -> bool) -> bool {
530    let others = field_values(el, other);
531    values.any_text(|a| {
532        number(a).is_some_and(|a| others.any_text(|b| number(b).is_some_and(|b| !holds(a, b))))
533    })
534}
535
536/// `sh:closed` — report every field the profile does not allow on this class.
537fn check_closed(el: &Fields, class: &str, mrid: &str, closed: &ClosedShape, out: &mut Vec<Violation>) {
538    let mut extra: Vec<&str> = el
539        .keys()
540        .copied()
541        .filter(|k| !closed.allowed.contains(k))
542        .collect();
543    // Deterministic order: HashMap iteration is not, and a violation list that
544    // reorders between runs is unusable in a diff.
545    extra.sort_unstable();
546
547    for field in extra {
548        out.push(Violation {
549            object_id: mrid.to_string(),
550            rule_id: closed.rule_id.to_string(),
551            class: class.to_string(),
552            property: field.to_string(),
553            message: closed.message.to_string(),
554            severity: closed.severity.to_string(),
555            name: closed.name.to_string(),
556            description: closed.description.to_string(),
557        });
558    }
559}
560
561/// A target element, resolved once per call: its mRID, its class, the
562/// fields it is read through, and whether it is a CGMES object no file
563/// defined ([`mrid_exempt`]).
564#[derive(Clone, Copy)]
565struct Resolved<'a> {
566    mrid: &'a String,
567    class: &'static str,
568    fields: &'a Fields,
569    described: bool,
570}
571
572impl<'a> Resolved<'a> {
573    fn new(source: Source, mrid: &'a String, entry: &'a Element, fields: &'a Fields) -> Self {
574        let described = source == Source::Cgmes && !entry.defined();
575        Resolved { mrid, class: entry.type_name(), fields, described }
576    }
577}
578
579/// Resolve `mrid` for `source`: `None` when it is missing or another family's
580/// element — e.g. a CGMES element under an NC shape, whose attributes that
581/// family does not define and would report as absent.
582fn resolve_target<'a>(ds: &'a CimDataset, source: Source, mrid: &'a String) -> Option<Resolved<'a>> {
583    let entry = ds.entries.get(mrid)?;
584    let fields = source.fields(entry)?;
585    Some(Resolved::new(source, mrid, entry, fields))
586}
587
588/// The reverse index and the `sh:targetSubjectsOf` index, built in one pass.
589///
590/// Both need a walk over every element, and that walk — random access into a
591/// map of every element, then into each one's fields — was most of their
592/// cost; one pass does it once. Only fields some active shape asks about are
593/// indexed, and nothing is scanned when no shape asks.
594///
595/// `sh:targetSubjectsOf` has no `by_type` to answer it, and scanning per shape
596/// is O(elements x shapes): on a 20k-element dataset that cost 10 ms for a
597/// profile whose shapes matched nothing.
598fn build_indexes<'a>(
599    ds: &'a CimDataset,
600    source: Source,
601    count_fields: &[&'static str],
602    list_fields: &[&'static str],
603    subject_fields: &[&'static str],
604) -> (ReverseIndex<'a>, FastMap<&'static str, Vec<Resolved<'a>>>) {
605    if count_fields.is_empty() && list_fields.is_empty() && subject_fields.is_empty() {
606        return (ReverseIndex::default(), FastMap::default());
607    }
608    type Partial<'a> = (ReverseIndex<'a>, FastMap<&'static str, Vec<Resolved<'a>>>);
609    let index = |entries: &mut dyn Iterator<Item = (&'a String, &'a Element)>| -> Partial<'a> {
610        let mut reverse = ReverseIndex::default();
611        let mut subjects: FastMap<&'static str, Vec<Resolved<'a>>> =
612            subject_fields.iter().map(|f| (*f, Vec::new())).collect();
613        for (mrid, entry) in entries {
614            let Some(f) = source.fields(entry) else { continue };
615            for field in count_fields {
616                for target in refs_of(f, field) {
617                    *reverse.counts.entry((target.as_str(), *field)).or_insert(0) += 1;
618                }
619            }
620            for field in list_fields {
621                for target in refs_of(f, field) {
622                    reverse.sources.entry((target.as_str(), *field)).or_default().push(mrid);
623                }
624            }
625            for field in subject_fields {
626                if f.contains_key(*field) {
627                    let r = Resolved::new(source, mrid, entry, f);
628                    subjects.get_mut(field).expect("seeded above").push(r);
629                }
630            }
631        }
632        (reverse, subjects)
633    };
634
635    let threads = threads_for(ds.entries.len());
636    if threads == 1 {
637        return index(&mut ds.entries.iter());
638    }
639    // One pass split into contiguous runs of the map's own order, each indexed
640    // on its own thread, then merged in run order: the lists come out exactly
641    // as the single pass builds them.
642    let all: Vec<(&'a String, &'a Element)> = ds.entries.iter().collect();
643    let parts: Vec<Partial<'a>> = par_map(&runs(&all, threads, |_| 1), |run| index(&mut run.iter().copied()));
644    let mut parts = parts.into_iter();
645    let (mut reverse, mut subjects) = parts.next().expect("at least one run");
646    for (r, subj) in parts {
647        for (k, n) in r.counts {
648            *reverse.counts.entry(k).or_insert(0) += n;
649        }
650        for (k, list) in r.sources {
651            reverse.sources.entry(k).or_default().extend(list);
652        }
653        for (field, list) in subj {
654            subjects.get_mut(field).expect("seeded above").extend(list);
655        }
656    }
657    (reverse, subjects)
658}
659
660/// Every element a shape's targets select, in `by_type` order — for a shape
661/// that mixes target kinds; the others are walked element-major in
662/// [`validate_shapes`].
663///
664/// That order is reproducible from run to run: decoding appends in document
665/// order, and `merge` walks a map whose hasher has a fixed seed. A class's
666/// elements are resolved once per call and shared with the element-major walk.
667fn targets_of<'a>(
668    ds: &'a CimDataset,
669    source: Source,
670    shape: &ShapeDef,
671    subjects: &FastMap<&'static str, Vec<Resolved<'a>>>,
672    by_class: &mut FastMap<&'static str, Vec<Resolved<'a>>>,
673) -> Vec<Resolved<'a>> {
674    let mut out: Vec<Resolved<'a>> = Vec::new();
675    for target in shape.targets {
676        match target {
677            Target::Class(classes) => {
678                for class in *classes {
679                    let list = by_class.entry(class).or_insert_with(|| {
680                        ds.by_type
681                            .get(*class)
682                            .into_iter()
683                            .flatten()
684                            .filter_map(|mrid| resolve_target(ds, source, mrid))
685                            .collect()
686                    });
687                    out.extend_from_slice(list);
688                }
689            }
690            Target::SubjectsOf(field) => {
691                out.extend_from_slice(subjects.get(field).map_or(&[], Vec::as_slice));
692            }
693        }
694    }
695    // A shape's targets overlap whenever it lists a class and one of its
696    // subclasses (`cim:Switch, cim:Breaker`), and a subjectsOf target can pick
697    // any of them again. SHACL validates a focus node once per shape.
698    let mut seen = FastSet::default();
699    out.retain(|t| seen.insert(t.mrid));
700    out
701}
702
703/// Every check of one shape against one target.
704fn run_shape<'a>(ctx: &Ctx<'a>, shape: &ShapeDef, t: &Resolved<'a>, out: &mut Vec<Violation>) {
705    for prop in shape.props {
706        check_prop(ctx, t.fields, t.class, t.mrid, prop, mrid_exempt(t, prop), out);
707    }
708    for logic in shape.logic {
709        check_logic(ctx, t.fields, t.class, t.mrid, logic, out);
710    }
711    if let Some(closed) = shape.closed {
712        check_closed(t.fields, t.class, t.mrid, closed, out);
713    }
714}
715
716/// Validate one profile's shapes against a dataset.
717///
718/// `shapes` is the whole table; only shapes belonging to `profile` run, which is
719/// what makes a shared constraint file (imported by 17 of 18 manifests) cost
720/// nothing on the profiles that do not use it.
721pub fn validate_profile(
722    ds: &CimDataset,
723    profile: &str,
724    shapes: &'static [ShapeDef],
725    cfg: &Config,
726) -> Vec<Violation> {
727    validate_profile_in(ds, &[], profile, shapes, cfg)
728}
729
730/// [`validate_profile`] on one file of a set: the class checks find a
731/// referenced object's type in the set's other files (see [`validate_shapes_in`]).
732pub fn validate_profile_in(
733    ds: &CimDataset,
734    set: &[CimDataset],
735    profile: &str,
736    shapes: &'static [ShapeDef],
737    _cfg: &Config,
738) -> Vec<Violation> {
739    let active: Vec<&ShapeDef> = shapes
740        .iter()
741        .filter(|s| s.profiles.contains(&profile))
742        .collect();
743    validate_shapes_in(ds, set, Source::Nc, &active)
744}
745
746/// Elements per slice of a class's targets, the unit of work a thread takes.
747const SLICE: usize = 2048;
748
749/// Validate a dataset against shapes the caller has already selected.
750///
751/// The selection is the caller's because the families select differently: NC
752/// by the profile codes its manifests assign, CGMES by profile plus the
753/// solved/not-solved and local/cross-profile split its file names encode.
754///
755/// Targets outside `source` are skipped: a family's shapes describe its own
756/// elements.
757pub fn validate_shapes(ds: &CimDataset, source: Source, active: &[&ShapeDef]) -> Vec<Violation> {
758    validate_shapes_in(ds, &[], source, active)
759}
760
761/// [`validate_shapes`] on `ds`, one file of the files `set` validated
762/// together. Only NC's class checks (`sh:class`, `sh:in` on a type, `sh:not`)
763/// read the set, for the type of a referenced object `ds` does not hold;
764/// every other check sees `ds` alone, as a per-file rule must.
765pub fn validate_shapes_in(ds: &CimDataset, set: &[CimDataset], source: Source, active: &[&ShapeDef]) -> Vec<Violation> {
766    if active.is_empty() {
767        return Vec::new();
768    }
769
770    // Build the reverse index only if something asks an inverse question, and
771    // only over the fields that are actually asked about.
772    let mut count_fields: Vec<&'static str> = Vec::new();
773    let mut list_fields: Vec<&'static str> = Vec::new();
774    let paths = active.iter().flat_map(|s| {
775        s.props
776            .iter()
777            .map(|p| &p.path)
778            .chain(s.logic.iter().flat_map(|l| l.branches.iter().flat_map(|b| b.props.iter().map(|p| &p.path))))
779    });
780    for path in paths {
781        match path {
782            Path::Inverse(f) => count_fields.push(f),
783            Path::Alternative(branches) => count_fields.extend(branches.iter().filter_map(|b| match b {
784                AltBranch::Inverse(f) => Some(*f),
785                AltBranch::Forward(_) => None,
786            })),
787            Path::Chain(steps) => list_fields.extend(steps.iter().filter_map(|s| match s {
788                Step::Inverse(f) => Some(*f),
789                _ => None,
790            })),
791            Path::Forward(_) => {}
792        }
793    }
794    count_fields.sort_unstable();
795    count_fields.dedup();
796    list_fields.sort_unstable();
797    list_fields.dedup();
798
799    let mut subject_fields: Vec<&'static str> = active
800        .iter()
801        .flat_map(|s| s.targets.iter())
802        .filter_map(|t| match t {
803            Target::SubjectsOf(f) => Some(*f),
804            Target::Class(_) => None,
805        })
806        .collect();
807    subject_fields.sort_unstable();
808    subject_fields.dedup();
809
810    let (reverse, subjects) = build_indexes(ds, source, &count_fields, &list_fields, &subject_fields);
811    let ctx = Ctx { ds, set, source, reverse };
812
813    // Element-major: visit each target once and apply every shape that
814    // targets it, rather than walking a class's elements once per shape. The
815    // cost per visit is reaching the element's fields — a cache miss into its
816    // own map — so doing it once for all of a class's shapes, not once per
817    // shape, is what pays. Shapes are grouped by their one kind of target; a
818    // shape mixing kinds can select an element twice and keeps the per-shape
819    // walk, which removes the duplicate. Within one kind, the classes are
820    // de-duplicated per shape: `sh:targetClass cim:Switch, cim:Breaker`
821    // expands `Switch` to its subclasses, Breaker among them, and a breaker
822    // must still be checked once.
823    let mut by_class_shapes: FastMap<&'static str, Vec<&ShapeDef>> = FastMap::default();
824    let mut by_subject_shapes: FastMap<&'static str, Vec<&ShapeDef>> = FastMap::default();
825    let mut mixed: Vec<&ShapeDef> = Vec::new();
826    for shape in active {
827        let all_class = shape.targets.iter().all(|t| matches!(t, Target::Class(_)));
828        match shape.targets {
829            _ if all_class => {
830                let mut classes: Vec<&'static str> = shape
831                    .targets
832                    .iter()
833                    .flat_map(|t| match t {
834                        Target::Class(cs) => cs.iter().copied(),
835                        Target::SubjectsOf(_) => [].iter().copied(),
836                    })
837                    .collect();
838                classes.sort_unstable();
839                classes.dedup();
840                for class in classes {
841                    by_class_shapes.entry(class).or_default().push(shape);
842                }
843            }
844            [Target::SubjectsOf(field)] => by_subject_shapes.entry(field).or_default().push(shape),
845            _ => mixed.push(shape),
846        }
847    }
848
849    // The class-targeted walk is most of the work, and it is memory-bound:
850    // each element costs a cache miss into its own field map, which threads
851    // overlap well. Each class's elements are cut into slices, and the slices,
852    // in order, are dealt out to threads in contiguous runs of about equal
853    // size. Concatenating the runs in order gives exactly the sequential
854    // result, order included.
855    let work: Vec<(&[&ShapeDef], &[String])> = by_class_shapes
856        .iter()
857        .flat_map(|(class, shapes)| {
858            ds.by_type
859                .get(*class)
860                .map_or(&[][..], Vec::as_slice)
861                .chunks(SLICE)
862                .map(move |mrids| (shapes.as_slice(), mrids))
863        })
864        .collect();
865    let total: usize = work.iter().map(|(_, m)| m.len()).sum();
866    let mut out = par_concat(&runs(&work, threads_for(total), |(_, m)| m.len()), |items| {
867        let mut out = Vec::new();
868        for (shapes, mrids) in items {
869            for t in mrids.iter().filter_map(|mrid| resolve_target(ds, source, mrid)) {
870                for shape in *shapes {
871                    run_shape(&ctx, shape, &t, &mut out);
872                }
873            }
874        }
875        out
876    });
877
878    // The same for `sh:targetSubjectsOf`, whose targets the index pass found.
879    let work: Vec<(&[&ShapeDef], &[Resolved])> = by_subject_shapes
880        .iter()
881        .flat_map(|(field, shapes)| {
882            subjects
883                .get(field)
884                .map_or(&[][..], Vec::as_slice)
885                .chunks(SLICE)
886                .map(move |targets| (shapes.as_slice(), targets))
887        })
888        .collect();
889    let total: usize = work.iter().map(|(_, t)| t.len()).sum();
890    out.extend(par_concat(&runs(&work, threads_for(total), |(_, t)| t.len()), |items| {
891        let mut out = Vec::new();
892        for (shapes, targets) in items {
893            for t in *targets {
894                for shape in *shapes {
895                    run_shape(&ctx, shape, t, &mut out);
896                }
897            }
898        }
899        out
900    }));
901    let mut by_class: FastMap<&'static str, Vec<Resolved>> = FastMap::default();
902    for shape in mixed {
903        for t in targets_of(ds, source, shape, &subjects, &mut by_class) {
904            run_shape(&ctx, shape, &t, &mut out);
905        }
906    }
907
908    // One result per element and rule. A property shape that several node
909    // shapes share — `eq:Switch.retained-cardinality` under both `eq:Switch`
910    // and `eq:Breaker` — reaches a breaker through each once `Switch` is
911    // expanded to its subclasses, and would report the same finding twice.
912    let mut seen = FastSet::default();
913    let keep: Vec<bool> = out
914        .iter()
915        .map(|v| seen.insert((v.object_id.as_str(), v.rule_id.as_str(), v.property.as_str(), v.message.as_str())))
916        .collect();
917    drop(seen);
918    let mut keep = keep.into_iter();
919    out.retain(|_| keep.next().unwrap_or(true));
920    out
921}