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