Skip to main content

openehr_sdt/rm_instance/
mod.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! **RM-instance validation** — the template-independent passes over a
5//! canonical-JSON RM tree, plus the report shape every validation surface of
6//! this crate speaks.
7//!
8//! These checks are properties of the instance value alone, so they hold for
9//! every COMPOSITION, EHR_STATUS, FOLDER or demographic PARTY tree whether or
10//! not an operational template is referenced. Two collecting passes recurse the
11//! whole instance, keying violations by RM instance path (`/content[0]/…`):
12//!
13//! 1. the RM-invariant pass runs each node's core class invariants plus the
14//!    JSON-level per-node checks of [`openehr_rm::v1_2::validate`]. Being
15//!    template-independent, it still checks nodes a template would fold away
16//!    (ELEMENT / `ITEM_TREE` / HISTORY / EVENT);
17//! 2. the [`terminology`] pass validates the RM-mandated openEHR
18//!    terminology-group and code-set codes against
19//!    [`openehr_rm::v1_2::validate::terminology`], backed by
20//!    [`openehr_term::bundle`].
21//!
22//! The decisions live in `openehr-rm`; this module owns the WALK — recursing,
23//! carrying the effective declared type down, and prefixing the absolute RM
24//! path onto each node-relative violation. The third, template-driven pass
25//! lives in [`crate::flat::validation`], and [`validate_composition`] composes
26//! all three.
27
28#![expect(
29    clippy::disallowed_types,
30    reason = "canonical JSON / Simplified Formats operate on the wire value by definition \
31              (ITS-JSON; ITS-REST Simplified Formats) — serde_json::Value IS the subject matter \
32              (#1694)"
33)]
34
35pub mod terminology;
36
37use serde_json::Value;
38use std::fmt::Write as _;
39
40use openehr_rm::v1_2::model::declared_concrete_type;
41use openehr_rm::v1_2::validate::{
42    check_archetyped_valid, check_cluster_items_present, check_data_structure_shapes,
43    check_mandatory_containers,
44};
45
46use crate::flat::webtemplate::model::WebTemplate;
47use openehr_its::wire_validate::validate_rm_invariants_as;
48
49/// A single validation violation, keyed by the RM path of the offending node
50/// (a message + path + violation kind).
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ValidationMessage {
53    /// The RM path to the offending node (RM instance path for RM-invariant /
54    /// terminology violations, archetype `aqlPath` for archetype-conformance
55    /// violations).
56    pub path: String,
57    /// A human-readable description of the violation.
58    pub message: String,
59    /// The violation category.
60    pub kind: ValidationKind,
61}
62
63/// The category of a [`ValidationMessage`].
64///
65/// The vocabulary spans both validation layers: [`ValidationKind::Invariant`]
66/// and [`ValidationKind::Terminology`] are what the RM-instance passes in this
67/// module report; the remaining variants are the archetype-conformance
68/// judgements of the template pass in [`crate::flat::validation`], which shares
69/// this report shape.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum ValidationKind {
72    /// An instance node's RM type does not conform to the constraint's type.
73    WrongType,
74    /// A mandatory node (`min >= 1`) is absent.
75    Required,
76    /// The number of matching nodes is outside the occurrences range.
77    Occurrences,
78    /// A container attribute's child count is outside its cardinality range.
79    Cardinality,
80    /// A numeric value is outside the constrained range.
81    RangeError,
82    /// A string value does not match the constrained pattern.
83    PatternError,
84    /// A coded value is not among the constrained coded options.
85    CodedValue,
86    /// A code is not valid in its RM-mandated openEHR terminology group.
87    Terminology,
88    /// An RM class invariant failed.
89    Invariant,
90    /// An instance node is not admitted by any sibling constraint or open slot
91    /// under a closed (constrained) attribute (closed-world admission per the
92    /// AOM2 direction — `AM/docs/AOM2/master04.2` `Rm_type_name` matching).
93    Unexpected,
94    /// Any other violation.
95    Other,
96}
97
98/// Push one violation onto a report.
99pub(crate) fn push(
100    out: &mut Vec<ValidationMessage>,
101    path: impl Into<String>,
102    message: impl Into<String>,
103    kind: ValidationKind,
104) {
105    out.push(ValidationMessage {
106        path: path.into(),
107        message: message.into(),
108        kind,
109    });
110}
111
112/// Normalize an RM instance path (empty → the root `/`).
113pub(crate) fn norm_path(p: &str) -> String {
114    if p.is_empty() {
115        "/".to_owned()
116    } else {
117        p.to_owned()
118    }
119}
120
121/// Validate a canonical-JSON COMPOSITION against its `WebTemplate`, RM
122/// invariants, and the RM-mandated openEHR terminology.
123///
124/// Returns every violation found (the validator does not stop at the first
125/// error); an empty result means the composition is valid to the extent this
126/// validator checks.
127///
128/// The composed entry point: the two instance passes of this module followed by
129/// the template-driven archetype-conformance pass
130/// ([`crate::flat::validation::validate_archetype_conformance`]).
131#[must_use]
132pub fn validate_composition(composition: &Value, wt: &WebTemplate) -> Vec<ValidationMessage> {
133    // Passes 1 + 2: the template-independent instance checks. The root's
134    // declared type is COMPOSITION — an untagged root is legal canonical JSON
135    // (the ITS-JSON schema requires `_type` only on polymorphic slots, and the
136    // resource root is concretely COMPOSITION).
137    let mut out = validate_rm_and_terminology(composition, "COMPOSITION");
138    // Pass 3: archetype conformance guided by the WebTemplate tree.
139    out.extend(crate::flat::validation::validate_archetype_conformance(
140        composition,
141        wt,
142    ));
143    out
144}
145
146/// Validate a canonical-JSON party against its `WebTemplate`, RM invariants,
147/// and the RM-mandated openEHR terminology.
148///
149/// The party counterpart of [`validate_composition`]: the template root must
150/// be a concrete party class (`PERSON`, `ORGANISATION`, `GROUP`, `AGENT` or
151/// `ROLE`, RM demographic `master02-demographic_package.adoc` §Class
152/// Descriptions), the instance passes of this module run with that class as
153/// the root's declared type, and the archetype-conformance pass walks the
154/// template from the root as it does for a COMPOSITION, a party being "a
155/// completely archetypable structure" (same document, §Archetyping).
156///
157/// A template whose root is not a party class answers one
158/// [`ValidationKind::WrongType`] message at `/` and runs no pass, since the
159/// template cannot describe a party.
160#[must_use]
161pub fn validate_party(party: &Value, wt: &WebTemplate) -> Vec<ValidationMessage> {
162    let root = wt
163        .tree
164        .rm_type
165        .split('<')
166        .next()
167        .unwrap_or(&wt.tree.rm_type);
168    if !crate::flat::build::PARTY_CLASSES.contains(&root) {
169        let mut out = Vec::new();
170        push(
171            &mut out,
172            "/",
173            format!(
174                "template {} is rooted at {root}, which is not a party class",
175                wt.template_id
176            ),
177            ValidationKind::WrongType,
178        );
179        return out;
180    }
181    let mut out = validate_rm_and_terminology(party, root);
182    out.extend(crate::flat::validation::validate_archetype_conformance(
183        party, wt,
184    ));
185    out
186}
187
188/// Validate only the **template-independent** passes: RM class invariants +
189/// the RM-mandated openEHR terminology.
190///
191/// These hold for every RM instance whether or not an operational template is
192/// referenced, so a COMPOSITION committed without a `template_id` still passes
193/// them.
194///
195/// One entry point for every commit kind: `COMPOSITION`, `EHR_STATUS`,
196/// `EHR_ACCESS`, `FOLDER`, the demographic PARTY types, …
197///
198/// The invariants these passes run — `ARCHETYPED.Rm_version_valid`,
199/// `LOCATABLE.Links_valid` / `Archetype_node_id_valid`, the `LINK` 1..1
200/// attributes, `FEEDER_AUDIT_DETAILS.System_id_valid`
201/// (`RM/docs/UML/classes/org.openehr.rm.common.*.adoc` §Invariants) — constrain
202/// every node carrying the shape, so one walk serves every resource kind.
203///
204/// `declared` is the ROOT's effective RM type, for a root whose wire `_type` is
205/// legitimately absent (canonical JSON requires `_type` only on polymorphic
206/// slots). Every descendant dispatches from its own `_type` or its parent
207/// attribute's concretely-declared type, and a tagged root overrides
208/// `declared`.
209#[must_use]
210pub fn validate_rm_and_terminology(root: &Value, declared: &str) -> Vec<ValidationMessage> {
211    validate_with(root, declared, LowerBounds::Enforced)
212}
213
214/// [`validate_rm_and_terminology`] for a `553|incomplete|` commit: the
215/// mandatory-presence and cardinality-lower-bound layers are relaxed to zero,
216/// every other layer runs at full strength.
217///
218/// RM common `master06-change_control_package.adoc` §Incomplete Content
219/// (NOTE): "mandatory attributes may be absent. Concretely, single-valued
220/// attributes may have null values and container attributes may be empty, even
221/// though they may have minimum existence and cardinality respectively of one.
222/// All other validity requirements must be satisfied. In other words, in an
223/// `incomplete` commit, data may be missing, but it may not be wrong." — and
224/// §Incomplete Content again, on the implementation form: incomplete data
225/// "respects the same template and archetype(s), but with all existence and
226/// cardinality lower limits set to zero".
227///
228/// Exactly three things change against the strict entry point:
229/// [`openehr_rm::v1_2::validate::check_mandatory_containers`] and
230/// [`openehr_rm::v1_2::validate::check_cluster_items_present`] do not run, and
231/// the class-invariant tier goes through
232/// [`openehr_its::wire_validate::validate_rm_invariants_relaxed_as`], which does not
233/// drive typed construction of a node missing mandatory data. Everything else —
234/// terminology, `LOCATABLE.Archetyped_valid`, the HISTORY shape duty, the
235/// undeclared-member refusal, every value-level invariant — is untouched.
236#[must_use]
237pub fn validate_rm_and_terminology_incomplete_as(
238    root: &Value,
239    declared: &str,
240) -> Vec<ValidationMessage> {
241    validate_with(root, declared, LowerBounds::Relaxed)
242}
243
244/// Whether the mandatory-presence / cardinality-lower-bound layers of the
245/// RM-instance passes are enforced or relaxed to zero (RM common master06
246/// §Incomplete Content).
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub(crate) enum LowerBounds {
249    /// The `532|complete|` reading: every lower bound is enforced.
250    Enforced,
251    /// The `553|incomplete|` reading: "all existence and cardinality lower
252    /// limits set to zero".
253    Relaxed,
254}
255
256/// The shared body of the two public entry points.
257fn validate_with(root: &Value, declared: &str, bounds: LowerBounds) -> Vec<ValidationMessage> {
258    let mut out = Vec::new();
259    // The ROOT arm of the declared-type conformance rule. Never relaxed — RM
260    // common master06 §Incomplete Content: "data may be missing, but it may
261    // not be wrong".
262    if let Some(wire) = root.get("_type").and_then(Value::as_str)
263        && openehr_rm::v1_2::model::class(declared).is_some()
264        && !openehr_rm::v1_2::model::is_a(wire, declared)
265    {
266        push(
267            &mut out,
268            "/".to_owned(),
269            format!(
270                "does not conform to RM type {declared}: the root claims `_type` \
271                 {wire}, which is not a {declared}"
272            ),
273            ValidationKind::Invariant,
274        );
275    }
276    // One reusable path buffer across both passes (each leaves it empty).
277    let mut path = String::new();
278    rm_invariant_pass(&mut out, root, &mut path, Some(declared), bounds);
279    terminology::terminology_pass(&mut out, root, &mut path, Some(declared));
280    out
281}
282
283/// Pass 1: recurse the whole instance, running each node's core RM class
284/// invariants plus the JSON-level per-node checks of [`openehr_rm::v1_2::validate`],
285/// keyed by the running RM instance path.
286///
287/// `path` is one reusable buffer pushed and truncated per recursion step, so
288/// the full path string is materialized only when a violation is recorded
289/// rather than allocated at every one of the ~1.5k nodes a commit visits.
290///
291/// `declared` is the parent attribute's declared RM type when concrete — the
292/// effective type of a node whose wire `_type` is legitimately absent — so
293/// untagged nodes like `COMPOSITION.context` still run their class invariants
294/// ([`openehr_rm::v1_2::model::declared_concrete_type`]).
295pub(crate) fn rm_invariant_pass(
296    out: &mut Vec<ValidationMessage>,
297    v: &Value,
298    path: &mut String,
299    declared: Option<&str>,
300    bounds: LowerBounds,
301) {
302    let Some(obj) = v.as_object() else { return };
303    let fields = NodeFields::of(obj);
304    let (node_id, has_archetype_details, details_archetype_id) = (
305        fields.node_id,
306        fields.has_archetype_details,
307        fields.details_archetype_id,
308    );
309    // The node's effective RM type: the wire tag, else the parent's
310    // concretely-declared attribute type (untagged nodes are legal there).
311    let ty = fields.ty.or(declared);
312    let mut inv = Vec::new();
313    if let Some(effective) = ty {
314        // The core (fast/typed) RM invariants only — the terminology-backed
315        // invariants are enforced by the dedicated terminology pass (its own
316        // `ValidationKind::Terminology` rendering), so calling the core-only
317        // entry here avoids double-reporting them.
318        match bounds {
319            LowerBounds::Enforced => validate_rm_invariants_as(effective, v, &mut inv),
320            LowerBounds::Relaxed => {
321                openehr_its::wire_validate::validate_rm_invariants_relaxed_as(
322                    effective, v, &mut inv,
323                );
324            }
325        }
326        if bounds == LowerBounds::Enforced {
327            // The orthogonal model-driven layer: mandatory-container lower
328            // bounds (kept outside the core pair so the fast-vs-typed
329            // equivalence property stays exact). Relaxed to zero on a
330            // `553|incomplete|` commit (master06 §Incomplete Content).
331            check_mandatory_containers(effective, v, &mut inv);
332        }
333    }
334    // The JSON-level per-node checks (`openehr-rm`'s own value semantics):
335    // they read the raw node, so an absent list and a present-but-empty one
336    // are still distinguishable here. This walk only adapts their
337    // node-relative violations onto the absolute RM instance path.
338    inv.extend(check_archetyped_valid(
339        node_id,
340        has_archetype_details,
341        details_archetype_id,
342    ));
343    if bounds == LowerBounds::Enforced {
344        // `CLUSTER.items` presence is a mandatory-presence duty, so it relaxes
345        // with the other lower bounds on a `553|incomplete|` commit; every
346        // other data-structure shape duty (the HISTORY generic-parameter rule)
347        // stays enforced below.
348        inv.extend(check_cluster_items_present(obj, ty));
349    }
350    inv.extend(check_data_structure_shapes(obj, ty));
351    for iv in inv {
352        let p = if iv.path.is_empty() {
353            norm_path(path)
354        } else {
355            format!("{}/{}", path.trim_end_matches('/'), iv.path)
356        };
357        push(out, p, iv.message, ValidationKind::Invariant);
358    }
359    for (k, val) in obj {
360        if !k.starts_with('_') {
361            descend_attribute(out, k, val, path, ty, bounds);
362        }
363    }
364}
365
366/// The node fields every per-node check reads, collected in ONE projection
367/// pass so none of them pays a hashed map lookup.
368///
369/// This runs for every node of every commit; the only remaining per-node gets
370/// are gated behind a matching `_type`.
371struct NodeFields<'a> {
372    ty: Option<&'a str>,
373    node_id: Option<&'a str>,
374    has_archetype_details: bool,
375    details_archetype_id: Option<&'a str>,
376}
377
378impl<'a> NodeFields<'a> {
379    fn of(obj: &'a serde_json::Map<String, Value>) -> Self {
380        let mut fields = NodeFields {
381            ty: None,
382            node_id: None,
383            has_archetype_details: false,
384            details_archetype_id: None,
385        };
386        for (k, val) in obj {
387            match k.as_str() {
388                "_type" => fields.ty = val.as_str(),
389                "archetype_node_id" => fields.node_id = val.as_str(),
390                "archetype_details" => {
391                    fields.has_archetype_details = !val.is_null();
392                    fields.details_archetype_id = val
393                        .get("archetype_id")
394                        .and_then(|a| a.get("value"))
395                        .and_then(Value::as_str);
396                }
397                _ => {}
398            }
399        }
400        fields
401    }
402}
403
404/// Descends into one attribute of a node, checking each member's declared slot
405/// type on the way down.
406fn descend_attribute(
407    out: &mut Vec<ValidationMessage>,
408    k: &str,
409    val: &Value,
410    path: &mut String,
411    ty: Option<&str>,
412    bounds: LowerBounds,
413) {
414    let child_declared = ty.and_then(|t| declared_concrete_type(t, k));
415    match val {
416        Value::Array(a) => {
417            for (i, item) in a.iter().enumerate() {
418                let base = path.len();
419                let _ = write!(path, "/{k}[{i}]");
420                if item.is_object() {
421                    check_slot_type(out, ty, k, item, path);
422                    rm_invariant_pass(out, item, path, child_declared, bounds);
423                } else if let Some(iv) = ty.and_then(|parent| {
424                    openehr_rm::v1_2::validate::check_slot_member_is_object(parent, k)
425                }) {
426                    // A scalar member of a class-typed list slot is the same
427                    // positive contradiction a foreign `_type` is — its
428                    // declared type is an RM class, which no JSON scalar can
429                    // be.
430                    push(out, norm_path(path), iv.message, ValidationKind::Invariant);
431                }
432                path.truncate(base);
433            }
434        }
435        Value::Object(_) => {
436            let base = path.len();
437            let _ = write!(path, "/{k}");
438            check_slot_type(out, ty, k, val, path);
439            rm_invariant_pass(out, val, path, child_declared, bounds);
440            path.truncate(base);
441        }
442        _ => {}
443    }
444}
445
446/// The declared-slot-type conformance rule (the WRONGNESS half of slot typing,
447/// never relaxed — RM common master06 §Incomplete Content): a TAGGED child
448/// must claim the slot's declared type or a subtype.
449///
450/// One model-driven rule for single slots and list members alike; the
451/// shallow-pruned typed dispatch cannot see it (each pruned member
452/// re-dispatches on its own `_type`), so the walk owns it.
453fn check_slot_type(
454    out: &mut Vec<ValidationMessage>,
455    parent_ty: Option<&str>,
456    attribute: &str,
457    item: &Value,
458    path: &str,
459) {
460    if let (Some(parent), Some(wire)) = (parent_ty, item.get("_type").and_then(Value::as_str))
461        && let Some(iv) =
462            openehr_rm::v1_2::validate::check_declared_slot_type(parent, attribute, wire)
463    {
464        push(out, norm_path(path), iv.message, ValidationKind::Invariant);
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    //! Per-rule unit tests for the RM-instance passes, built on minimal
471    //! hand-shaped instances so each rule is exercised in isolation. End-to-end
472    //! corpus + public-seam tests live in `tests/it/`.
473
474    use serde_json::{Value, json};
475
476    use super::*;
477
478    // ── Declared-slot-type conformance (the WRONGNESS half of slot typing) ───
479
480    /// RM ehr `composition.adoc` §Attributes types `content`
481    /// `List<CONTENT_ITEM>`, so a `DV_TEXT` member is a positive type
482    /// contradiction and is refused.
483    #[test]
484    fn foreign_type_in_a_list_slot_rejected() {
485        let inst = json!({
486            "_type": "COMPOSITION",
487            "content": [{ "_type": "DV_TEXT", "value": "not a content item" }]
488        });
489        let out = rm_only(&inst);
490        assert!(
491            out.iter().any(|m| m.path == "/content[0]"
492                && m.message.contains("`content` is declared CONTENT_ITEM")),
493            "the foreign list member must be refused at its own path: {out:?}"
494        );
495    }
496
497    /// The same contradiction on a SINGLE-VALUED slot: `COMPOSITION.context` is
498    /// declared `EVENT_CONTEXT`, so a tagged foreign object there is refused.
499    #[test]
500    fn foreign_type_in_a_single_slot_rejected() {
501        let inst = json!({
502            "_type": "COMPOSITION",
503            "context": { "_type": "DV_TEXT", "value": "not a context" }
504        });
505        let out = rm_only(&inst);
506        assert!(
507            out.iter().any(|m| m.path == "/context"
508                && m.message.contains("`context` is declared EVENT_CONTEXT")),
509            "the foreign single member must be refused at its own path: {out:?}"
510        );
511    }
512
513    /// A legitimate subtype in an abstract-declared slot stays accepted —
514    /// strict means exact, in both directions (spec-adherence.md).
515    #[test]
516    fn subtype_in_an_abstract_slot_accepted() {
517        let inst = json!({
518            "_type": "COMPOSITION",
519            "content": [{
520                "_type": "OBSERVATION",
521                "name": { "_type": "DV_TEXT", "value": "o" },
522                "archetype_node_id": "at0001"
523            }]
524        });
525        let out = rm_only(&inst);
526        assert!(
527            !out.iter()
528                .any(|m| m.message.contains("is declared CONTENT_ITEM")),
529            "an OBSERVATION is a CONTENT_ITEM — no slot violation: {out:?}"
530        );
531    }
532
533    /// Type WRONGNESS never relaxes on a `553|incomplete|` commit — "data may
534    /// be missing, but it may not be wrong" (RM common master06 §Incomplete
535    /// Content).
536    #[test]
537    fn slot_type_conformance_survives_the_incomplete_relaxation() {
538        let inst = json!({
539            "_type": "COMPOSITION",
540            "content": [{ "_type": "DV_TEXT", "value": "still wrong" }]
541        });
542        let mut out = Vec::new();
543        rm_invariant_pass(
544            &mut out,
545            &inst,
546            &mut String::new(),
547            Some("COMPOSITION"),
548            LowerBounds::Relaxed,
549        );
550        assert!(
551            out.iter()
552                .any(|m| m.message.contains("`content` is declared CONTENT_ITEM")),
553            "the incomplete relaxation must not admit a wrong type: {out:?}"
554        );
555    }
556
557    // ── LOCATABLE root identity + Links_valid (RM invariant pass) ─────────────
558
559    /// Run only pass 1 (the RM class invariants over the whole instance).
560    fn rm_only(instance: &Value) -> Vec<ValidationMessage> {
561        let mut out = Vec::new();
562        rm_invariant_pass(
563            &mut out,
564            instance,
565            &mut String::new(),
566            Some("COMPOSITION"),
567            LowerBounds::Enforced,
568        );
569        out
570    }
571
572    /// A minimal archetype-root node carrying an ARCHETYPED block, used to
573    /// isolate the `LOCATABLE` root/link rules from every other invariant.
574    fn root_node(node_id: &str, archetype_id: &str) -> Value {
575        json!({
576            "_type": "SECTION",
577            "name": { "_type": "DV_TEXT", "value": "s" },
578            "archetype_node_id": node_id,
579            "archetype_details": {
580                "_type": "ARCHETYPED",
581                "archetype_id": { "_type": "ARCHETYPE_ID", "value": archetype_id },
582                "rm_version": "1.2.0"
583            },
584            "items": [{
585                "_type": "OBSERVATION", "archetype_node_id": "at0001",
586                "name": { "_type": "DV_TEXT", "value": "o" }
587            }]
588        })
589    }
590
591    /// `RM/docs/UML/classes/org.openehr.rm.common.locatable.adoc` §Attributes
592    /// (`archetype_node_id`): "At an archetype root point, the value of this
593    /// attribute is always the stringified form of the `archetype_id` found in
594    /// the `archetype_details` object" — a root whose two archetype identities
595    /// disagree is refused.
596    #[test]
597    fn archetype_root_node_id_mismatch_rejected() {
598        let inst = root_node(
599            "openEHR-EHR-SECTION.other.v1",
600            "openEHR-EHR-SECTION.adhoc.v1",
601        );
602        let msgs = rm_only(&inst);
603        assert!(
604            msgs.iter().any(|m| m.kind == ValidationKind::Invariant
605                && m.message.contains("LOCATABLE.archetype_node_id")),
606            "expected a root node-id identity violation, got {msgs:?}"
607        );
608    }
609
610    /// The accepting twin: the same root whose `archetype_node_id` IS the
611    /// stringified `archetype_details.archetype_id` raises no identity finding.
612    #[test]
613    fn archetype_root_node_id_match_accepted() {
614        let inst = root_node(
615            "openEHR-EHR-SECTION.adhoc.v1",
616            "openEHR-EHR-SECTION.adhoc.v1",
617        );
618        let msgs = rm_only(&inst);
619        assert!(
620            !msgs
621                .iter()
622                .any(|m| m.message.contains("LOCATABLE.archetype_node_id")),
623            "a matching root must not violate the node-id identity rule: {msgs:?}"
624        );
625    }
626
627    /// `locatable.adoc` §Invariants `Links_valid`: the shape is
628    /// `Option<NonEmptyVec<LINK>>`, so a present-but-empty `links` refuses at
629    /// the typed tier's decode, on any LOCATABLE node.
630    #[test]
631    fn links_present_but_empty_rejected() {
632        let mut inst = root_node(
633            "openEHR-EHR-SECTION.adhoc.v1",
634            "openEHR-EHR-SECTION.adhoc.v1",
635        );
636        let obj = inst.as_object_mut().expect("root object");
637        obj.remove("items"); // SECTION.items is 0..1; keep the fixture valid but for links
638        obj.insert("links".into(), json!([]));
639        let msgs = rm_only(&inst);
640        assert!(
641            msgs.iter()
642                .any(|m| m.message.contains("links") && m.message.contains("at least one member")),
643            "expected the links parse refusal, got {msgs:?}"
644        );
645    }
646
647    /// The accepting twin: a non-empty `links` list satisfies `Links_valid`.
648    #[test]
649    fn links_non_empty_accepted() {
650        let mut inst = root_node(
651            "openEHR-EHR-SECTION.adhoc.v1",
652            "openEHR-EHR-SECTION.adhoc.v1",
653        );
654        inst.as_object_mut().expect("root object").insert(
655            "links".into(),
656            json!([{
657                "_type": "LINK",
658                "meaning": { "_type": "DV_TEXT", "value": "follow up" },
659                "type": { "_type": "DV_TEXT", "value": "issue" },
660                "target": { "_type": "DV_EHR_URI", "value": "ehr://example/x" }
661            }]),
662        );
663        let msgs = rm_only(&inst);
664        assert!(
665            !msgs.iter().any(|m| m.message.contains("Links_valid")),
666            "a non-empty links list must not violate Links_valid: {msgs:?}"
667        );
668    }
669
670    // ── validation-walk cost measurement (not a gate) ─────────────────────────
671
672    /// Count the `_type`-bearing nodes reachable in `v` (the units both
673    /// template-independent passes visit).
674    fn count_type_nodes(v: &Value) -> usize {
675        match v {
676            Value::Object(obj) => {
677                let self_count = usize::from(obj.contains_key("_type"));
678                self_count
679                    + obj
680                        .iter()
681                        .filter(|(k, _)| !k.starts_with('_'))
682                        .map(|(_, val)| count_type_nodes(val))
683                        .sum::<usize>()
684            }
685            Value::Array(a) => a.iter().map(count_type_nodes).sum(),
686            _ => 0,
687        }
688    }
689
690    /// Time `iters` runs of `f`, returning microseconds per run.
691    fn time_pass(iters: u32, mut f: impl FnMut() -> usize) -> f64 {
692        let start = std::time::Instant::now();
693        let mut sink = 0usize;
694        for _ in 0..iters {
695            sink = sink.wrapping_add(f());
696        }
697        std::hint::black_box(sink);
698        start.elapsed().as_secs_f64() * 1e6 / f64::from(iters)
699    }
700
701    /// MEASUREMENT (not a correctness gate): quantify the pre-tx template-
702    /// independent validation walk over the populated IPS example (~1.5k `_type`
703    /// nodes). The RM-invariant and terminology passes each traverse the whole
704    /// instance independently; this splits and times them. Ignored by default
705    /// (timing, not correctness); run:
706    /// `cargo nextest run -p openehr-its --run-ignored all \
707    ///   -E 'test(measure_ips_validation_walk_cost)' --no-capture`.
708    #[test]
709    #[ignore = "measurement, not a correctness gate — run with --run-ignored all"]
710    fn measure_ips_validation_walk_cost() {
711        let path = concat!(
712            env!("CARGO_MANIFEST_DIR"),
713            "/../../corpus/templates/ckm/international-patient-summary.example.json"
714        );
715        let comp: Value =
716            serde_json::from_str(&std::fs::read_to_string(path).expect("read IPS example"))
717                .expect("parse IPS example");
718        let node_count = count_type_nodes(&comp);
719
720        // Warm up (allocator, branch predictors, the lazily-initialized bundle).
721        for _ in 0..5 {
722            std::hint::black_box(validate_rm_and_terminology(&comp, "COMPOSITION").len());
723        }
724
725        let iters = 50;
726        let t_rm = time_pass(iters, || {
727            let mut out = Vec::new();
728            rm_invariant_pass(
729                &mut out,
730                &comp,
731                &mut String::new(),
732                Some("COMPOSITION"),
733                LowerBounds::Enforced,
734            );
735            out.len()
736        });
737        let t_term = time_pass(iters, || {
738            let mut out = Vec::new();
739            terminology::terminology_pass(&mut out, &comp, &mut String::new(), Some("COMPOSITION"));
740            out.len()
741        });
742        let t_both = time_pass(iters, || {
743            validate_rm_and_terminology(&comp, "COMPOSITION").len()
744        });
745
746        eprintln!("IPS validation walk cost ({node_count} _type nodes, {iters} iters):");
747        eprintln!("  pass 1 rm_invariant_pass : {t_rm:>8.1} us/op");
748        eprintln!("  pass 2 terminology_pass  : {t_term:>8.1} us/op");
749        eprintln!("  combined (1+2)           : {t_both:>8.1} us/op");
750    }
751
752    /// MEASUREMENT (not a correctness gate): quantify the archetype-conformance
753    /// **walk** (pass 3) over the populated IPS example against its OPT-built
754    /// `WebTemplate`, plus the full [`validate_composition`]. Ignored by default
755    /// (timing, not correctness); run:
756    /// `cargo nextest run -p openehr-its --run-ignored all \
757    ///   -E 'test(measure_ips_validation_full_cost)' --no-capture`.
758    #[test]
759    #[ignore = "measurement, not a correctness gate — run with --run-ignored all"]
760    fn measure_ips_validation_full_cost() {
761        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/templates/ckm");
762        let opt_xml = std::fs::read_to_string(format!("{dir}/international-patient-summary.opt"))
763            .expect("read IPS OPT");
764        let opt = openehr_its::opt14::from_xml(&opt_xml).expect("parse IPS OPT");
765        let wt = crate::flat::webtemplate::builder::build_web_template(&opt)
766            .expect("build IPS WebTemplate");
767        let comp: Value = serde_json::from_str(
768            &std::fs::read_to_string(format!("{dir}/international-patient-summary.example.json"))
769                .expect("read IPS example"),
770        )
771        .expect("parse IPS example");
772        let node_count = count_type_nodes(&comp);
773
774        // Warm up (allocator, branch predictors, the lazily-initialized bundle).
775        for _ in 0..5 {
776            std::hint::black_box(validate_composition(&comp, &wt).len());
777        }
778
779        // Public entry points only, so this harness compiles unchanged.
780        let iters = 50;
781        let t_rmterm = time_pass(iters, || {
782            validate_rm_and_terminology(&comp, "COMPOSITION").len()
783        });
784        let t_walk = time_pass(iters, || {
785            crate::flat::validation::validate_archetype_conformance(&comp, &wt).len()
786        });
787        let t_all = time_pass(iters, || validate_composition(&comp, &wt).len());
788
789        eprintln!("IPS full validation cost ({node_count} _type nodes, {iters} iters):");
790        eprintln!("  passes 1+2 rm+terminology      : {t_rmterm:>8.1} us/op");
791        eprintln!("  pass 3 walk (archetype conf.)  : {t_walk:>8.1} us/op");
792        eprintln!("  full validate_composition      : {t_all:>8.1} us/op");
793    }
794}