openehr-rm 0.0.4

openEHR RM 1.2.0 Reference Model, generated from BMM
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
//! RM-level validation glue (hand-written spec behaviour).
//!
//! The module is preserved
//! across `openehr-codegen` regeneration — the generator does not emit or overwrite it, so
//! the generator's `declare_hand_written_modules` keeps it and `lib.rs`
//! auto-declares `pub mod validate;`.
//!
//! Everything here judges a canonical-JSON RM node **as a value** — no codec,
//! no template, no wire context — and reports [`InvariantViolation`]s relative
//! to that node:
//!
//! 1. **The allocation-free fast-path RM class-invariant check**
//!    ([`try_fast_validate`] → the private `fast` path) over a live canonical-JSON node, plus
//!    the **shared invariant helpers** used by the sibling `*_impl.rs`
//!    behaviour files (the DV_AMOUNT / DV_QUANTIFIED accuracy + magnitude-status
//!    rules, the LOCATABLE `Archetype_node_id_valid` rule, ISO-8601 value
//!    checks).
//! 2. **The JSON-level per-node checks the typed model cannot express** —
//!    [`check_mandatory_containers`] (model-driven container lower bounds),
//!    [`nonempty_list_violations`] (the `x /= Void implies not x.is_empty`
//!    family, over the BMM-derived rule table), [`check_archetyped_valid`] and
//!    [`check_data_structure_shapes`]. Each is a pure function over the node's
//!    own value, run as its own layer beside the fast/typed core pair so the
//!    equivalence property between those two stays exactly the core property.
//! 3. **The terminology-backed invariants**, in the sibling [`terminology`]
//!    module (they need the openEHR terminology bundle, not just the node).
//! 4. **The `553|incomplete|` presence relaxation predicates**, in the sibling
//!    [`incomplete`] module: the pair of pure model-driven questions ("is
//!    anything missing?" / "is anything wrong?") that RM common
//!    `master06-change_control_package.adoc` §Incomplete Content splits a
//!    node's structural judgement into. Used only by the relaxed
//!    (`553|incomplete|`) commit path; the strict path never calls them.
//! 5. **The typed-dispatch tier**, in the sibling [`typed_dispatch`] module:
//!    the `_type` → concrete-RM-type table that *deserializes* a node through
//!    the emitted canonical-JSON `serde` impls (`crate::json_serde`) and runs
//!    that class's `Validate` impl — the authoritative oracle the fast path may
//!    only skip when its result is provably identical.
//!
//! Kept OUT of this crate, in `openehr-its`: the GENERATED five-crate
//! structural dispatch that [`typed_dispatch::dispatch_typed`] falls through to
//! (it spans `openehr-base`/`-rm`/`-am`/`-term`/`-lang` at once, so it can only
//! be emitted downstream of all of them), the thin wire-boundary entry points
//! that compose the tiers, and the walkers that recurse an instance and prefix
//! absolute RM paths. The wire-boundary entry point
//! `openehr_its::wire_validate::validate_rm_value` calls [`try_fast_validate`]
//! then falls back to [`typed_dispatch::dispatch_typed`].
//!
//! # The invariant source and the diagnostic form
//!
//! The RM class invariants realized here are the released class tables' own
//! invariant expressions, machine-classified from the vendored BMM — the
//! generated register at the head of the crate-private `generated` module
//! (`validate/generated.rs`) is the per-invariant
//! authority (venue + citation + adjudication). A failure renders the uniform
//! message `Invariant <Name> failed on type <RM_TYPE>` (see
//! `invariant_failed`): `<Name>` is the invariant's released class-table name
//! (`docs/specs/openehr/RM/docs/UML/classes/*.adoc` §Invariants), so a
//! violation is identifiable by the spec's own vocabulary. (The rendering
//! matches openEHR's `archie` reference implementation — prior art the tests
//! and stored diagnostics were pinned against, never the authority.)
//!
//! What deliberately does **not** run in the *core/typed* tiers:
//! - **Terminology-bound invariants** (the class-table rules that invoke
//!   `has_code_for_group_id` / `code_set (id).has_code` — `Language_valid`,
//!   `Encoding_valid`, `Category_validity`, `Change_type_valid`, …). They
//!   resolve a code against the openEHR terminology bundle rather than
//!   inspecting the node alone, so they live in the sibling
//!   [`terminology`] module (over `openehr-term`) and are run as a separate
//!   post-core layer — never inside the fast/typed pair, whose equivalence
//!   property is defined over the core invariants only.
//! - **Invariants adjudicated out of the per-node layer** — each carries a
//!   citation-pinned `Excluded` adjudication in the generated register
//!   (aggregate/cross-object rules owned by another layer, derived-function
//!   constraints, and rules over undeclared attributes).
//! - **Cross-child recursion**: each `Validate` impl checks only its own class
//!   invariants; the composition validator recurses into children (and prefixes
//!   the absolute RM path onto each [`InvariantViolation`]).

#![expect(
    clippy::disallowed_types,
    reason = "the wire-boundary validation reads the canonical JSON node before the typed decode \
              (#1694 boundary class)"
)]

use openehr_base::validate::InvariantViolation;
use serde_json::Value;

mod fast;
/// The generated RM class-invariant cores (`openehr-codegen -- emit-validate`):
/// one `pub(crate) fn <name>_core` per mechanically-shaped invariant group, the
/// single source both the typed `Validate` impls and [`fast`] call. This is the
/// ONE hand-declared module for that `// @generated` file — the runtime helpers
/// the cores call (`invariant_failed`, the ISO-8601 validators, the dialect
/// predicates) stay hand-written below.
pub(crate) mod generated;
// The `553|incomplete|` presence relaxation predicates (its own `//!` module
// docs carry the detail — an outer doc attribute here would force rustdoc to
// resolve the module's intra-doc links in THIS module's scope instead of its
// own).
pub mod incomplete;
// The terminology-backed RM class invariants (its own `//!` module docs carry
// the detail — an outer doc attribute here would force rustdoc to resolve the
// module's intra-doc links in THIS module's scope instead of its own).
pub mod terminology;
// The typed-dispatch tier (its own `//!` module docs carry the detail — an
// outer doc attribute here would force rustdoc to resolve the module's
// intra-doc links in THIS module's scope instead of its own).
pub mod typed_dispatch;

/// Run the allocation-free fast-path RM class-invariant check for a single
/// canonical-JSON node, dispatching on its `_type`.
///
/// Returns `true` when the fast path vouched for (fully handled) the node —
/// nothing is appended on `false`.
///
/// This is the public seam the wire-boundary two-tier dispatcher
/// (`openehr_its::wire_validate::validate_rm_value`) calls before falling back to
/// the typed deserialize path. Kept here because the fast path is untyped
/// (walks `&serde_json::Value` against the generated RM model) and needs no
/// canonical-JSON codec — pure RM model semantics.
///
/// NOTE: no openEHR spec governs the fast path — it is our own performance
/// design; the *semantics* it realizes are exactly the RM class invariants of
/// the `*_impl.rs` siblings (see the private `fast` module).
#[must_use]
pub fn try_fast_validate(ty: &str, value: &Value, out: &mut Vec<InvariantViolation>) -> bool {
    fast::try_validate(ty, value, out)
}

/// The inherited LOCATABLE `Archetype_node_id_valid` violation for a
/// canonical-JSON node whose RM type is `ty`, or `None` when the node does not
/// violate it.
///
/// `LOCATABLE.Archetype_node_id_valid` (`not archetype_node_id.is_empty`,
/// `docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.common.locatable.adoc`
/// §Invariants) is inherited by **every** concrete LOCATABLE descendant, so the
/// set of RM types it applies to is read from the generated static RM model
/// ([`crate::model::descendants`] of `LOCATABLE` — the transitive *concrete*
/// descendant closure of the BMM), never from a hand-maintained list: a class
/// the spec adds to the hierarchy is covered the moment the model is
/// regenerated. The violation itself is built by the generated core
/// (`generated::archetype_node_id_core`), so its text has one source.
///
/// Only a **present and empty** `archetype_node_id` violates the invariant. An
/// absent (or non-string) one is a structural defect of a mandatory attribute,
/// reported by the decode/mandatory-attribute layer instead — reporting it here
/// too would double-report the same defect.
///
/// The wire-boundary dispatcher (`openehr_its::wire_validate`) runs this for
/// every node after its class-invariant tier, appending only what that tier did
/// not already report (the concrete LOCATABLEs with a typed `Validate` impl
/// realize the inherited invariant themselves).
#[must_use]
pub fn locatable_node_id_violation(ty: &str, value: &Value) -> Option<InvariantViolation> {
    // Cheap first: the overwhelming majority of nodes carry a non-empty id, so
    // the model lookup is paid only on an actual violation candidate.
    if value.get("archetype_node_id").and_then(Value::as_str) != Some("") {
        return None;
    }
    if !crate::model::descendants("LOCATABLE").contains(&ty) {
        return None;
    }
    let mut out = Vec::with_capacity(1);
    generated::archetype_node_id_core(ty, "", &mut out);
    out.pop()
}

/// The model-driven mandatory-container lower-bound check.
///
/// Every attribute the
/// static RM model declares as a MANDATORY container must be present, and one
/// whose BMM cardinality has a lower bound ≥ 1 must be non-empty — e.g.
/// `CLUSTER.items: List<ITEM>` is `1..*`
/// (`docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.data_structures.cluster.adoc`
/// §Attributes), so a CLUSTER with an absent or empty `items` does not conform.
///
/// This is a distinct mechanism from the codec decode: a MANDATORY container
/// emits as a bare `Vec<T>` and the canonical-JSON reader treats an absent
/// array as an empty `Vec` (wire tolerance), so the omission is invisible to
/// typed decoding — the lower bound is enforced HERE, from the model, for every
/// class uniformly. The OPTIONAL-attribute family (`x /= Void implies not
/// x.is_empty`, e.g. `COMPOSITION.content`) is a different rule with a different
/// evaluator ([`nonempty_list_violations`]); this function only judges MANDATORY
/// containers, so the two never double-report.
/// `List<Octet>` is exempt by shape: canonical JSON renders it as an inline
/// base64 string, which presence-checks as a non-array member.
///
/// Kept OUTSIDE the fast/typed core pair (the caller runs it as its own layer),
/// so the fast-vs-typed equivalence property stays exactly the core property.
pub fn check_mandatory_containers(ty: &str, value: &Value, out: &mut Vec<InvariantViolation>) {
    for attr in crate::model::attributes(ty) {
        if !attr.is_mandatory
            || matches!(attr.container, crate::model::Container::None)
            || attr.declared_type == "Octet"
        {
            continue;
        }
        match value.get(attr.name) {
            None | Some(Value::Null) => {
                out.push(InvariantViolation::here(format!(
                    "does not conform to RM type {ty}: mandatory container `{}` is absent",
                    attr.name
                )));
            }
            Some(Value::Array(a))
                if a.is_empty() && attr.cardinality.is_some_and(|c| c.lower >= 1) =>
            {
                out.push(InvariantViolation::here(format!(
                    "does not conform to RM type {ty}: mandatory container `{}` is empty \
                     (cardinality lower bound 1)",
                    attr.name
                )));
            }
            _ => {}
        }
    }
}

/// The `x /= Void implies not x.is_empty` invariant family for one node.
///
/// Every rule of the generated `NONEMPTY_LIST_RULES` table (in the private
/// `generated` module) that applies to `ty` is evaluated against the node's own
/// attributes.
///
/// The rule table is READ FROM THE BMM (every class invariant with that exact
/// assertion shape over a container attribute), and a rule applies to its
/// declaring class **and its transitive concrete descendants** — resolved from
/// the generated static RM model ([`crate::model::descendants`]), never from a
/// hand-maintained list — so `LOCATABLE.Links_valid`,
/// `DV_ORDERED.Other_reference_ranges_validity` and
/// `ENTRY.Other_participations_valid` reach every descendant, and a class the
/// spec adds to the hierarchy is covered the moment the model is regenerated.
///
/// The optional-container emission shape (`Option<Vec<T>>`) is what makes the
/// family decidable at all: the forbidden state is the attribute PRESENT with
/// zero members, which the canonical-JSON reader now preserves
/// (`openehr_its::json_codec::runtime::optional_container_field`). This
/// evaluator reads it off the node, which is the same value the typed model
/// carries.
///
/// Kept OUTSIDE the fast/typed core pair (the caller runs it as its own layer,
/// exactly like [`check_mandatory_containers`]), so the fast-vs-typed
/// equivalence property stays exactly the core property and no rule can be
/// reported twice.
///
/// Every violation is reported on the node itself (an empty
/// [`InvariantViolation::path`]); the caller prefixes the absolute RM path.
pub fn nonempty_list_violations(ty: &str, value: &Value, out: &mut Vec<InvariantViolation>) {
    for (class, attribute, invariant) in generated::NONEMPTY_LIST_RULES {
        if *class != ty && !crate::model::descendants(class).contains(&ty) {
            continue;
        }
        let present_and_empty = value
            .get(*attribute)
            .and_then(Value::as_array)
            .is_some_and(Vec::is_empty);
        generated::nonempty_list_core(ty, attribute, invariant, present_and_empty, out);
    }
}

/// The declared-slot-type conformance rule, over the BMM-generated attribute
/// model.
///
/// A child node's wire `_type` must name the RM type the parent
/// attribute declares, or a subtype of it (`docs/specs/openehr/ITS-JSON`
/// discipline: `_type` names the instance's RM class; the attribute's
/// declared type comes from the RM UML/BMM — e.g. RM ehr
/// `composition.adoc` §Attributes types `content` `List<CONTENT_ITEM>`, so a
/// `DV_TEXT` member of `content` is a positive type contradiction). This is
/// the WRONGNESS half of slot typing — it never relaxes ("data may be
/// missing, but it may not be wrong", RM common
/// `master06-change_control_package.adoc` §Incomplete Content); the
/// presence/lower-bound half lives in [`check_mandatory_containers`] /
/// [`nonempty_list_violations`].
///
/// Returns `None` (no judgement) when the slot is unknown to the model, the
/// declared type is not a modelled class (a primitive such as `String`), or
/// the attribute is a keyed map (`Hash` — its JSON object is a map, not an
/// RM node). An untagged child is judged elsewhere (the effective-type rule,
/// [`crate::model::declared_concrete_type`]).
#[must_use]
pub fn check_declared_slot_type(
    parent_type: &str,
    field: &str,
    wire_type: &str,
) -> Option<InvariantViolation> {
    let attr = crate::model::attribute(parent_type, field)?;
    if matches!(attr.container, crate::model::Container::Hash) {
        return None;
    }
    crate::model::class(attr.declared_type)?;
    if crate::model::is_a(wire_type, attr.declared_type) {
        return None;
    }
    Some(InvariantViolation::here(format!(
        "does not conform to RM type {parent_type}: `{field}` is declared \
         {declared} and this member claims `_type` {wire_type}, which is not \
         a {declared}",
        declared = attr.declared_type,
    )))
}

/// The scalar-member arm of the declared-slot-type rule.
///
/// A NON-OBJECT member
/// of a list slot whose declared element type is a modelled RM class is the
/// same positive contradiction a foreign `_type` is — no JSON scalar can be
/// an instance of an RM class (canonical JSON encodes every RM object as a
/// JSON object; ITS-JSON). Same guards as [`check_declared_slot_type`]:
/// `None` for unknown slots, primitive-typed slots (a `List<String>` member
/// IS legitimately a scalar), and keyed maps. Never relaxed.
#[must_use]
pub fn check_slot_member_is_object(parent_type: &str, field: &str) -> Option<InvariantViolation> {
    let attr = crate::model::attribute(parent_type, field)?;
    if matches!(attr.container, crate::model::Container::Hash) {
        return None;
    }
    crate::model::class(attr.declared_type)?;
    Some(InvariantViolation::here(format!(
        "does not conform to RM type {parent_type}: `{field}` is declared \
         {declared} and this member is not a JSON object",
        declared = attr.declared_type,
    )))
}

/// `LOCATABLE.Archetyped_valid`: `is_archetype_root xor archetype_details =
/// Void`
/// (`docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.common.locatable.adoc`
/// §Invariants).
///
/// The enforceable arm on an instance is: a **non-root** node — one whose
/// `archetype_node_id` is an `at`/`id` term code
/// ([`crate::paths::archetype_node_id_is_term_code`]), which per the node-id
/// format can never be the root of an archetyped structure — must NOT carry
/// `archetype_details`.
///
/// NOTE: the converse arm ("an archetype-HRID node must carry
/// `archetype_details`") is NOT enforced, because the invariant's own
/// operand is undefined: `locatable.adoc` §Functions gives
/// `is_archetype_root ()` a Meaning sentence only ("True if this node is
/// the root of an archetyped structure") — no postcondition, no derivation
/// expression. Under the reference-object-model reading, where
/// `is_archetype_root` IS the presence of `archetype_details`, the
/// invariant is a tautology and the converse arm asserts nothing; only
/// under the node-id reading would it mandate `archetype_details` on every
/// archetype-HRID node, and the released text does not choose between the
/// two. An arm that is underivable from the released text is not
/// enforceable, so it is reported rather than gated. Corroboration, not the
/// ground:
/// the CNF valid data sets and the canonical-JSON corpus systematically
/// omit `archetype_details` on nested archetype roots. The COMPOSITION
/// root arm stays separately enforced (`composition_impl.rs`
/// `Is_archetype_root`).
///
/// The second arm is the root node-id **identity** rule: a node that DOES
/// carry `archetype_details` is an archetype root, and
/// `docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.common.locatable.adoc`
/// §Attributes (`archetype_node_id`) fixes its value verbatim — "At an
/// archetype root point, the value of this attribute is always the
/// stringified form of the `archetype_id` found in the `archetype_details`
/// object" (restated in
/// `docs/specs/openehr/RM/docs/common/master03-archetyped_package.adoc`
/// §The LOCATABLE Class: "the only exception is at archetype root points in
/// data, where `archetype_node_id` carries the archetype identifier in
/// string form"). A root whose two archetype identities disagree names no
/// resolvable generating archetype.
///
/// Both violations are reported on the node itself (an empty
/// [`InvariantViolation::path`]); the caller prefixes the absolute RM path.
#[must_use]
pub fn check_archetyped_valid(
    node_id: Option<&str>,
    has_archetype_details: bool,
    details_archetype_id: Option<&str>,
) -> Vec<InvariantViolation> {
    let mut out = Vec::new();
    let Some(node_id) = node_id else {
        return out;
    };
    if crate::paths::archetype_node_id_is_term_code(node_id) && has_archetype_details {
        out.push(InvariantViolation::here(format!(
            "node {node_id:?} is not an archetype root (at/id term code) and must \
             not carry archetype_details (LOCATABLE.Archetyped_valid)"
        )));
    }
    if let Some(archetype_id) = details_archetype_id
        && archetype_id != node_id
    {
        out.push(InvariantViolation::here(format!(
            "archetype root archetype_node_id {node_id:?} is not the stringified \
             archetype_details.archetype_id {archetype_id:?} — at an archetype root \
             the two are always the same value (LOCATABLE.archetype_node_id)"
        )));
    }
    out
}

/// The `CLUSTER.items` PRESENCE duty.
///
/// It is split out of
/// [`check_data_structure_shapes`] because it is a mandatory-presence rule and
/// therefore the one shape duty the `553|incomplete|` state relaxes (RM common
/// `master06-change_control_package.adoc` §Incomplete Content: "container
/// attributes may be empty, even though they may have minimum existence and
/// cardinality respectively of one").
///
/// `CLUSTER.items` is 1..1 (RM `data_structures` `cluster.adoc`; the ITS-JSON
/// CLUSTER schema lists `items` as required) — after deserialize an absent
/// list collapses into an empty `Vec`, so presence is only checkable here.
/// Reported on the node itself; the caller prefixes the absolute RM path.
#[must_use]
pub fn check_cluster_items_present(
    obj: &serde_json::Map<String, Value>,
    ty: Option<&str>,
) -> Option<InvariantViolation> {
    (ty == Some("CLUSTER") && obj.get("items").and_then(Value::as_array).is_none()).then(|| {
        InvariantViolation::here("CLUSTER.items is mandatory (1..1 List<ITEM>, cluster.adoc)")
    })
}

/// JSON-level data-structure shape duties the typed model cannot express:
///
/// - one `HISTORY`'s events all carry the SAME `ITEM_STRUCTURE` subtype
///   in `data` — "A History of type `HISTORY<ITEM_LIST>` … constrains the
///   type of the data at each Event to be of type `ITEM_LIST` and nothing
///   else" (`docs/specs/openehr/RM/docs/data_structures/master06-history_package.adoc`;
///   `history.adoc` generic parameter) — the monomorphized runtime type
///   cannot see `T`.
///
/// The `CLUSTER.items` presence duty is [`check_cluster_items_present`]'s (it
/// is a mandatory-presence rule, so the incomplete-commit path relaxes it while
/// keeping every duty here). A HISTORY violation carries the `events[i]`
/// sub-path of the offending event; the caller prefixes the absolute RM path.
#[must_use]
pub fn check_data_structure_shapes(
    obj: &serde_json::Map<String, Value>,
    ty: Option<&str>,
) -> Vec<InvariantViolation> {
    let mut out = Vec::new();
    let Some(ty) = ty else {
        return out;
    };
    if ty == "HISTORY"
        && let Some(events) = obj.get("events").and_then(Value::as_array)
    {
        let mut first: Option<&str> = None;
        for (i, event) in events.iter().enumerate() {
            let Some(data_ty) = event.pointer("/data/_type").and_then(Value::as_str) else {
                continue;
            };
            match first {
                None => first = Some(data_ty),
                Some(locked) if locked != data_ty => {
                    out.push(InvariantViolation::at(
                        format!("events[{i}]"),
                        format!(
                            "HISTORY events must all carry the same ITEM_STRUCTURE \
                             subtype in data — the history is HISTORY<{locked}> but \
                             this event carries {data_ty} (RM data_structures \
                             master06 §History)"
                        ),
                    ));
                }
                Some(_) => {}
            }
        }
    }
    out
}

/// Build a class-invariant violation in this workspace's uniform message
/// shape: `"Invariant <name> failed on type <RM_TYPE>"`, where `<name>` is the
/// invariant's own BMM name, so a failure is identifiable by that name alone.
///
/// NOTE: no openEHR spec governs the wording of a violation message — the
/// shape is our own convention (it originated in openEHR's reference
/// implementation, which is prior art only, never the ground). The path is
/// left empty (the value itself); the composition validator prefixes the
/// absolute RM path.
#[must_use]
pub(crate) fn invariant_failed(name: &str, rm_type: &str) -> InvariantViolation {
    InvariantViolation::here(format!("Invariant {name} failed on type {rm_type}"))
}

/// `true` when a floating value denotes a whole number — the integrality
/// probe the DV_PROPORTION precision/fraction invariants need (RM
/// `dv_proportion.adoc` §Invariants; archie's `isInteger` is the prior art).
#[must_use]
#[expect(
    clippy::float_cmp,
    reason = "an exact-integrality test is precisely a bit-equality question (`x.floor() == x`), not a tolerance comparison"
)]
pub(crate) fn is_integral(v: f64) -> bool {
    v.is_finite() && v.floor() == v
}

// ── named runtime realizations of the BMM assertion-dialect predicates ────────
//
// These are the callable runtime helpers the assertion-dialect emitter maps its
// leaf predicates onto (the `plan::overrides` dialect table names each). They
// were previously inlined into the invariant cores below; extracting them under
// the BMM predicate spelling makes the emitter's future generated cores call one
// named runtime function per dialect predicate. Behaviour is identical to the
// former inline forms.

/// BASE/RM `valid_magnitude_status (s)`: `s` is one of `= < > <= >= ~` — the
/// DV_QUANTIFIED `Magnitude_status_valid` predicate
/// (`docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.data_types.dv_quantified.adoc`).
#[must_use]
pub(crate) fn valid_magnitude_status(s: &str) -> bool {
    matches!(s, "=" | "<" | ">" | "<=" | ">=" | "~")
}

/// RM `valid_percentage (v)`: `0 <= v <= 100` — the DV_AMOUNT `Accuracy_validity`
/// predicate for a percent-recorded accuracy
/// (`docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.data_types.dv_amount.adoc`).
#[must_use]
pub(crate) fn valid_percentage(v: f64) -> bool {
    (0.0..=100.0).contains(&v)
}

/// RM `valid_proportion_kind (k)`: `k` is one of the PROPORTION_KIND codes
/// `0..=4` (ratio, unitary, percent, fraction, integer_fraction) — the
/// DV_PROPORTION `Type_validity` predicate
/// (`docs/specs/openehr/RM/docs/UML/classes/org.openehr.rm.data_types.proportion_kind.adoc`).
#[must_use]
pub(crate) fn valid_proportion_kind(k: i32) -> bool {
    (0..=4).contains(&k)
}

// ── ISO-8601 value validation ────────────────────────────────────────────────
//
// NOTE: archie has no `@Invariant` for DV_DATE/DV_TIME/DV_DATE_TIME/
// DV_DURATION value well-formedness — it enforces it structurally by parsing
// `value` into a typed `java.time` object at construction. In our model the
// value is a `String`, so we express the same guarantee as an explicit RM class
// invariant (`Value_valid`). The forms accepted are the openEHR ISO-8601 subset
// (partial precision permitted; DV_DURATION permits a leading sign and a `W`
// designator mixed with others, per the openEHR deviation). Kept intentionally
// lenient: it rejects clearly-malformed values, not valid partial ones.

fn all_digits(s: &str) -> bool {
    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}

/// The byte sub-range of `s`, or `""` when the range is out of bounds or lands
/// off a UTF-8 boundary.
///
/// The compact ISO 8601 forms below extract fixed byte windows only after an
/// exact-length + all-ASCII-digits test, so the empty fallback is unreachable
/// there; it exists so the extraction can never panic (a `""` window fails
/// every `in_range`/`valid_day` predicate, i.e. it rejects rather than aborts).
fn part(s: &str, range: std::ops::Range<usize>) -> &str {
    s.get(range).unwrap_or_default()
}

fn digits_n(s: &str, n: usize) -> bool {
    s.len() == n && all_digits(s)
}

fn in_range(s: &str, lo: u32, hi: u32) -> bool {
    s.len() == 2 && all_digits(s) && s.parse::<u32>().is_ok_and(|v| (lo..=hi).contains(&v))
}

/// `true` for a Gregorian leap year: divisible by 4, except centuries not
/// divisible by 400 (BASE `Time_definitions`; the calendar `days_in_month`
/// depends on it).
fn is_leap_year(year: u32) -> bool {
    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}

/// Calendar days in a given month of a given year — the `days_in_month (m, y)`
/// the BASE `Time_definitions.valid_day` postcondition dispatches through
/// (`docs/specs/openehr/BASE/docs/UML/classes/org.openehr.base.foundation_types.time_definitions.adoc`
/// lines 95–103). Returns `0` for a month outside `1..=12` (caller has already
/// range-checked the month, so that branch is defensive).
fn days_in_month(year: u32, month: u32) -> u32 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if is_leap_year(year) => 29,
        2 => 28,
        _ => 0,
    }
}

/// A calendar-valid two-digit day for the 4-digit `y` / 2-digit `m` strings:
/// `d` is `01`..`days_in_month(m, y)` — the BASE `Iso8601_date` invariant
/// `Day_valid: not day_unknown implies valid_day (year, month, day)` with
/// `valid_day (y, m, d) = (d >= 1 and d <= days_in_month (m, y))`
/// (`org.openehr.base.foundation_types.iso8601_date.adoc` line 107;
/// `time_definitions.adoc` line 102). This is calendar-exact — it rejects
/// `2021-02-31`, `2021-04-31`, and `2021-02-29` (non-leap) while accepting
/// `2020-02-29` (leap).
fn valid_day(y: &str, m: &str, d: &str) -> bool {
    if d.len() != 2 || !all_digits(d) {
        return false;
    }
    let (Ok(year), Ok(month), Ok(day)) = (y.parse::<u32>(), m.parse::<u32>(), d.parse::<u32>())
    else {
        return false;
    };
    (1..=days_in_month(year, month)).contains(&day)
}

/// A valid openEHR ISO-8601 date: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, or the
/// compact `YYYYMM` / `YYYYMMDD` forms. Day validity is **calendar-exact**
/// (month lengths + leap years) per BASE `Iso8601_date.Day_valid`, not a bare
/// `1..=31` range.
#[must_use]
pub(crate) fn is_valid_iso_date(s: &str) -> bool {
    if s.contains('-') {
        match s.split('-').collect::<Vec<_>>().as_slice() {
            [y] => digits_n(y, 4),
            [y, m] => digits_n(y, 4) && in_range(m, 1, 12),
            [y, m, d] => digits_n(y, 4) && in_range(m, 1, 12) && valid_day(y, m, d),
            _ => false,
        }
    } else {
        match s.len() {
            4 => all_digits(s),
            6 => all_digits(s) && in_range(part(s, 4..6), 1, 12),
            8 => {
                all_digits(s)
                    && in_range(part(s, 4..6), 1, 12)
                    && valid_day(part(s, 0..4), part(s, 4..6), part(s, 6..8))
            }
            _ => false,
        }
    }
}

fn is_valid_tz(tz: &str) -> bool {
    if tz.is_empty() || tz == "Z" {
        return true;
    }
    let Some(rest) = tz.strip_prefix(['+', '-']) else {
        return false;
    };
    // BASE `Iso8601_timezone` bounds are ASYMMETRIC (`iso8601_timezone.adoc`
    // Max_hour_valid / Min_hour_valid; `time_definitions.adoc`
    // Max_timezone_hour = 14, Min_timezone_hour = 12): `+` offsets go to
    // +14:00, `-` offsets only to -12:00 (reject `-13:00`).
    //
    // NOTE (corpus adjudication): the invariants literally require
    // `hour > 0` when signed, but the canonical corpus + CNF data sets carry
    // `+00:00`/`-00:00` UTC forms in 42 files — the corpus outranks the prose
    // reading, so hour 0 is accepted with either sign (≡ `Z`).
    let max_hour = if tz.starts_with('+') { 14 } else { 12 };
    if rest.contains(':') {
        matches!(rest.split(':').collect::<Vec<_>>().as_slice(),
            [h, m] if in_range(h, 0, max_hour) && in_range(m, 0, 59))
    } else {
        match rest.len() {
            2 => in_range(rest, 0, max_hour),
            4 => in_range(part(rest, 0..2), 0, max_hour) && in_range(part(rest, 2..4), 0, 59),
            _ => false,
        }
    }
}

fn is_valid_time_core(s: &str) -> bool {
    // A trailing decimal fraction (after '.' or ',') is legal ONLY on the
    // seconds component: "partial date/times with fractional minutes or hours
    // … in openEHR, only fractional seconds are supported" (BASE
    // `foundation_types/master06-time_types.adoc` §"ISO 8601 semantics not
    // included in these types"). A fractional hour ("10.5") or fractional
    // minute ("10:05.5") is therefore rejected — a fraction is accepted only
    // when the base carries full `HH:MM:SS` (seconds present). This mirrors the
    // seconds-only fraction rule the duration path enforces
    // (`parse_duration_components`).
    let (base, frac) = match s.split_once(['.', ',']) {
        Some((b, f)) => (b, Some(f)),
        None => (s, None),
    };
    if let Some(f) = frac
        && !all_digits(f)
    {
        return false;
    }
    let has_frac = frac.is_some();
    if base.contains(':') {
        match base.split(':').collect::<Vec<_>>().as_slice() {
            [h] => !has_frac && in_range(h, 0, 23),
            [h, m] => !has_frac && in_range(h, 0, 23) && in_range(m, 0, 59),
            [h, m, sec] => in_range(h, 0, 23) && in_range(m, 0, 59) && in_range(sec, 0, 60),
            _ => false,
        }
    } else {
        match base.len() {
            2 => !has_frac && in_range(base, 0, 23),
            4 => {
                !has_frac && in_range(part(base, 0..2), 0, 23) && in_range(part(base, 2..4), 0, 59)
            }
            6 => {
                in_range(part(base, 0..2), 0, 23)
                    && in_range(part(base, 2..4), 0, 59)
                    && in_range(part(base, 4..6), 0, 60)
            }
            _ => false,
        }
    }
}

/// A valid openEHR ISO-8601 time: `HH`, `HH:MM`, `HH:MM:SS[.fff]` (and the
/// compact `HHMM` / `HHMMSS` forms), with an optional `Z` / `±HH[:MM]` timezone.
#[must_use]
pub(crate) fn is_valid_iso_time(s: &str) -> bool {
    // Split off a trailing timezone (`Z`, or a `+`/`-` offset that is not the
    // fractional separator). Scan from the end for `Z`/`+`/`-`.
    if let Some(stripped) = s.strip_suffix('Z') {
        return is_valid_time_core(stripped);
    }
    if let Some((core, tz)) = s.rfind(['+', '-']).and_then(|pos| s.split_at_checked(pos)) {
        return is_valid_time_core(core) && is_valid_tz(tz);
    }
    is_valid_time_core(s)
}

/// A valid openEHR ISO-8601 date-time: a date, then (if a time component is
/// present) `T` and a time. A `T`-less value is accepted as a date-only partial.
#[must_use]
pub(crate) fn is_valid_iso_date_time(s: &str) -> bool {
    match s.split_once('T') {
        Some((date, time)) => is_valid_iso_date(date) && is_valid_iso_time(time),
        None => is_valid_iso_date(s),
    }
}

fn parse_duration_components(s: &str, allowed: &[u8], any: &mut bool) -> bool {
    let bytes = s.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        let start = i;
        while bytes.get(i).is_some_and(u8::is_ascii_digit) {
            i += 1;
        }
        let mut has_fraction = false;
        if matches!(bytes.get(i), Some(b'.' | b',')) {
            has_fraction = true;
            i += 1;
            while bytes.get(i).is_some_and(u8::is_ascii_digit) {
                i += 1;
            }
        }
        // No number, or a number with no designator after it.
        let Some(&designator) = bytes.get(i) else {
            return false;
        };
        if i == start {
            return false;
        }
        if !allowed.contains(&designator) {
            return false;
        }
        // BASE `master06-time_types.adoc` §Primitive Time Types: "in openEHR,
        // only fractional seconds are supported" — a decimal fraction is only
        // permitted on the seconds ('S') component, never on Y/M/W/D/H/M.
        if has_fraction && designator != b'S' {
            return false;
        }
        i += 1;
        *any = true;
    }
    true
}

/// A valid openEHR ISO-8601 duration: optional leading sign, `P`, then one or
/// more `nY nM nW nD` components and an optional `T nH nM nS` part (openEHR
/// permits the sign and a `W` designator mixed with the others).
#[must_use]
pub(crate) fn is_valid_iso_duration(s: &str) -> bool {
    let s = s.strip_prefix(['+', '-']).unwrap_or(s);
    let Some(rest) = s.strip_prefix('P') else {
        return false;
    };
    let (date_part, time_part) = match rest.split_once('T') {
        Some((d, t)) => (d, Some(t)),
        None => (rest, None),
    };
    let mut any = false;
    if !parse_duration_components(date_part, b"YMWD", &mut any) {
        return false;
    }
    if let Some(t) = time_part
        && (t.is_empty() || !parse_duration_components(t, b"HMS", &mut any))
    {
        return false;
    }
    any
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn iso_date_forms() {
        assert!(is_valid_iso_date("2021"));
        assert!(is_valid_iso_date("2021-05"));
        assert!(is_valid_iso_date("2021-05-17"));
        assert!(is_valid_iso_date("20210517"));
        assert!(!is_valid_iso_date("2021-13"));
        assert!(!is_valid_iso_date("2021-05-32"));
        assert!(!is_valid_iso_date("not-a-date"));
        assert!(!is_valid_iso_date(""));
    }

    /// BASE `Iso8601_date.Day_valid` (`valid_day = d <= days_in_month(m, y)`,
    /// `iso8601_date.adoc` line 107). Calendar-exact month lengths, both the
    /// extended (`YYYY-MM-DD`) and compact (`YYYYMMDD`) forms.
    #[test]
    fn iso_date_day_is_calendar_exact() {
        // 31-day months accept 31; 30-day months reject it.
        assert!(is_valid_iso_date("2021-01-31"));
        assert!(is_valid_iso_date("2021-12-31"));
        assert!(!is_valid_iso_date("2021-04-31")); // April has 30 days
        assert!(!is_valid_iso_date("2021-06-31"));
        assert!(!is_valid_iso_date("2021-09-31"));
        assert!(!is_valid_iso_date("2021-11-31"));
        assert!(is_valid_iso_date("2021-04-30"));

        // February: 28 in a common year, 29 in a leap year, never 30/31.
        assert!(!is_valid_iso_date("2021-02-31"));
        assert!(!is_valid_iso_date("2021-02-30"));
        assert!(!is_valid_iso_date("2021-02-29")); // 2021 is not a leap year
        assert!(is_valid_iso_date("2021-02-28"));
        assert!(is_valid_iso_date("2020-02-29")); // 2020 divisible by 4
        assert!(is_valid_iso_date("2000-02-29")); // 2000 divisible by 400
        assert!(!is_valid_iso_date("1900-02-29")); // 1900 century, not /400

        // Day 00 is never valid.
        assert!(!is_valid_iso_date("2021-05-00"));

        // Compact form is held to the same calendar rule.
        assert!(!is_valid_iso_date("20210431"));
        assert!(!is_valid_iso_date("20210229"));
        assert!(is_valid_iso_date("20200229"));
        assert!(is_valid_iso_date("20210131"));
    }

    #[test]
    fn iso_time_forms() {
        assert!(is_valid_iso_time("10"));
        assert!(is_valid_iso_time("10:30"));
        assert!(is_valid_iso_time("10:30:59"));
        assert!(is_valid_iso_time("10:30:59.250"));
        assert!(is_valid_iso_time("10:30:59Z"));
        assert!(is_valid_iso_time("10:30:59+01:00"));
        assert!(!is_valid_iso_time("25:00"));
        assert!(!is_valid_iso_time("10:61"));
        assert!(!is_valid_iso_time("abc"));
    }

    /// BASE `foundation_types/master06-time_types.adoc` §"ISO 8601 semantics
    /// not included in these types": "partial date/times with fractional
    /// minutes or hours … in openEHR, only fractional seconds are supported".
    /// A fraction is accepted only on full `HH:MM:SS` (seconds present);
    /// fractional hours/minutes are rejected.
    #[test]
    fn iso_time_fraction_only_on_seconds() {
        // Fractional seconds (period or comma) is the sole permitted case.
        assert!(is_valid_iso_time("10:30:59.250"));
        assert!(is_valid_iso_time("10:30:59,5"));
        assert!(is_valid_iso_time("103059.250")); // compact HHMMSS
        assert!(is_valid_iso_time("10:30:59.5+01:00")); // fraction before timezone
        // A fractional hour or minute is rejected in every base form.
        assert!(!is_valid_iso_time("10.5")); // fractional hour
        assert!(!is_valid_iso_time("10:05.5")); // fractional minute (extended)
        assert!(!is_valid_iso_time("1005.5")); // fractional minute (compact HHMM)
        assert!(!is_valid_iso_time("10,5")); // fractional hour, comma
    }

    #[test]
    fn iso_date_time_forms() {
        assert!(is_valid_iso_date_time("2021-05-17T10:30:00"));
        assert!(is_valid_iso_date_time("2021-05-17T10:30:00+02:00"));
        assert!(is_valid_iso_date_time("2021-05-17"));
        assert!(!is_valid_iso_date_time("2021-05-17T99:00"));
        assert!(!is_valid_iso_date_time("nope"));
    }

    #[test]
    fn iso_duration_forms() {
        assert!(is_valid_iso_duration("P1Y"));
        assert!(is_valid_iso_duration("P1Y2M10D"));
        assert!(is_valid_iso_duration("PT2H30M"));
        assert!(is_valid_iso_duration("P1Y2M10DT2H30M"));
        assert!(is_valid_iso_duration("P2W"));
        assert!(is_valid_iso_duration("-P1D"));
        assert!(is_valid_iso_duration("PT0.5S"));
        assert!(!is_valid_iso_duration("P"));
        assert!(!is_valid_iso_duration("1Y"));
        assert!(!is_valid_iso_duration("P1X"));
        assert!(!is_valid_iso_duration("PT"));
    }

    /// BASE `master06-time_types.adoc` §Primitive Time Types: "in openEHR, only
    /// fractional seconds are supported" — a decimal fraction on any component
    /// other than seconds is invalid, even though the pattern of designators is
    /// otherwise well-formed.
    #[test]
    fn iso_duration_fraction_only_on_seconds() {
        // Fraction on seconds (period or comma) is the sole permitted case.
        assert!(is_valid_iso_duration("PT2H30M0.5S"));
        assert!(is_valid_iso_duration("PT0,5S"));
        // Fraction on any other component is rejected.
        assert!(!is_valid_iso_duration("P1Y3M4DT2.5H"));
        assert!(!is_valid_iso_duration("PT2H14.5M"));
        assert!(!is_valid_iso_duration("P1.5Y"));
        assert!(!is_valid_iso_duration("P1.5M"));
        assert!(!is_valid_iso_duration("P1.5W"));
        assert!(!is_valid_iso_duration("P1.5D"));
        assert!(!is_valid_iso_duration("PT1.5H"));
        assert!(!is_valid_iso_duration("PT2H14,5M"));
    }

    // NOTE: the `_type`-dispatch tests (fast/typed equivalence, corpus, mutation
    // battery) live in `openehr-its/tests/it/rm_validation.rs`, where the tiers
    // are reachable through the COMPOSED wire entry points — the fast path
    // (`try_fast_validate`) and the typed table
    // (`typed_dispatch::dispatch_typed`) both live in this crate, but the
    // generated five-crate structural fallthrough they are composed with can
    // only exist downstream. The ISO-8601 helper tests below stay with the
    // helpers they exercise.

    /// BASE `Iso8601_timezone`: `+` offsets reach +14:00, `-` offsets stop at
    /// -12:00; ±00:00 accepted per the corpus (see `is_valid_tz`).
    #[test]
    fn timezone_bounds_are_asymmetric() {
        assert!(is_valid_iso_time("10:00:00+14:00"));
        assert!(is_valid_iso_time("10:00:00-12:00"));
        assert!(is_valid_iso_time("10:00:00+00:00"));
        assert!(is_valid_iso_time("10:00:00-00:00"));
        assert!(
            !is_valid_iso_time("10:00:00+15:00"),
            "+15 exceeds Max_timezone_hour"
        );
        assert!(
            !is_valid_iso_time("10:00:00-13:00"),
            "-13 exceeds Min_timezone_hour"
        );
    }
}