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: an id that differs by case,
15//! space or a specialisation is unmapped. Whatever is uncertain marks the
16//! access [`Classification::unclassified`] with its ids as evidence, and the
17//! access is never refused for it. No specification governs the
18//! classification: our own design.
19
20use std::collections::{BTreeMap, BTreeSet};
21use std::fmt;
22
23use crate::category::Category;
24use crate::map::{CategoryMap, Mapping, Table};
25
26/// What a category of an access was read from.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28#[non_exhaustive]
29pub enum Basis {
30    /// A root object the access delivered or read.
31    Returned,
32    /// A root object the access wrote.
33    Written,
34    /// An id the request constrains its data to, where it carries no root
35    /// object.
36    Queried,
37    /// The kind of request, which serves one category by construction.
38    Construction,
39}
40
41impl Basis {
42    /// The basis as a record writes it.
43    #[must_use]
44    pub fn code(self) -> &'static str {
45        match self {
46            Self::Returned => "returned",
47            Self::Written => "written",
48            Self::Queried => "queried",
49            Self::Construction => "construction",
50        }
51    }
52}
53
54/// One root object an access reached, named by its model ids: never a value
55/// it holds.
56#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
57pub struct RootObject {
58    /// `archetype_details.template_id`, when it names one.
59    pub template_id: Option<String>,
60    /// `archetype_details.archetype_id`, or the archetype node id of a root.
61    pub archetype_id: Option<String>,
62    /// The `OBJECT_VERSION_ID` of the version the object is, when known.
63    pub version_uid: Option<String>,
64}
65
66impl fmt::Debug for RootObject {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.debug_struct("RootObject")
69            .field("template_id", &self.template_id.is_some())
70            .field("archetype_id", &self.archetype_id.is_some())
71            .field("version_uid", &self.version_uid.is_some())
72            .finish()
73    }
74}
75
76/// What an access shows of the data it reached.
77#[derive(Clone, Default, PartialEq, Eq)]
78pub struct Evidence {
79    reached: Option<Basis>,
80    objects: Vec<RootObject>,
81    queried_templates: BTreeSet<String>,
82    queried_archetypes: BTreeSet<String>,
83    every_root_bound: bool,
84    unrooted: bool,
85    no_category: bool,
86    unreadable: Option<Unreadable>,
87    constructed: BTreeSet<Category>,
88}
89
90/// The ids a request constrains its data to, read from its syntax tree.
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct Queried {
93    /// The template ids.
94    pub templates: BTreeSet<String>,
95    /// The archetype ids.
96    pub archetypes: BTreeSet<String>,
97    /// Whether every class the request reads data from is bound to one of
98    /// them; a request the reader could not bind is never of no category.
99    pub every_root_bound: bool,
100}
101
102impl Evidence {
103    /// The evidence of an access that reached `objects` as `basis`
104    /// ([`Basis::Returned`] or [`Basis::Written`]).
105    #[must_use]
106    pub fn reached(basis: Basis, objects: Vec<RootObject>) -> Self {
107        Self {
108            reached: Some(basis),
109            objects,
110            ..Self::default()
111        }
112    }
113
114    /// The evidence of an access to a resource that holds no data of any
115    /// category, such as an `EHR` or an `EHR_STATUS`.
116    #[must_use]
117    pub fn no_category() -> Self {
118        Self {
119            no_category: true,
120            ..Self::default()
121        }
122    }
123
124    /// The evidence of an access whose data could not be read for their
125    /// ids, for the reason `why`.
126    #[must_use]
127    pub fn unreadable(why: Unreadable) -> Self {
128        Self {
129            unreadable: Some(why),
130            ..Self::default()
131        }
132    }
133
134    /// This evidence, the request constraining its data as `queried` says.
135    #[must_use]
136    pub fn queried(mut self, queried: Queried) -> Self {
137        self.queried_templates = queried.templates;
138        self.queried_archetypes = queried.archetypes;
139        self.every_root_bound = queried.every_root_bound;
140        self
141    }
142
143    /// This evidence, the access having delivered a value that is no root
144    /// object, such as a leaf value or an aggregate, whose data the request's
145    /// constraints then classify beside the root objects.
146    #[must_use]
147    pub fn with_unrooted(mut self) -> Self {
148        self.unrooted = true;
149        self
150    }
151
152    /// This evidence, the request serving `category` by construction, such
153    /// as a request for a patient summary, whatever its data show: the
154    /// classification holds `category` on the basis [`Basis::Construction`]
155    /// beside every category the data show.
156    #[must_use]
157    pub fn constructed(mut self, category: Category) -> Self {
158        self.constructed.insert(category);
159        self
160    }
161
162    /// The root objects the access reached.
163    #[must_use]
164    pub fn objects(&self) -> &[RootObject] {
165        &self.objects
166    }
167}
168
169impl fmt::Debug for Evidence {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.debug_struct("Evidence")
172            .field("reached", &self.reached)
173            .field("objects", &self.objects.len())
174            .field("queried_templates", &self.queried_templates.len())
175            .field("queried_archetypes", &self.queried_archetypes.len())
176            .field("every_root_bound", &self.every_root_bound)
177            .field("unrooted", &self.unrooted)
178            .field("no_category", &self.no_category)
179            .field("unreadable", &self.unreadable.is_some())
180            .field("constructed", &self.constructed)
181            .finish()
182    }
183}
184
185/// Why an access is unclassified.
186#[derive(Debug, Clone, PartialEq, Eq)]
187#[non_exhaustive]
188pub enum Unclassified {
189    /// A root object, or every id the request names, maps to no key.
190    Unmapped,
191    /// The access carries no root object and the request names no id.
192    NamedNothing,
193    /// The request reads a class bound to no id, so what it read is not
194    /// known.
195    Unbound,
196    /// The data could not be read for their ids, for the reason given.
197    Unreadable(Unreadable),
198}
199
200impl Unclassified {
201    /// The reason as a record writes it: `unmapped`, `named-nothing`,
202    /// `unbound`, or the code of the [`Unreadable`] reason.
203    #[must_use]
204    pub fn code(&self) -> &'static str {
205        match self {
206            Self::Unmapped => "unmapped",
207            Self::NamedNothing => "named-nothing",
208            Self::Unbound => "unbound",
209            Self::Unreadable(why) => why.code(),
210        }
211    }
212}
213
214/// Why the data of an access could not be read for their ids.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
216#[non_exhaustive]
217pub enum Unreadable {
218    /// The recording system does not read the data of this kind of
219    /// operation: `operation-not-read`.
220    Operation,
221    /// The body is in a format the recording system does not read:
222    /// `format-not-read`.
223    Format,
224    /// The body is in a format the recording system reads, but holds no
225    /// root object it can name: `body-not-read`.
226    Body,
227    /// The access returned no object: `no-object-returned`.
228    NoObject,
229}
230
231impl Unreadable {
232    /// The reason as a record writes it.
233    #[must_use]
234    pub fn code(self) -> &'static str {
235        match self {
236            Self::Operation => "operation-not-read",
237            Self::Format => "format-not-read",
238            Self::Body => "body-not-read",
239            Self::NoObject => "no-object-returned",
240        }
241    }
242}
243
244/// The categories of one access, their bases, and the evidence.
245///
246/// Only [`CategoryMap::classify`] makes one, and every one it makes holds a
247/// category, is of no category by an exact `none` or a resource kind, or is
248/// [`unclassified`](Classification::unclassified) with its reason: it has no
249/// `Default`, so no empty classification can stand in for a missing one.
250///
251/// `Debug` shows the categories and the marks, never an id: a template id
252/// tied to a patient says what kind of care they had.
253#[derive(Clone, PartialEq, Eq)]
254pub struct Classification {
255    categories: BTreeMap<Category, BTreeSet<Basis>>,
256    no_category: bool,
257    unclassified: Option<Unclassified>,
258    templates: BTreeSet<String>,
259    archetypes: BTreeSet<String>,
260    versions: BTreeSet<String>,
261    unmapped: BTreeSet<String>,
262    digest: Option<String>,
263}
264
265impl fmt::Debug for Classification {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        f.debug_struct("Classification")
268            .field("categories", &self.categories)
269            .field("no_category", &self.no_category)
270            .field("unclassified", &self.unclassified.as_ref().map(|_| "set"))
271            .field("templates", &self.templates.len())
272            .field("archetypes", &self.archetypes.len())
273            .field("versions", &self.versions.len())
274            .field("unmapped", &self.unmapped.len())
275            .finish_non_exhaustive()
276    }
277}
278
279impl Classification {
280    /// The categories, each with what it was read from.
281    #[must_use]
282    pub fn categories(&self) -> &BTreeMap<Category, BTreeSet<Basis>> {
283        &self.categories
284    }
285
286    /// Whether the access reached a resource of no category, or only data
287    /// the map declares `none`, and nothing unclassified.
288    #[must_use]
289    pub fn is_no_category(&self) -> bool {
290        self.no_category && self.categories.is_empty() && self.unclassified.is_none()
291    }
292
293    /// Why the access, or a part of it, is unclassified; `None` when the
294    /// map classified all of it.
295    #[must_use]
296    pub fn unclassified(&self) -> Option<&Unclassified> {
297        self.unclassified.as_ref()
298    }
299
300    /// The template ids of the evidence.
301    #[must_use]
302    pub fn templates(&self) -> &BTreeSet<String> {
303        &self.templates
304    }
305
306    /// The archetype ids of the evidence.
307    #[must_use]
308    pub fn archetypes(&self) -> &BTreeSet<String> {
309        &self.archetypes
310    }
311
312    /// The version uids of the root objects the access reached.
313    #[must_use]
314    pub fn versions(&self) -> &BTreeSet<String> {
315        &self.versions
316    }
317
318    /// The ids the map holds no key for.
319    #[must_use]
320    pub fn unmapped(&self) -> &BTreeSet<String> {
321        &self.unmapped
322    }
323
324    /// The digest of the map the access was classified under.
325    #[must_use]
326    pub fn digest(&self) -> Option<&str> {
327        self.digest.as_deref()
328    }
329
330    fn add(&mut self, mapping: &Mapping, basis: Basis) {
331        match mapping {
332            Mapping::NoCategory => self.no_category = true,
333            Mapping::Categories(categories) => {
334                for category in categories {
335                    self.categories
336                        .entry(category.clone())
337                        .or_default()
338                        .insert(basis);
339                }
340            }
341        }
342    }
343}
344
345impl CategoryMap {
346    /// The categories of an access that showed `evidence`.
347    ///
348    /// The categories of every root object and every queried id are joined,
349    /// and `none` never removes one. The access is of no category only when
350    /// its resource kind holds none, or when the map declares `none` by an
351    /// exact key for everything it reached and nothing is uncertain; it is
352    /// unclassified otherwise.
353    #[must_use]
354    pub fn classify(&self, evidence: &Evidence) -> Classification {
355        let mut classified = Classification {
356            categories: BTreeMap::new(),
357            no_category: evidence.no_category,
358            unclassified: None,
359            templates: BTreeSet::new(),
360            archetypes: BTreeSet::new(),
361            versions: BTreeSet::new(),
362            unmapped: BTreeSet::new(),
363            digest: self.digest().map(str::to_owned),
364        };
365        if evidence.no_category {
366            return classified;
367        }
368        if let Some(why) = evidence.unreadable {
369            classified.unclassify(Unclassified::Unreadable(why));
370        }
371        if let Some(basis) = evidence.reached {
372            for object in &evidence.objects {
373                self.object(object, basis, &mut classified);
374            }
375        }
376        for category in &evidence.constructed {
377            classified
378                .categories
379                .entry(category.clone())
380                .or_default()
381                .insert(Basis::Construction);
382        }
383        if evidence.objects.is_empty() || evidence.unrooted {
384            self.queried(evidence, &mut classified);
385        }
386        // NOTE: no specification governs this: our own design; a result with no category,
387        // no exact `none` and no reason is a path the classifier missed, so it fails closed.
388        if classified.categories.is_empty() && !classified.no_category {
389            classified.unclassify(Unclassified::NamedNothing);
390        }
391        classified
392    }
393
394    /// Classifies one root object into `classified`: its template key, else
395    /// its archetype key, else it is unmapped. An archetype's `none` does not
396    /// stand for a template the map does not hold.
397    fn object(&self, object: &RootObject, basis: Basis, classified: &mut Classification) {
398        classified.templates.extend(object.template_id.clone());
399        classified.archetypes.extend(object.archetype_id.clone());
400        classified.versions.extend(object.version_uid.clone());
401        let template = object
402            .template_id
403            .as_deref()
404            .map(|template| self.template(template));
405        let archetype = object
406            .archetype_id
407            .as_deref()
408            .and_then(|archetype| self.archetype(archetype));
409        let mapping = match (template, archetype) {
410            (Some(Some(mapping)), _) | (None | Some(None), Some(mapping))
411                if !matches!((template, mapping), (Some(None), Mapping::NoCategory)) =>
412            {
413                Some(mapping)
414            }
415            _ => None,
416        };
417        if let Some(mapping) = mapping {
418            classified.add(mapping, basis);
419            return;
420        }
421        classified.unmapped.extend(
422            object
423                .template_id
424                .iter()
425                .chain(&object.archetype_id)
426                .cloned(),
427        );
428        classified.unclassify(Unclassified::Unmapped);
429    }
430
431    /// Classifies the ids the request constrains its data to: its template
432    /// ids when it names any, else its archetype ids. Every one must map, and
433    /// every class the request reads must be bound, or the access is
434    /// unclassified beside what did map.
435    fn queried(&self, evidence: &Evidence, classified: &mut Classification) {
436        classified
437            .templates
438            .extend(evidence.queried_templates.iter().cloned());
439        classified
440            .archetypes
441            .extend(evidence.queried_archetypes.iter().cloned());
442        let (ids, table) = if evidence.queried_templates.is_empty() {
443            (&evidence.queried_archetypes, Table::Archetypes)
444        } else {
445            (&evidence.queried_templates, Table::Templates)
446        };
447        if ids.is_empty() {
448            classified.unclassify(Unclassified::NamedNothing);
449            return;
450        }
451        for id in ids {
452            let found = match table {
453                Table::Templates => self.template(id),
454                Table::Archetypes => self.archetype(id),
455            };
456            if let Some(mapping) = found {
457                classified.add(mapping, Basis::Queried);
458            } else {
459                classified.unmapped.insert(id.clone());
460                classified.unclassify(Unclassified::Unmapped);
461            }
462        }
463        if !evidence.every_root_bound {
464            classified.unclassify(Unclassified::Unbound);
465        }
466    }
467}
468
469impl Classification {
470    /// Marks the access unclassified for `why`, unless a reason is held.
471    fn unclassify(&mut self, why: Unclassified) {
472        if self.unclassified.is_none() {
473            self.unclassified = Some(why);
474        }
475    }
476}