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}