Skip to main content

concept_store/
builder.rs

1//! Writing an artifact, offline, in one transaction.
2//!
3//! The build tool feeds concepts, designations, acceptability, properties, and
4//! nothing else; `finish` computes the preferred designations and
5//! commits. Two builds from the same input produce the same bytes.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use concept_graph::ordinal::Ordinal;
11use redb::{Database, TableHandle};
12
13use crate::record::{Concept, Designation, PropertyValue};
14use crate::tables;
15
16/// A failure while building.
17#[derive(Debug, thiserror::Error)]
18pub enum BuildError {
19    /// The database could not be created.
20    #[error("cannot create the artifact at {path}")]
21    Create {
22        /// The artifact.
23        path: PathBuf,
24        /// The underlying error.
25        #[source]
26        source: redb::DatabaseError,
27    },
28    /// A transaction failed.
29    #[error("transaction failed")]
30    Transaction(#[from] redb::TransactionError),
31    /// A table could not be opened.
32    #[error("cannot open table")]
33    Table(#[from] redb::TableError),
34    /// A write failed inside the database.
35    #[error("storage write failed")]
36    Storage(#[from] redb::StorageError),
37    /// The commit failed.
38    #[error("commit failed")]
39    Commit(#[from] redb::CommitError),
40    /// Reclaiming the unused pages after the commit failed.
41    #[error("compaction failed")]
42    Compaction(#[from] redb::CompactionError),
43    /// A vocabulary name was registered twice with different ordinals.
44    #[error("{kind} {name:?} is already ordinal {existing}, not {requested}")]
45    Vocabulary {
46        /// The vocabulary.
47        kind: String,
48        /// The name.
49        name: String,
50        /// The ordinal it already has.
51        existing: u32,
52        /// The ordinal requested.
53        requested: u32,
54    },
55}
56
57/// The acceptability that marks a designation as preferred in a language
58/// reference set, as the code system spells it (for SNOMED, the SCTID of
59/// `|Preferred|`).
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct PreferredRule {
62    /// The acceptability ordinal meaning preferred.
63    pub preferred: u32,
64}
65
66/// An artifact under construction.
67///
68/// Rows are buffered in memory, sorted by key, and written in `finish`, each
69/// table opened once. redb fills pages tightly when keys arrive in order, and
70/// opening a table is not free; the first build wrote every row through its
71/// own `open_table` in arrival order and took ten minutes for the NL edition.
72pub struct StoreBuilder {
73    path: PathBuf,
74    db: Database,
75    system: String,
76    version: String,
77    vocabularies: BTreeMap<&'static str, BTreeMap<u32, String>>,
78    codes: BTreeMap<String, u32>,
79    concepts: BTreeMap<u32, Vec<u8>>,
80    designations: BTreeMap<(u32, u32), Vec<u8>>,
81    acceptability: BTreeMap<(u32, u32, u32), u32>,
82    designation_uses: BTreeMap<(u32, u32), u32>,
83    properties: BTreeMap<(u32, u32), Vec<u8>>,
84}
85
86impl std::fmt::Debug for StoreBuilder {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("StoreBuilder")
89            .field("path", &self.path)
90            .field("concepts", &self.concepts.len())
91            .field("designations", &self.designations.len())
92            .finish_non_exhaustive()
93    }
94}
95
96impl StoreBuilder {
97    /// Creates the artifact at `path`, replacing any file there, and records
98    /// the layout version, system, and version.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`BuildError`] when the file cannot be created.
103    pub fn create(path: &Path, system: &str, version: &str) -> Result<Self, BuildError> {
104        if path.exists() {
105            std::fs::remove_file(path).map_err(|source| BuildError::Create {
106                path: path.to_path_buf(),
107                source: redb::DatabaseError::Storage(redb::StorageError::Io(source)),
108            })?;
109        }
110        let db = Database::create(path).map_err(|source| BuildError::Create {
111            path: path.to_path_buf(),
112            source,
113        })?;
114        Ok(Self {
115            path: path.to_path_buf(),
116            db,
117            system: system.to_owned(),
118            version: version.to_owned(),
119            vocabularies: BTreeMap::new(),
120            codes: BTreeMap::new(),
121            concepts: BTreeMap::new(),
122            designations: BTreeMap::new(),
123            acceptability: BTreeMap::new(),
124            designation_uses: BTreeMap::new(),
125            properties: BTreeMap::new(),
126        })
127    }
128
129    /// Names a vocabulary ordinal (a property key, designation use, language
130    /// reference set, or acceptability).
131    ///
132    /// # Errors
133    ///
134    /// Returns [`BuildError::Vocabulary`] when `name` already has another ordinal.
135    pub fn vocabulary(
136        &mut self,
137        kind: crate::store::Vocabulary,
138        ordinal: u32,
139        name: &str,
140    ) -> Result<(), BuildError> {
141        let table_name = match kind {
142            crate::store::Vocabulary::PropertyKeys => tables::PROPERTY_KEYS.name(),
143            crate::store::Vocabulary::DesignationUses => tables::DESIGNATION_USES.name(),
144            crate::store::Vocabulary::LanguageRefsets => tables::LANGUAGE_REFSETS.name(),
145            crate::store::Vocabulary::Acceptabilities => tables::ACCEPTABILITIES.name(),
146        };
147        let entries = self.vocabularies.entry(table_name).or_default();
148        if let Some((existing, _)) = entries
149            .iter()
150            .find(|(key, value)| value.as_str() == name && **key != ordinal)
151        {
152            return Err(BuildError::Vocabulary {
153                kind: table_name.to_owned(),
154                name: name.to_owned(),
155                existing: *existing,
156                requested: ordinal,
157            });
158        }
159        entries.insert(ordinal, name.to_owned());
160        Ok(())
161    }
162
163    /// Adds a concept under `ordinal`.
164    ///
165    /// # Errors
166    ///
167    /// Cannot fail today; the signature keeps the door open for a full buffer.
168    pub fn concept(&mut self, ordinal: Ordinal, concept: &Concept) -> Result<(), BuildError> {
169        self.codes.insert(concept.code.clone(), ordinal.index());
170        self.concepts.insert(ordinal.index(), concept.encode());
171        Ok(())
172    }
173
174    /// Adds designation `index` of concept `ordinal`.
175    ///
176    /// # Errors
177    ///
178    /// Cannot fail today; the signature keeps the door open for a full buffer.
179    pub fn designation(
180        &mut self,
181        ordinal: Ordinal,
182        index: u32,
183        designation: &Designation,
184    ) -> Result<(), BuildError> {
185        self.designations
186            .insert((ordinal.index(), index), designation.encode());
187        self.designation_uses
188            .insert((ordinal.index(), index), designation.use_ordinal);
189        Ok(())
190    }
191
192    /// Records the acceptability of a designation in a language reference set.
193    ///
194    /// # Errors
195    ///
196    /// Cannot fail today; the signature keeps the door open for a full buffer.
197    pub fn acceptability(
198        &mut self,
199        ordinal: Ordinal,
200        index: u32,
201        language_refset: u32,
202        acceptability: u32,
203    ) -> Result<(), BuildError> {
204        self.acceptability
205            .insert((ordinal.index(), index, language_refset), acceptability);
206        Ok(())
207    }
208
209    /// Sets the values of property `key` on concept `ordinal`.
210    ///
211    /// # Errors
212    ///
213    /// Cannot fail today; the signature keeps the door open for a full buffer.
214    pub fn properties(
215        &mut self,
216        ordinal: Ordinal,
217        key: u32,
218        values: &[PropertyValue],
219    ) -> Result<(), BuildError> {
220        self.properties
221            .insert((ordinal.index(), key), PropertyValue::encode_list(values));
222        Ok(())
223    }
224
225    /// Writes every buffered row in key order, computes the preferred
226    /// designations per language reference set and use, records the concept
227    /// count, and commits.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`BuildError`] when a table cannot be written or the commit fails.
232    pub fn finish(self, rule: &PreferredRule) -> Result<PathBuf, BuildError> {
233        let txn = self.db.begin_write()?;
234        {
235            let mut meta = txn.open_table(tables::META)?;
236            meta.insert(tables::META_LAYOUT, tables::LAYOUT_VERSION)?;
237            meta.insert(tables::META_SYSTEM, self.system.as_str())?;
238            meta.insert(tables::META_VERSION, self.version.as_str())?;
239            meta.insert(
240                tables::META_CONCEPTS,
241                self.concepts.len().to_string().as_str(),
242            )?;
243        }
244        {
245            let mut codes = txn.open_table(tables::CODES)?;
246            for (code, ordinal) in &self.codes {
247                codes.insert(code.as_str(), *ordinal)?;
248            }
249        }
250        {
251            let mut concepts = txn.open_table(tables::CONCEPTS)?;
252            for (ordinal, bytes) in &self.concepts {
253                concepts.insert(*ordinal, bytes.as_slice())?;
254            }
255        }
256        {
257            let mut designations = txn.open_table(tables::DESIGNATIONS)?;
258            for (key, bytes) in &self.designations {
259                designations.insert(*key, bytes.as_slice())?;
260            }
261        }
262        {
263            let mut acceptability = txn.open_table(tables::ACCEPTABILITY)?;
264            for (key, value) in &self.acceptability {
265                acceptability.insert(*key, *value)?;
266            }
267        }
268        {
269            let mut properties = txn.open_table(tables::PROPERTIES)?;
270            for (key, bytes) in &self.properties {
271                properties.insert(*key, bytes.as_slice())?;
272            }
273        }
274        for table_def in [
275            tables::PROPERTY_KEYS,
276            tables::DESIGNATION_USES,
277            tables::LANGUAGE_REFSETS,
278            tables::ACCEPTABILITIES,
279        ] {
280            let mut table = txn.open_table(table_def)?;
281            if let Some(entries) = self.vocabularies.get(table_def.name()) {
282                for (ordinal, name) in entries {
283                    table.insert(*ordinal, name.as_str())?;
284                }
285            }
286        }
287        {
288            // The preferred designation per (concept, refset, use): the lowest
289            // index among those the refset marks preferred.
290            let mut chosen: BTreeMap<(u32, u32, u32), u32> = BTreeMap::new();
291            for ((concept, index, refset), acceptability) in &self.acceptability {
292                if *acceptability != rule.preferred {
293                    continue;
294                }
295                let Some(use_ordinal) = self.designation_uses.get(&(*concept, *index)) else {
296                    continue;
297                };
298                chosen
299                    .entry((*concept, *refset, *use_ordinal))
300                    .and_modify(|existing| *existing = (*existing).min(*index))
301                    .or_insert(*index);
302            }
303            let mut preferred = txn.open_table(tables::PREFERRED)?;
304            for (key, index) in &chosen {
305                preferred.insert(*key, *index)?;
306            }
307        }
308        txn.commit()?;
309        // redb grows the file in regions ahead of use; compaction returns the
310        // unused pages so the artifact is the size of its data. Repeated until
311        // redb reports nothing further to reclaim.
312        let mut db = self.db;
313        while db.compact()? {}
314        drop(db);
315        Ok(self.path)
316    }
317}