Skip to main content

snomed_ecl_engine/store/
descriptions.rs

1use super::columns::{Column, RowSets};
2use super::term_storage::TermStorage;
3use super::*;
4use std::sync::OnceLock;
5
6const MAGIC: &[u8; 8] = b"SNDES001";
7
8#[derive(Clone, Debug, Serialize, Deserialize)]
9pub struct DescriptionManifest {
10    pub bytes: u64,
11    pub sha256: String,
12    pub descriptions: usize,
13    pub active_descriptions: usize,
14    pub language_memberships: usize,
15}
16
17/// Input to the offline builder. Terms are packed into one UTF-8 buffer on build.
18#[derive(Debug)]
19pub struct Description {
20    pub id: u64,
21    pub concept: u32,
22    pub module: u32,
23    pub kind: u32,
24    pub effective_time: u32,
25    pub active: bool,
26    pub language: [u8; 2],
27    pub term: String,
28    /// Active language members: (refset ordinal, acceptability ordinal).
29    pub dialects: Vec<(u32, u32)>,
30}
31
32#[derive(Debug, Default)]
33pub struct DescriptionIndex {
34    pub(super) concepts: Vec<u32>,
35    ids: Vec<u64>,
36    modules: Column,
37    kinds: Column,
38    dates: Column,
39    flags: Column,
40    term_offsets: Vec<u32>,
41    terms: TermStorage,
42    dialects: RowSets,
43}
44
45impl DescriptionIndex {
46    pub fn build(count: usize, mut descriptions: Vec<Description>) -> Result<Self> {
47        descriptions.sort_unstable_by_key(|d| (d.concept, d.id));
48        let mut index = Self {
49            concepts: vec![0; count + 1],
50            term_offsets: vec![0],
51            ..Self::default()
52        };
53        let (mut modules, mut kinds, mut dates, mut flags) =
54            (Vec::new(), Vec::new(), Vec::new(), Vec::new());
55        let (mut dialect_offsets, mut dialects, mut terms) = (vec![0], Vec::new(), String::new());
56        let mut ids = std::collections::HashSet::new();
57        for mut d in descriptions {
58            ensure!(ids.insert(d.id), "Duplicate description ID");
59            ensure!((d.concept as usize) < count, "Unknown description concept");
60            index.concepts[d.concept as usize + 1] += 1;
61            index.ids.push(d.id);
62            modules.push(d.module);
63            kinds.push(d.kind);
64            dates.push(d.effective_time);
65            flags.push(u16::from_le_bytes(d.language) as u32 | (u32::from(d.active) << 16));
66            terms.push_str(&d.term);
67            index.term_offsets.push(u32::try_from(terms.len())?);
68            d.dialects.sort_unstable();
69            d.dialects.dedup();
70            for (refset, acceptability) in d.dialects {
71                dialects.extend([refset, acceptability]);
72            }
73            dialect_offsets.push(u32::try_from(dialects.len())?);
74        }
75        index.modules = Column::new(modules);
76        index.kinds = Column::new(kinds);
77        index.dates = Column::new(dates);
78        index.flags = Column::new(flags);
79        index.dialects = RowSets::new(dialect_offsets, dialects, index.len())?;
80        index.terms = TermStorage::Owned(terms);
81        prefix_sum(&mut index.concepts)?;
82        index.validate(count)?;
83        Ok(index)
84    }
85
86    pub fn len(&self) -> usize {
87        self.ids.len()
88    }
89    pub fn is_empty(&self) -> bool {
90        self.ids.is_empty()
91    }
92    pub fn for_concept(&self, concept: u32) -> std::ops::Range<usize> {
93        self.concepts[concept as usize] as usize..self.concepts[concept as usize + 1] as usize
94    }
95    pub fn id(&self, row: usize) -> u64 {
96        self.ids[row]
97    }
98    pub fn module(&self, row: usize) -> u32 {
99        self.modules.get(row)
100    }
101    pub fn kind(&self, row: usize) -> u32 {
102        self.kinds.get(row)
103    }
104    pub fn effective_time(&self, row: usize) -> u32 {
105        self.dates.get(row)
106    }
107    pub fn active(&self, row: usize) -> bool {
108        self.flags.get(row) & (1 << 16) != 0
109    }
110    pub fn language(&self, row: usize) -> [u8; 2] {
111        (self.flags.get(row) as u16).to_le_bytes()
112    }
113    pub fn term(&self, row: usize) -> Result<String> {
114        self.with_term(row, str::to_owned)
115    }
116    pub fn term_bytes(&self, row: usize) -> usize {
117        (self.term_offsets[row + 1] - self.term_offsets[row]) as usize
118    }
119    /// Visits text without allocating a string. The callback must not re-enter this index's text reader.
120    pub fn with_term<T>(&self, row: usize, visit: impl FnOnce(&str) -> T) -> Result<T> {
121        self.terms.with_range(
122            self.term_offsets[row] as usize,
123            self.term_offsets[row + 1] as usize,
124            visit,
125        )
126    }
127    pub fn dialects(&self, row: usize) -> impl Iterator<Item = (u32, u32)> + '_ {
128        self.dialects
129            .get(row)
130            .chunks_exact(2)
131            .map(|pair| (pair[0], pair[1]))
132    }
133
134    fn validate(&self, count: usize) -> Result<()> {
135        let n = self.len();
136        validate_offsets(&self.concepts, count, n)?;
137        validate_offsets(&self.term_offsets, n, self.terms.len())?;
138        self.dialects.validate(count, n)?;
139        ensure!(
140            [
141                self.modules.len(),
142                self.kinds.len(),
143                self.dates.len(),
144                self.flags.len()
145            ]
146            .iter()
147            .all(|&v| v == n),
148            "Description column length mismatch"
149        );
150        ensure!(
151            self.modules
152                .iter()
153                .chain(self.kinds.iter())
154                .all(|v| (v as usize) < count),
155            "Invalid description metadata ordinal"
156        );
157        self.terms.validate(&self.term_offsets)?;
158        ensure!(
159            self.term_offsets.windows(2).all(|w| w[0] < w[1]),
160            "Empty description term"
161        );
162        let mut ids = self.ids.clone();
163        ids.sort_unstable();
164        ensure!(
165            ids.iter()
166                .all(|v| (100_000..1_000_000_000_000_000_000).contains(v))
167                && ids.windows(2).all(|w| w[0] < w[1]),
168            "Invalid or duplicate description ID"
169        );
170        for row in 0..n {
171            ensure!(
172                self.flags.get(row) >> 17 == 0
173                    && self.language(row).iter().all(u8::is_ascii_lowercase),
174                "Invalid description language or flags"
175            );
176        }
177        Ok(())
178    }
179
180    pub fn write(&self, path: &Path) -> Result<DescriptionManifest> {
181        self.validate(self.concepts.len().saturating_sub(1))?;
182        let mut out = BufWriter::new(File::create_new(path)?);
183        out.write_all(MAGIC)?;
184        put_u32s(&mut out, &self.concepts)?;
185        put_u64(&mut out, self.ids.len() as u64)?;
186        for &id in &self.ids {
187            put_u64(&mut out, id)?;
188        }
189        for column in [&self.modules, &self.kinds, &self.dates, &self.flags] {
190            column.write(&mut out)?;
191        }
192        put_u32s(&mut out, &self.term_offsets)?;
193        self.dialects.write(&mut out)?;
194        self.terms.write(&mut out)?;
195        out.flush()?;
196        out.get_ref().sync_all()?;
197        Ok(DescriptionManifest {
198            bytes: path.metadata()?.len(),
199            sha256: sha256(path)?,
200            descriptions: self.len(),
201            active_descriptions: (0..self.len()).filter(|&i| self.active(i)).count(),
202            language_memberships: self.dialects.entries() / 2,
203        })
204    }
205
206    pub(super) fn open(
207        section: &Section,
208        metadata: &DescriptionManifest,
209        count: usize,
210    ) -> Result<Self> {
211        let mut input = Input::open(section, MAGIC)?;
212        let concepts = input.u32s()?;
213        let n = input.count(8)?;
214        let ids = (0..n).map(|_| input.u64()).collect::<Result<_>>()?;
215        let modules = Column::new(input.u32s()?);
216        let kinds = Column::new(input.u32s()?);
217        let dates = Column::new(input.u32s()?);
218        let flags = Column::new(input.u32s()?);
219        let term_offsets = input.u32s()?;
220        let dialects = RowSets::read(&mut input, n)?;
221        let length = input.count(1)?;
222        ensure!(
223            input.remaining == length as u64,
224            "Trailing description bytes"
225        );
226        let start = input.reader.stream_position()?;
227        let index = Self {
228            concepts,
229            ids,
230            modules,
231            kinds,
232            dates,
233            flags,
234            term_offsets,
235            dialects,
236            terms: TermStorage::stored(section.clone(), start, length)?,
237        };
238        index.validate(count)?;
239        ensure!(
240            index.len() == metadata.descriptions
241                && index.dialects.entries() / 2 == metadata.language_memberships
242                && (0..index.len()).filter(|&r| index.active(r)).count()
243                    == metadata.active_descriptions,
244            "Description manifest counts differ"
245        );
246        Ok(index)
247    }
248
249    #[cfg(feature = "import")]
250    pub(crate) fn into_descriptions(self, mapping: &[u32]) -> Result<Vec<Description>> {
251        let mut rows = Vec::with_capacity(self.len());
252        for (concept, &new) in mapping.iter().enumerate() {
253            for row in self.for_concept(concept as u32) {
254                rows.push(Description {
255                    id: self.id(row),
256                    concept: new,
257                    module: mapping[self.module(row) as usize],
258                    kind: mapping[self.kind(row) as usize],
259                    effective_time: self.effective_time(row),
260                    active: self.active(row),
261                    language: self.language(row),
262                    term: self.term(row)?,
263                    dialects: self
264                        .dialects(row)
265                        .map(|(r, a)| (mapping[r as usize], mapping[a as usize]))
266                        .collect(),
267                });
268            }
269        }
270        Ok(rows)
271    }
272}
273
274/// One description, as read for a single concept.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct DescriptionRow {
277    pub id: u64,
278    pub module: u32,
279    pub kind: u32,
280    pub effective_time: u32,
281    pub active: bool,
282    pub language: [u8; 2],
283    pub term: String,
284    /// Active language members: (refset ordinal, acceptability ordinal).
285    pub dialects: Vec<(u32, u32)>,
286}
287
288/// Where each array of the description section starts, so one concept's rows
289/// can be read without loading the rest. Every array is a u64 length followed
290/// by fixed-width values; see `DescriptionIndex::write`.
291#[derive(Debug)]
292struct Layout {
293    count: usize,
294    rows: u64,
295    concepts: u64,
296    ids: u64,
297    /// Modules, kinds, dates and flags, in that order.
298    columns: [u64; 4],
299    term_offsets: u64,
300    dialect_offsets: u64,
301    dialect_values: u64,
302    dialect_entries: u64,
303    terms: u64,
304    term_bytes: u64,
305}
306
307/// Reads a section at positions: without a lock when it is uncompressed,
308/// otherwise through one shared block reader.
309#[derive(Debug)]
310struct Seeker {
311    positional: Option<super::container::PositionalReader>,
312    reader: std::sync::Mutex<SectionReader>,
313    layout: Layout,
314}
315impl Seeker {
316    fn open(section: &Section, count: usize) -> Result<Self> {
317        let mut seeker = Self {
318            positional: section.positional()?,
319            reader: std::sync::Mutex::new(section.reader()?),
320            layout: Layout {
321                count,
322                rows: 0,
323                concepts: 0,
324                ids: 0,
325                columns: [0; 4],
326                term_offsets: 0,
327                dialect_offsets: 0,
328                dialect_values: 0,
329                dialect_entries: 0,
330                terms: 0,
331                term_bytes: 0,
332            },
333        };
334        let mut magic = [0; 8];
335        seeker.read(0, &mut magic)?;
336        ensure!(&magic == MAGIC, "Invalid description section");
337        // Walks the length prefixes, checking each array fits before the next.
338        let mut at = 8u64;
339        let mut array = |seeker: &Self, width: u64, expected: Option<u64>| -> Result<(u64, u64)> {
340            let length = seeker.u64(at)?;
341            ensure!(
342                expected.is_none_or(|e| e == length),
343                "Description array length differs"
344            );
345            let start = at + 8;
346            at = length
347                .checked_mul(width)
348                .and_then(|bytes| start.checked_add(bytes))
349                .filter(|&end| end <= section.length)
350                .context("Description array exceeds its section")?;
351            Ok((start, length))
352        };
353        let (concepts, _) = array(&seeker, 4, Some(count as u64 + 1))?;
354        let (ids, rows) = array(&seeker, 8, None)?;
355        let mut columns = [0; 4];
356        for column in &mut columns {
357            *column = array(&seeker, 4, Some(rows))?.0;
358        }
359        let (term_offsets, _) = array(&seeker, 4, Some(rows + 1))?;
360        let (dialect_offsets, _) = array(&seeker, 4, Some(rows + 1))?;
361        let (dialect_values, dialect_entries) = array(&seeker, 4, None)?;
362        let (terms, term_bytes) = array(&seeker, 1, None)?;
363        ensure!(at == section.length, "Trailing description bytes");
364        seeker.layout = Layout {
365            count,
366            rows,
367            concepts,
368            ids,
369            columns,
370            term_offsets,
371            dialect_offsets,
372            dialect_values,
373            dialect_entries,
374            terms,
375            term_bytes,
376        };
377        Ok(seeker)
378    }
379    fn read(&self, position: u64, bytes: &mut [u8]) -> Result<()> {
380        if let Some(reader) = &self.positional {
381            reader.read_exact_at(position, bytes)?;
382        } else {
383            let mut reader = self
384                .reader
385                .lock()
386                .map_err(|_| anyhow::anyhow!("Description reader poisoned"))?;
387            reader.seek(SeekFrom::Start(position))?;
388            reader.read_exact(bytes)?;
389        }
390        Ok(())
391    }
392    fn u64(&self, position: u64) -> Result<u64> {
393        let mut bytes = [0; 8];
394        self.read(position, &mut bytes)?;
395        Ok(u64::from_le_bytes(bytes))
396    }
397    /// `count` consecutive u32 values of the array at `start`, from `first`.
398    fn u32s(&self, start: u64, first: u64, count: u64) -> Result<Vec<u32>> {
399        let mut bytes = vec![0; usize::try_from(count * 4)?];
400        self.read(start + first * 4, &mut bytes)?;
401        Ok(bytes
402            .chunks_exact(4)
403            .map(|b| u32::from_le_bytes(b.try_into().unwrap()))
404            .collect())
405    }
406    fn rows(&self, concept: u32) -> Result<Vec<DescriptionRow>> {
407        let layout = &self.layout;
408        ensure!(
409            (concept as usize) < layout.count,
410            "Description concept out of range"
411        );
412        let bounds = self.u32s(layout.concepts, concept as u64, 2)?;
413        let (first, last) = (bounds[0] as u64, bounds[1] as u64);
414        ensure!(
415            first <= last && last <= layout.rows,
416            "Invalid description offsets"
417        );
418        let k = last - first;
419        if k == 0 {
420            return Ok(Vec::new());
421        }
422        let mut ids = vec![0; usize::try_from(k * 8)?];
423        self.read(layout.ids + first * 8, &mut ids)?;
424        let [modules, kinds, dates, flags] = layout.columns.map(|at| self.u32s(at, first, k));
425        let (modules, kinds, dates, flags) = (modules?, kinds?, dates?, flags?);
426        let terms = self.u32s(layout.term_offsets, first, k + 1)?;
427        let dialects = self.u32s(layout.dialect_offsets, first, k + 1)?;
428        let (term_start, term_end) = (terms[0] as u64, terms[k as usize] as u64);
429        let (dialect_start, dialect_end) = (dialects[0] as u64, dialects[k as usize] as u64);
430        ensure!(
431            terms.windows(2).all(|w| w[0] < w[1])
432                && term_end <= layout.term_bytes
433                && dialects.windows(2).all(|w| w[0] <= w[1])
434                && dialects.iter().all(|v| v % 2 == 0)
435                && dialect_end <= layout.dialect_entries,
436            "Invalid description text or language offsets"
437        );
438        let mut text = vec![0; usize::try_from(term_end - term_start)?];
439        self.read(layout.terms + term_start, &mut text)?;
440        let members = self.u32s(
441            layout.dialect_values,
442            dialect_start,
443            dialect_end - dialect_start,
444        )?;
445        let count = layout.count as u32;
446        let mut rows = Vec::with_capacity(k as usize);
447        for i in 0..k as usize {
448            let id = u64::from_le_bytes(ids[i * 8..i * 8 + 8].try_into().unwrap());
449            let language = (flags[i] as u16).to_le_bytes();
450            ensure!(
451                modules[i] < count
452                    && kinds[i] < count
453                    && flags[i] >> 17 == 0
454                    && language.iter().all(u8::is_ascii_lowercase)
455                    && (100_000..1_000_000_000_000_000_000).contains(&id),
456                "Invalid description row"
457            );
458            let term = &text[(terms[i] as u64 - term_start) as usize
459                ..(terms[i + 1] as u64 - term_start) as usize];
460            let pairs = &members[(dialects[i] as u64 - dialect_start) as usize
461                ..(dialects[i + 1] as u64 - dialect_start) as usize];
462            ensure!(
463                pairs.iter().all(|&v| v < count),
464                "Invalid description dialect ordinal"
465            );
466            rows.push(DescriptionRow {
467                id,
468                module: modules[i],
469                kind: kinds[i],
470                effective_time: dates[i],
471                active: flags[i] & (1 << 16) != 0,
472                language,
473                term: std::str::from_utf8(term)
474                    .context("Invalid description UTF-8")?
475                    .to_owned(),
476                dialects: pairs.chunks_exact(2).map(|p| (p[0], p[1])).collect(),
477            });
478        }
479        Ok(rows)
480    }
481}
482
483/// The sidecar is opened only when requested. Numeric queries perform no text I/O.
484#[derive(Debug, Default)]
485pub struct DescriptionStore {
486    source: Option<(Section, DescriptionManifest, usize)>,
487    loaded: OnceLock<std::result::Result<DescriptionIndex, String>>,
488    seeker: OnceLock<std::result::Result<Seeker, String>>,
489}
490impl DescriptionStore {
491    pub fn loaded(index: DescriptionIndex) -> Self {
492        Self {
493            source: None,
494            loaded: OnceLock::from(Ok(index)),
495            seeker: OnceLock::new(),
496        }
497    }
498    pub(super) fn lazy(
499        source: &IndexSource,
500        metadata: DescriptionManifest,
501        count: usize,
502    ) -> Result<Self> {
503        Ok(Self {
504            source: Some((source.section("descriptions.bin")?, metadata, count)),
505            loaded: OnceLock::new(),
506            seeker: OnceLock::new(),
507        })
508    }
509    /// Whether this store has descriptions at all.
510    pub fn is_available(&self) -> bool {
511        self.source.is_some() || self.loaded.get().is_some()
512    }
513    /// Whether the whole index is in memory already.
514    pub fn is_loaded(&self) -> bool {
515        matches!(self.loaded.get(), Some(Ok(_)))
516    }
517    /// One concept's descriptions. Reads only that concept's rows unless the
518    /// whole index is already loaded, so describing a concept costs a few
519    /// small reads rather than loading every description in the edition.
520    pub fn concept_rows(&self, concept: u32) -> Result<Option<Vec<DescriptionRow>>> {
521        if let Some(Ok(index)) = self.loaded.get() {
522            return Ok(Some(
523                index
524                    .for_concept(concept)
525                    .map(|row| {
526                        Ok(DescriptionRow {
527                            id: index.id(row),
528                            module: index.module(row),
529                            kind: index.kind(row),
530                            effective_time: index.effective_time(row),
531                            active: index.active(row),
532                            language: index.language(row),
533                            term: index.term(row)?,
534                            dialects: index.dialects(row).collect(),
535                        })
536                    })
537                    .collect::<Result<_>>()?,
538            ));
539        }
540        let Some((section, _, count)) = &self.source else {
541            return Ok(None);
542        };
543        match self
544            .seeker
545            .get_or_init(|| Seeker::open(section, *count).map_err(|e| e.to_string()))
546        {
547            Ok(seeker) => seeker.rows(concept).map(Some),
548            Err(message) => bail!("Description index: {message}"),
549        }
550    }
551    pub fn get(&self) -> Result<Option<&DescriptionIndex>> {
552        if self.source.is_none() && self.loaded.get().is_none() {
553            return Ok(None);
554        }
555        match self.loaded.get_or_init(|| {
556            let (path, metadata, count) = self.source.as_ref().unwrap();
557            DescriptionIndex::open(path, metadata, *count).map_err(|e| e.to_string())
558        }) {
559            Ok(index) => Ok(Some(index)),
560            Err(message) => bail!("Description index: {message}"),
561        }
562    }
563    #[cfg(feature = "import")]
564    pub(crate) fn into_index(mut self) -> Result<Option<DescriptionIndex>> {
565        self.get()?;
566        self.loaded
567            .take()
568            .map(|r| r.map_err(anyhow::Error::msg))
569            .transpose()
570    }
571}