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
406fn check_prop<'a>(
407    ctx: &Ctx<'a>,
408    el: &'a Fields,
409    class: &str,
410    mrid: &'a str,
411    prop: &PropShape,
412    out: &mut Vec<Violation>,
413) {
414    let values = resolve(ctx, el, mrid, &prop.path);
415    for c in prop.checks {
416        if fails(ctx, el, &values, &c.constraint) {
417            out.push(violation(mrid, class, &path_label(&prop.path), c));
418        }
419    }
420}
421
422/// Does a [`Logic`] branch conform? `None` when a path in it left the
423/// dataset, so its conformance is not known.
424fn branch_conforms<'a>(ctx: &Ctx<'a>, el: &'a Fields, mrid: &'a str, branch: &Branch) -> Option<bool> {
425    let mut holds = true;
426    for prop in branch.props {
427        let values = resolve(ctx, el, mrid, &prop.path);
428        if matches!(values, Values::Unknown) {
429            return None;
430        }
431        if prop.checks.iter().any(|c| fails(ctx, el, &values, &c.constraint)) {
432            holds = false;
433            break;
434        }
435    }
436    Some(holds != branch.negate)
437}
438
439/// Node-level `sh:and` / `sh:or` / `sh:xone`. Reported with the node shape's
440/// name as the property, which is what the generated validators report.
441fn check_logic<'a>(
442    ctx: &Ctx<'a>,
443    el: &'a Fields,
444    class: &str,
445    mrid: &'a str,
446    logic: &Logic,
447    out: &mut Vec<Violation>,
448) {
449    let mut conforming = 0usize;
450    for branch in logic.branches {
451        match branch_conforms(ctx, el, mrid, branch) {
452            Some(true) => conforming += 1,
453            Some(false) => {}
454            // An unknown branch could tip any of the three either way.
455            None => return,
456        }
457    }
458    let holds = match logic.op {
459        LogicOp::And => conforming == logic.branches.len(),
460        LogicOp::Or => conforming >= 1,
461        LogicOp::Xone => conforming == 1,
462    };
463    if !holds {
464        out.push(Violation {
465            object_id: mrid.to_string(),
466            rule_id: logic.rule_id.to_string(),
467            class: class.to_string(),
468            property: logic.name.to_string(),
469            message: logic.message.to_string(),
470            severity: logic.severity.to_string(),
471            name: logic.name.to_string(),
472            description: logic.description.to_string(),
473        });
474    }
475}
476
477/// Does any pairing of this property's numbers with `other`'s fail `holds`?
478fn pair(values: &Values, el: &Fields, other: &str, holds: impl Fn(f64, f64) -> bool) -> bool {
479    let others = field_values(el, other);
480    values.any_text(|a| {
481        number(a).is_some_and(|a| others.any_text(|b| number(b).is_some_and(|b| !holds(a, b))))
482    })
483}
484
485/// `sh:closed` — report every field the profile does not allow on this class.
486fn check_closed(el: &Fields, class: &str, mrid: &str, closed: &ClosedShape, out: &mut Vec<Violation>) {
487    let mut extra: Vec<&str> = el
488        .keys()
489        .copied()
490        .filter(|k| !closed.allowed.contains(k))
491        .collect();
492    // Deterministic order: HashMap iteration is not, and a violation list that
493    // reorders between runs is unusable in a diff.
494    extra.sort_unstable();
495
496    for field in extra {
497        out.push(Violation {
498            object_id: mrid.to_string(),
499            rule_id: closed.rule_id.to_string(),
500            class: class.to_string(),
501            property: field.to_string(),
502            message: closed.message.to_string(),
503            severity: closed.severity.to_string(),
504            name: closed.name.to_string(),
505            description: closed.description.to_string(),
506        });
507    }
508}
509
510/// A target element, resolved once per call: its mRID, its class, and the
511/// fields it is read through.
512#[derive(Clone, Copy)]
513struct Resolved<'a> {
514    mrid: &'a String,
515    class: &'static str,
516    fields: &'a Fields,
517}
518
519/// Resolve `mrid` for `source`: `None` when it is missing or another family's
520/// element — e.g. a CGMES element under an NC shape, whose attributes that
521/// family does not define and would report as absent.
522fn resolve_target<'a>(ds: &'a CimDataset, source: Source, mrid: &'a String) -> Option<Resolved<'a>> {
523    let entry = ds.entries.get(mrid)?;
524    let fields = source.fields(entry)?;
525    Some(Resolved { mrid, class: entry.type_name(), fields })
526}
527
528/// The reverse index and the `sh:targetSubjectsOf` index, built in one pass.
529///
530/// Both need a walk over every element, and that walk — random access into a
531/// map of every element, then into each one's fields — was most of their
532/// cost; one pass does it once. Only fields some active shape asks about are
533/// indexed, and nothing is scanned when no shape asks.
534///
535/// `sh:targetSubjectsOf` has no `by_type` to answer it, and scanning per shape
536/// is O(elements x shapes): on a 20k-element dataset that cost 10 ms for a
537/// profile whose shapes matched nothing.
538fn build_indexes<'a>(
539    ds: &'a CimDataset,
540    source: Source,
541    count_fields: &[&'static str],
542    list_fields: &[&'static str],
543    subject_fields: &[&'static str],
544) -> (ReverseIndex<'a>, FastMap<&'static str, Vec<Resolved<'a>>>) {
545    if count_fields.is_empty() && list_fields.is_empty() && subject_fields.is_empty() {
546        return (ReverseIndex::default(), FastMap::default());
547    }
548    type Partial<'a> = (ReverseIndex<'a>, FastMap<&'static str, Vec<Resolved<'a>>>);
549    let index = |entries: &mut dyn Iterator<Item = (&'a String, &'a Element)>| -> Partial<'a> {
550        let mut reverse = ReverseIndex::default();
551        let mut subjects: FastMap<&'static str, Vec<Resolved<'a>>> =
552            subject_fields.iter().map(|f| (*f, Vec::new())).collect();
553        for (mrid, entry) in entries {
554            let Some(f) = source.fields(entry) else { continue };
555            for field in count_fields {
556                for target in refs_of(f, field) {
557                    *reverse.counts.entry((target.as_str(), *field)).or_insert(0) += 1;
558                }
559            }
560            for field in list_fields {
561                for target in refs_of(f, field) {
562                    reverse.sources.entry((target.as_str(), *field)).or_default().push(mrid);
563                }
564            }
565            for field in subject_fields {
566                if f.contains_key(*field) {
567                    let r = Resolved { mrid, class: entry.type_name(), fields: f };
568                    subjects.get_mut(field).expect("seeded above").push(r);
569                }
570            }
571        }
572        (reverse, subjects)
573    };
574
575    let threads = threads_for(ds.entries.len());
576    if threads == 1 {
577        return index(&mut ds.entries.iter());
578    }
579    // One pass split into contiguous runs of the map's own order, each indexed
580    // on its own thread, then merged in run order: the lists come out exactly
581    // as the single pass builds them.
582    let all: Vec<(&'a String, &'a Element)> = ds.entries.iter().collect();
583    let parts: Vec<Partial<'a>> = par_map(&runs(&all, threads, |_| 1), |run| index(&mut run.iter().copied()));
584    let mut parts = parts.into_iter();
585    let (mut reverse, mut subjects) = parts.next().expect("at least one run");
586    for (r, subj) in parts {
587        for (k, n) in r.counts {
588            *reverse.counts.entry(k).or_insert(0) += n;
589        }
590        for (k, list) in r.sources {
591            reverse.sources.entry(k).or_default().extend(list);
592        }
593        for (field, list) in subj {
594            subjects.get_mut(field).expect("seeded above").extend(list);
595        }
596    }
597    (reverse, subjects)
598}
599
600/// Every element a shape's targets select, in `by_type` order — for a shape
601/// that mixes target kinds; the others are walked element-major in
602/// [`validate_shapes`].
603///
604/// That order is reproducible from run to run: decoding appends in document
605/// order, and `merge` walks a map whose hasher has a fixed seed. A class's
606/// elements are resolved once per call and shared with the element-major walk.
607fn targets_of<'a>(
608    ds: &'a CimDataset,
609    source: Source,
610    shape: &ShapeDef,
611    subjects: &FastMap<&'static str, Vec<Resolved<'a>>>,
612    by_class: &mut FastMap<&'static str, Vec<Resolved<'a>>>,
613) -> Vec<Resolved<'a>> {
614    let mut out: Vec<Resolved<'a>> = Vec::new();
615    for target in shape.targets {
616        match target {
617            Target::Class(classes) => {
618                for class in *classes {
619                    let list = by_class.entry(class).or_insert_with(|| {
620                        ds.by_type
621                            .get(*class)
622                            .into_iter()
623                            .flatten()
624                            .filter_map(|mrid| resolve_target(ds, source, mrid))
625                            .collect()
626                    });
627                    out.extend_from_slice(list);
628                }
629            }
630            Target::SubjectsOf(field) => {
631                out.extend_from_slice(subjects.get(field).map_or(&[], Vec::as_slice));
632            }
633        }
634    }
635    // A shape's targets overlap whenever it lists a class and one of its
636    // subclasses (`cim:Switch, cim:Breaker`), and a subjectsOf target can pick
637    // any of them again. SHACL validates a focus node once per shape.
638    let mut seen = FastSet::default();
639    out.retain(|t| seen.insert(t.mrid));
640    out
641}
642
643/// Every check of one shape against one target.
644fn run_shape<'a>(ctx: &Ctx<'a>, shape: &ShapeDef, t: &Resolved<'a>, out: &mut Vec<Violation>) {
645    for prop in shape.props {
646        check_prop(ctx, t.fields, t.class, t.mrid, prop, out);
647    }
648    for logic in shape.logic {
649        check_logic(ctx, t.fields, t.class, t.mrid, logic, out);
650    }
651    if let Some(closed) = shape.closed {
652        check_closed(t.fields, t.class, t.mrid, closed, out);
653    }
654}
655
656/// Validate one profile's shapes against a dataset.
657///
658/// `shapes` is the whole table; only shapes belonging to `profile` run, which is
659/// what makes a shared constraint file (imported by 17 of 18 manifests) cost
660/// nothing on the profiles that do not use it.
661pub fn validate_profile(
662    ds: &CimDataset,
663    profile: &str,
664    shapes: &'static [ShapeDef],
665    _cfg: &Config,
666) -> Vec<Violation> {
667    let active: Vec<&ShapeDef> = shapes
668        .iter()
669        .filter(|s| s.profiles.contains(&profile))
670        .collect();
671    validate_shapes(ds, Source::Nc, &active)
672}
673
674/// Elements per slice of a class's targets, the unit of work a thread takes.
675const SLICE: usize = 2048;
676
677/// Validate a dataset against shapes the caller has already selected.
678///
679/// The selection is the caller's because the families select differently: NC
680/// by the profile codes its manifests assign, CGMES by profile plus the
681/// solved/not-solved and local/cross-profile split its file names encode.
682///
683/// Targets outside `source` are skipped: a family's shapes describe its own
684/// elements.
685pub fn validate_shapes(ds: &CimDataset, source: Source, active: &[&ShapeDef]) -> Vec<Violation> {
686    if active.is_empty() {
687        return Vec::new();
688    }
689
690    // Build the reverse index only if something asks an inverse question, and
691    // only over the fields that are actually asked about.
692    let mut count_fields: Vec<&'static str> = Vec::new();
693    let mut list_fields: Vec<&'static str> = Vec::new();
694    let paths = active.iter().flat_map(|s| {
695        s.props
696            .iter()
697            .map(|p| &p.path)
698            .chain(s.logic.iter().flat_map(|l| l.branches.iter().flat_map(|b| b.props.iter().map(|p| &p.path))))
699    });
700    for path in paths {
701        match path {
702            Path::Inverse(f) => count_fields.push(f),
703            Path::Alternative(branches) => count_fields.extend(branches.iter().filter_map(|b| match b {
704                AltBranch::Inverse(f) => Some(*f),
705                AltBranch::Forward(_) => None,
706            })),
707            Path::Chain(steps) => list_fields.extend(steps.iter().filter_map(|s| match s {
708                Step::Inverse(f) => Some(*f),
709                _ => None,
710            })),
711            Path::Forward(_) => {}
712        }
713    }
714    count_fields.sort_unstable();
715    count_fields.dedup();
716    list_fields.sort_unstable();
717    list_fields.dedup();
718
719    let mut subject_fields: Vec<&'static str> = active
720        .iter()
721        .flat_map(|s| s.targets.iter())
722        .filter_map(|t| match t {
723            Target::SubjectsOf(f) => Some(*f),
724            Target::Class(_) => None,
725        })
726        .collect();
727    subject_fields.sort_unstable();
728    subject_fields.dedup();
729
730    let (reverse, subjects) = build_indexes(ds, source, &count_fields, &list_fields, &subject_fields);
731    let ctx = Ctx { ds, source, reverse };
732
733    // Element-major: visit each target once and apply every shape that
734    // targets it, rather than walking a class's elements once per shape. The
735    // cost per visit is reaching the element's fields — a cache miss into its
736    // own map — so doing it once for all of a class's shapes, not once per
737    // shape, is what pays. Shapes are grouped by their one kind of target; a
738    // shape mixing kinds can select an element twice and keeps the per-shape
739    // walk, which removes the duplicate. Within one kind, the classes are
740    // de-duplicated per shape: `sh:targetClass cim:Switch, cim:Breaker`
741    // expands `Switch` to its subclasses, Breaker among them, and a breaker
742    // must still be checked once.
743    let mut by_class_shapes: FastMap<&'static str, Vec<&ShapeDef>> = FastMap::default();
744    let mut by_subject_shapes: FastMap<&'static str, Vec<&ShapeDef>> = FastMap::default();
745    let mut mixed: Vec<&ShapeDef> = Vec::new();
746    for shape in active {
747        let all_class = shape.targets.iter().all(|t| matches!(t, Target::Class(_)));
748        match shape.targets {
749            _ if all_class => {
750                let mut classes: Vec<&'static str> = shape
751                    .targets
752                    .iter()
753                    .flat_map(|t| match t {
754                        Target::Class(cs) => cs.iter().copied(),
755                        Target::SubjectsOf(_) => [].iter().copied(),
756                    })
757                    .collect();
758                classes.sort_unstable();
759                classes.dedup();
760                for class in classes {
761                    by_class_shapes.entry(class).or_default().push(shape);
762                }
763            }
764            [Target::SubjectsOf(field)] => by_subject_shapes.entry(field).or_default().push(shape),
765            _ => mixed.push(shape),
766        }
767    }
768
769    // The class-targeted walk is most of the work, and it is memory-bound:
770    // each element costs a cache miss into its own field map, which threads
771    // overlap well. Each class's elements are cut into slices, and the slices,
772    // in order, are dealt out to threads in contiguous runs of about equal
773    // size. Concatenating the runs in order gives exactly the sequential
774    // result, order included.
775    let work: Vec<(&[&ShapeDef], &[String])> = by_class_shapes
776        .iter()
777        .flat_map(|(class, shapes)| {
778            ds.by_type
779                .get(*class)
780                .map_or(&[][..], Vec::as_slice)
781                .chunks(SLICE)
782                .map(move |mrids| (shapes.as_slice(), mrids))
783        })
784        .collect();
785    let total: usize = work.iter().map(|(_, m)| m.len()).sum();
786    let mut out = par_concat(&runs(&work, threads_for(total), |(_, m)| m.len()), |items| {
787        let mut out = Vec::new();
788        for (shapes, mrids) in items {
789            for t in mrids.iter().filter_map(|mrid| resolve_target(ds, source, mrid)) {
790                for shape in *shapes {
791                    run_shape(&ctx, shape, &t, &mut out);
792                }
793            }
794        }
795        out
796    });
797
798    // The same for `sh:targetSubjectsOf`, whose targets the index pass found.
799    let work: Vec<(&[&ShapeDef], &[Resolved])> = by_subject_shapes
800        .iter()
801        .flat_map(|(field, shapes)| {
802            subjects
803                .get(field)
804                .map_or(&[][..], Vec::as_slice)
805                .chunks(SLICE)
806                .map(move |targets| (shapes.as_slice(), targets))
807        })
808        .collect();
809    let total: usize = work.iter().map(|(_, t)| t.len()).sum();
810    out.extend(par_concat(&runs(&work, threads_for(total), |(_, t)| t.len()), |items| {
811        let mut out = Vec::new();
812        for (shapes, targets) in items {
813            for t in *targets {
814                for shape in *shapes {
815                    run_shape(&ctx, shape, t, &mut out);
816                }
817            }
818        }
819        out
820    }));
821    let mut by_class: FastMap<&'static str, Vec<Resolved>> = FastMap::default();
822    for shape in mixed {
823        for t in targets_of(ds, source, shape, &subjects, &mut by_class) {
824            run_shape(&ctx, shape, &t, &mut out);
825        }
826    }
827
828    // One result per element and rule. A property shape that several node
829    // shapes share — `eq:Switch.retained-cardinality` under both `eq:Switch`
830    // and `eq:Breaker` — reaches a breaker through each once `Switch` is
831    // expanded to its subclasses, and would report the same finding twice.
832    let mut seen = FastSet::default();
833    let keep: Vec<bool> = out
834        .iter()
835        .map(|v| seen.insert((v.object_id.as_str(), v.rule_id.as_str(), v.property.as_str(), v.message.as_str())))
836        .collect();
837    drop(seen);
838    let mut keep = keep.into_iter();
839    out.retain(|_| keep.next().unwrap_or(true));
840    out
841}