Skip to main content

ehds_logging/
classify.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The categories of one access, classified with the deployment's
5//! [`CategoryMap`] from the model ids of what the access delivered, read or
6//! wrote (Regulation (EU) 2025/327 Annex II 3.2(c)).
7//!
8//! The data an access reaches are its root objects, each named by its
9//! template id and its archetype id: the template key wins, then the
10//! archetype key. Where the access delivered a value that is no root object
11//! (a leaf value or an aggregate), or nothing at all, the ids the request
12//! constrains its data to classify it too, its templates first. A category
13//! is a set, since one access reaches data of several, and `none` never
14//! removes one. A key matches only exactly, or, in a map declared with
15//! [`Matching::OpenEhr`](crate::map::Matching::OpenEhr), apart from case: an
16//! id that differs by space or a specialisation is unmapped. A root object of
17//! a resource kind that holds no data of any category, such as an
18//! `EHR_STATUS` reached beside a `COMPOSITION`, adds "no category" with its
19//! kind and takes nothing from the categories of the others. Whatever is
20//! uncertain marks the access [`Classification::unclassified`] with its ids
21//! as evidence, and the access is never refused for it. No specification
22//! governs the classification: our own design.
23
24use std::collections::{BTreeMap, BTreeSet};
25use std::fmt;
26
27use crate::category::Category;
28use crate::map::{CategoryMap, Mapping, Table};
29
30/// What a category of an access was read from.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[non_exhaustive]
33pub enum Basis {
34    /// A root object the access delivered or read.
35    Returned,
36    /// A root object the access wrote.
37    Written,
38    /// An id the request constrains its data to, where it carries no root
39    /// object.
40    Queried,
41    /// The kind of request, which serves one category by construction.
42    Construction,
43    /// The kind of resource the access reached, which holds data of no
44    /// category.
45    ResourceKind,
46}
47
48impl Basis {
49    /// The basis as a record writes it.
50    #[must_use]
51    pub fn code(self) -> &'static str {
52        match self {
53            Self::Returned => "returned",
54            Self::Written => "written",
55            Self::Queried => "queried",
56            Self::Construction => "construction",
57            Self::ResourceKind => "resource-kind",
58        }
59    }
60}
61
62/// A kind of resource that holds no data of any category: the openEHR
63/// Reference Model classes an access can reach beside clinical content, or a
64/// kind the deployment names.
65#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
66#[non_exhaustive]
67pub enum ResourceKind {
68    /// `EHR`.
69    Ehr,
70    /// `EHR_STATUS`.
71    EhrStatus,
72    /// `EHR_ACCESS`.
73    EhrAccess,
74    /// `FOLDER`.
75    Folder,
76    /// An `ITEM_TAG`.
77    ItemTag,
78    /// A `REVISION_HISTORY`.
79    RevisionHistory,
80    /// A kind the deployment names.
81    Other(String),
82}
83
84impl ResourceKind {
85    /// The kind as a record writes it: the Reference Model class name, or the
86    /// deployment's name.
87    #[must_use]
88    pub fn code(&self) -> &str {
89        match self {
90            Self::Ehr => "EHR",
91            Self::EhrStatus => "EHR_STATUS",
92            Self::EhrAccess => "EHR_ACCESS",
93            Self::Folder => "FOLDER",
94            Self::ItemTag => "ITEM_TAG",
95            Self::RevisionHistory => "REVISION_HISTORY",
96            Self::Other(name) => name,
97        }
98    }
99}
100
101/// One root object an access reached, named by its model ids: never a value
102/// it holds.
103#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
104pub struct RootObject {
105    /// `archetype_details.template_id`, when it names one.
106    pub template_id: Option<String>,
107    /// `archetype_details.archetype_id`, or the archetype node id of a root.
108    pub archetype_id: Option<String>,
109    /// The `OBJECT_VERSION_ID` of the version the object is, when known.
110    pub version_uid: Option<String>,
111    /// The kind of the object, when it is a resource that holds data of no
112    /// category: the object then adds "no category" with its kind, whatever
113    /// its ids.
114    pub kind: Option<ResourceKind>,
115}
116
117impl fmt::Debug for RootObject {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        f.debug_struct("RootObject")
120            .field("template_id", &self.template_id.is_some())
121            .field("archetype_id", &self.archetype_id.is_some())
122            .field("version_uid", &self.version_uid.is_some())
123            .field("kind", &self.kind)
124            .finish()
125    }
126}
127
128/// What an access shows of the data it reached.
129#[derive(Clone, Default, PartialEq, Eq)]
130pub struct Evidence {
131    reached: Option<Basis>,
132    objects: Vec<RootObject>,
133    queried_templates: BTreeSet<String>,
134    queried_archetypes: BTreeSet<String>,
135    every_root_bound: bool,
136    unrooted: bool,
137    no_category: bool,
138    kinds: BTreeSet<ResourceKind>,
139    unreadable: Option<Unreadable>,
140    constructed: BTreeSet<Category>,
141}
142
143/// The ids a request constrains its data to, read from its syntax tree.
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
145pub struct Queried {
146    /// The template ids.
147    pub templates: BTreeSet<String>,
148    /// The archetype ids.
149    pub archetypes: BTreeSet<String>,
150    /// Whether every class the request reads data from is bound to one of
151    /// them; a request the reader could not bind is never of no category.
152    pub every_root_bound: bool,
153}
154
155impl Evidence {
156    /// The evidence of an access that reached `objects` as `basis`
157    /// ([`Basis::Returned`] or [`Basis::Written`]).
158    #[must_use]
159    pub fn reached(basis: Basis, objects: Vec<RootObject>) -> Self {
160        Self {
161            reached: Some(basis),
162            objects,
163            ..Self::default()
164        }
165    }
166
167    /// The evidence of an access to a resource that holds no data of any
168    /// category, such as an `EHR` or an `EHR_STATUS`, with no kind named.
169    #[must_use]
170    pub fn no_category() -> Self {
171        Self {
172            no_category: true,
173            ..Self::default()
174        }
175    }
176
177    /// The evidence of an access to a resource of `kind`, which holds no data
178    /// of any category.
179    #[must_use]
180    pub fn resource(kind: ResourceKind) -> Self {
181        Self {
182            no_category: true,
183            kinds: BTreeSet::from([kind]),
184            ..Self::default()
185        }
186    }
187
188    /// The evidence of an access whose data could not be read for their
189    /// ids, for the reason `why`.
190    #[must_use]
191    pub fn unreadable(why: Unreadable) -> Self {
192        Self {
193            unreadable: Some(why),
194            ..Self::default()
195        }
196    }
197
198    /// This evidence, the request constraining its data as `queried` says.
199    #[must_use]
200    pub fn queried(mut self, queried: Queried) -> Self {
201        self.queried_templates = queried.templates;
202        self.queried_archetypes = queried.archetypes;
203        self.every_root_bound = queried.every_root_bound;
204        self
205    }
206
207    /// This evidence, the access having delivered a value that is no root
208    /// object, such as a leaf value or an aggregate, whose data the request's
209    /// constraints then classify beside the root objects.
210    #[must_use]
211    pub fn with_unrooted(mut self) -> Self {
212        self.unrooted = true;
213        self
214    }
215
216    /// This evidence, the request serving `category` by construction, such
217    /// as a request for a patient summary, whatever its data show: the
218    /// classification holds `category` on the basis [`Basis::Construction`]
219    /// beside every category the data show.
220    #[must_use]
221    pub fn constructed(mut self, category: Category) -> Self {
222        self.constructed.insert(category);
223        self
224    }
225
226    /// The root objects the access reached.
227    #[must_use]
228    pub fn objects(&self) -> &[RootObject] {
229        &self.objects
230    }
231}
232
233impl fmt::Debug for Evidence {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        f.debug_struct("Evidence")
236            .field("reached", &self.reached)
237            .field("objects", &self.objects.len())
238            .field("queried_templates", &self.queried_templates.len())
239            .field("queried_archetypes", &self.queried_archetypes.len())
240            .field("every_root_bound", &self.every_root_bound)
241            .field("unrooted", &self.unrooted)
242            .field("no_category", &self.no_category)
243            .field("kinds", &self.kinds)
244            .field("unreadable", &self.unreadable.is_some())
245            .field("constructed", &self.constructed)
246            .finish()
247    }
248}
249
250/// Why an access is unclassified.
251#[derive(Debug, Clone, PartialEq, Eq)]
252#[non_exhaustive]
253pub enum Unclassified {
254    /// A root object, or every id the request names, maps to no key.
255    Unmapped,
256    /// The access carries no root object and the request names no id.
257    NamedNothing,
258    /// The request reads a class bound to no id, so what it read is not
259    /// known.
260    Unbound,
261    /// The data could not be read for their ids, for the reason given.
262    Unreadable(Unreadable),
263}
264
265impl Unclassified {
266    /// The reason as a record writes it: `unmapped`, `named-nothing`,
267    /// `unbound`, or the code of the [`Unreadable`] reason.
268    #[must_use]
269    pub fn code(&self) -> &'static str {
270        match self {
271            Self::Unmapped => "unmapped",
272            Self::NamedNothing => "named-nothing",
273            Self::Unbound => "unbound",
274            Self::Unreadable(why) => why.code(),
275        }
276    }
277}
278
279/// Why the data of an access could not be read for their ids.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
281#[non_exhaustive]
282pub enum Unreadable {
283    /// The recording system does not read the data of this kind of
284    /// operation: `operation-not-read`.
285    Operation,
286    /// The body is in a format the recording system does not read:
287    /// `format-not-read`.
288    Format,
289    /// The body is in a format the recording system reads, but holds no
290    /// root object it can name: `body-not-read`.
291    Body,
292    /// The access returned no object: `no-object-returned`.
293    NoObject,
294}
295
296impl Unreadable {
297    /// The reason as a record writes it.
298    #[must_use]
299    pub fn code(self) -> &'static str {
300        match self {
301            Self::Operation => "operation-not-read",
302            Self::Format => "format-not-read",
303            Self::Body => "body-not-read",
304            Self::NoObject => "no-object-returned",
305        }
306    }
307}
308
309/// The categories of one access, their bases, and the evidence.
310///
311/// Only [`CategoryMap::classify`] makes one, and every one it makes holds a
312/// category, is of no category by an exact `none` or a resource kind, or is
313/// [`unclassified`](Classification::unclassified) with its reason: it has no
314/// `Default`, so no empty classification can stand in for a missing one.
315///
316/// `Debug` shows the categories and the marks, never an id: a template id
317/// tied to a patient says what kind of care they had.
318#[derive(Clone, PartialEq, Eq)]
319pub struct Classification {
320    categories: BTreeMap<Category, BTreeSet<Basis>>,
321    no_category: bool,
322    kinds: BTreeSet<ResourceKind>,
323    unclassified: Option<Unclassified>,
324    templates: BTreeSet<String>,
325    archetypes: BTreeSet<String>,
326    versions: BTreeSet<String>,
327    unmapped: BTreeSet<String>,
328    counts: EvidenceCounts,
329    digest: Option<String>,
330}
331
332/// How many distinct ids each evidence set of a classification held before
333/// the map's evidence limit cut it.
334#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
335pub struct EvidenceCounts {
336    /// Template ids.
337    pub templates: usize,
338    /// Archetype ids.
339    pub archetypes: usize,
340    /// Version uids.
341    pub versions: usize,
342    /// Ids the map holds no key for.
343    pub unmapped: usize,
344}
345
346impl fmt::Debug for Classification {
347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348        f.debug_struct("Classification")
349            .field("categories", &self.categories)
350            .field("no_category", &self.no_category)
351            .field("kinds", &self.kinds)
352            .field("unclassified", &self.unclassified.as_ref().map(|_| "set"))
353            .field("templates", &self.templates.len())
354            .field("archetypes", &self.archetypes.len())
355            .field("versions", &self.versions.len())
356            .field("unmapped", &self.unmapped.len())
357            .finish_non_exhaustive()
358    }
359}
360
361impl Classification {
362    /// The categories, each with what it was read from.
363    #[must_use]
364    pub fn categories(&self) -> &BTreeMap<Category, BTreeSet<Basis>> {
365        &self.categories
366    }
367
368    /// Whether the access reached a resource of no category, or only data
369    /// the map declares `none`, and nothing unclassified.
370    #[must_use]
371    pub fn is_no_category(&self) -> bool {
372        self.no_category && self.categories.is_empty() && self.unclassified.is_none()
373    }
374
375    /// The kinds of resource of no category the access reached, each the
376    /// basis [`Basis::ResourceKind`] of its "no category".
377    #[must_use]
378    pub fn no_category_kinds(&self) -> &BTreeSet<ResourceKind> {
379        &self.kinds
380    }
381
382    /// How many distinct ids each evidence set held; a set holds fewer when
383    /// the map's evidence limit cut it.
384    #[must_use]
385    pub fn evidence_counts(&self) -> EvidenceCounts {
386        self.counts
387    }
388
389    /// Why the access, or a part of it, is unclassified; `None` when the
390    /// map classified all of it.
391    #[must_use]
392    pub fn unclassified(&self) -> Option<&Unclassified> {
393        self.unclassified.as_ref()
394    }
395
396    /// The template ids of the evidence.
397    #[must_use]
398    pub fn templates(&self) -> &BTreeSet<String> {
399        &self.templates
400    }
401
402    /// The archetype ids of the evidence.
403    #[must_use]
404    pub fn archetypes(&self) -> &BTreeSet<String> {
405        &self.archetypes
406    }
407
408    /// The version uids of the root objects the access reached.
409    #[must_use]
410    pub fn versions(&self) -> &BTreeSet<String> {
411        &self.versions
412    }
413
414    /// The ids the map holds no key for.
415    #[must_use]
416    pub fn unmapped(&self) -> &BTreeSet<String> {
417        &self.unmapped
418    }
419
420    /// The digest of the map the access was classified under.
421    #[must_use]
422    pub fn digest(&self) -> Option<&str> {
423        self.digest.as_deref()
424    }
425
426    fn add(&mut self, mapping: &Mapping, basis: Basis) {
427        match mapping {
428            Mapping::NoCategory => self.no_category = true,
429            Mapping::Categories(categories) => {
430                for category in categories {
431                    self.categories
432                        .entry(category.clone())
433                        .or_default()
434                        .insert(basis);
435                }
436            }
437        }
438    }
439}
440
441impl CategoryMap {
442    /// The categories of an access that showed `evidence`.
443    ///
444    /// The categories of every root object and every queried id are joined,
445    /// and `none` never removes one. The access is of no category only when
446    /// its resource kind holds none, or when the map declares `none` by an
447    /// exact key for everything it reached and nothing is uncertain; it is
448    /// unclassified otherwise.
449    #[must_use]
450    pub fn classify(&self, evidence: &Evidence) -> Classification {
451        let mut classified = Classification {
452            categories: BTreeMap::new(),
453            no_category: evidence.no_category,
454            kinds: evidence.kinds.clone(),
455            unclassified: None,
456            templates: BTreeSet::new(),
457            archetypes: BTreeSet::new(),
458            versions: BTreeSet::new(),
459            unmapped: BTreeSet::new(),
460            counts: EvidenceCounts::default(),
461            digest: self.digest().map(str::to_owned),
462        };
463        if evidence.no_category {
464            return classified;
465        }
466        if let Some(why) = evidence.unreadable {
467            classified.unclassify(Unclassified::Unreadable(why));
468        }
469        if let Some(basis) = evidence.reached {
470            for object in &evidence.objects {
471                self.object(object, basis, &mut classified);
472            }
473        }
474        for category in &evidence.constructed {
475            classified
476                .categories
477                .entry(category.clone())
478                .or_default()
479                .insert(Basis::Construction);
480        }
481        if evidence.objects.is_empty() || evidence.unrooted {
482            self.queried(evidence, &mut classified);
483        }
484        // NOTE: no specification governs this: our own design; a result with no category,
485        // no exact `none` and no reason is a path the classifier missed, so it fails closed.
486        if classified.categories.is_empty() && !classified.no_category {
487            classified.unclassify(Unclassified::NamedNothing);
488        }
489        classified.bound(self.evidence_limit());
490        classified
491    }
492
493    /// Classifies one root object into `classified`: its template key, else
494    /// its archetype key, else it is unmapped. An archetype's `none` does not
495    /// stand for a template the map does not hold.
496    fn object(&self, object: &RootObject, basis: Basis, classified: &mut Classification) {
497        classified.templates.extend(object.template_id.clone());
498        classified.archetypes.extend(object.archetype_id.clone());
499        classified.versions.extend(object.version_uid.clone());
500        if let Some(kind) = &object.kind {
501            classified.no_category = true;
502            classified.kinds.insert(kind.clone());
503            return;
504        }
505        let template = object
506            .template_id
507            .as_deref()
508            .map(|template| self.template(template));
509        let archetype = object
510            .archetype_id
511            .as_deref()
512            .and_then(|archetype| self.archetype(archetype));
513        let mapping = match (template, archetype) {
514            (Some(Some(mapping)), _) | (None | Some(None), Some(mapping))
515                if !matches!((template, mapping), (Some(None), Mapping::NoCategory)) =>
516            {
517                Some(mapping)
518            }
519            _ => None,
520        };
521        if let Some(mapping) = mapping {
522            classified.add(mapping, basis);
523            return;
524        }
525        classified.unmapped.extend(
526            object
527                .template_id
528                .iter()
529                .chain(&object.archetype_id)
530                .cloned(),
531        );
532        classified.unclassify(Unclassified::Unmapped);
533    }
534
535    /// Classifies the ids the request constrains its data to: its template
536    /// ids when it names any, else its archetype ids. Every one must map, and
537    /// every class the request reads must be bound, or the access is
538    /// unclassified beside what did map.
539    fn queried(&self, evidence: &Evidence, classified: &mut Classification) {
540        classified
541            .templates
542            .extend(evidence.queried_templates.iter().cloned());
543        classified
544            .archetypes
545            .extend(evidence.queried_archetypes.iter().cloned());
546        let (ids, table) = if evidence.queried_templates.is_empty() {
547            (&evidence.queried_archetypes, Table::Archetypes)
548        } else {
549            (&evidence.queried_templates, Table::Templates)
550        };
551        if ids.is_empty() {
552            classified.unclassify(Unclassified::NamedNothing);
553            return;
554        }
555        for id in ids {
556            let found = match table {
557                Table::Templates => self.template(id),
558                Table::Archetypes => self.archetype(id),
559            };
560            if let Some(mapping) = found {
561                classified.add(mapping, Basis::Queried);
562            } else {
563                classified.unmapped.insert(id.clone());
564                classified.unclassify(Unclassified::Unmapped);
565            }
566        }
567        if !evidence.every_root_bound {
568            classified.unclassify(Unclassified::Unbound);
569        }
570    }
571}
572
573impl Classification {
574    /// Counts each evidence set and keeps at most `limit` of its ids, the
575    /// first in order.
576    fn bound(&mut self, limit: Option<usize>) {
577        self.counts = EvidenceCounts {
578            templates: self.templates.len(),
579            archetypes: self.archetypes.len(),
580            versions: self.versions.len(),
581            unmapped: self.unmapped.len(),
582        };
583        if let Some(limit) = limit {
584            for set in [
585                &mut self.templates,
586                &mut self.archetypes,
587                &mut self.versions,
588                &mut self.unmapped,
589            ] {
590                while set.len() > limit {
591                    set.pop_last();
592                }
593            }
594        }
595    }
596
597    /// Marks the access unclassified for `why`, unless a reason is held.
598    fn unclassify(&mut self, why: Unclassified) {
599        if self.unclassified.is_none() {
600            self.unclassified = Some(why);
601        }
602    }
603}