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    #[expect(
169        clippy::unnecessary_wraps,
170        reason = "the build seam is fallible by contract: a full buffer fails here"
171    )]
172    pub fn concept(&mut self, ordinal: Ordinal, concept: &Concept) -> Result<(), BuildError> {
173        self.codes.insert(concept.code.clone(), ordinal.index());
174        self.concepts.insert(ordinal.index(), concept.encode());
175        Ok(())
176    }
177
178    /// Adds designation `index` of concept `ordinal`.
179    ///
180    /// # Errors
181    ///
182    /// Cannot fail today; the signature keeps the door open for a full buffer.
183    #[expect(
184        clippy::unnecessary_wraps,
185        reason = "the build seam is fallible by contract: a full buffer fails here"
186    )]
187    pub fn designation(
188        &mut self,
189        ordinal: Ordinal,
190        index: u32,
191        designation: &Designation,
192    ) -> Result<(), BuildError> {
193        self.designations
194            .insert((ordinal.index(), index), designation.encode());
195        self.designation_uses
196            .insert((ordinal.index(), index), designation.use_ordinal);
197        Ok(())
198    }
199
200    /// Records the acceptability of a designation in a language reference set.
201    ///
202    /// # Errors
203    ///
204    /// Cannot fail today; the signature keeps the door open for a full buffer.
205    #[expect(
206        clippy::unnecessary_wraps,
207        reason = "the build seam is fallible by contract: a full buffer fails here"
208    )]
209    pub fn acceptability(
210        &mut self,
211        ordinal: Ordinal,
212        index: u32,
213        language_refset: u32,
214        acceptability: u32,
215    ) -> Result<(), BuildError> {
216        self.acceptability
217            .insert((ordinal.index(), index, language_refset), acceptability);
218        Ok(())
219    }
220
221    /// Sets the values of property `key` on concept `ordinal`.
222    ///
223    /// # Errors
224    ///
225    /// Cannot fail today; the signature keeps the door open for a full buffer.
226    #[expect(
227        clippy::unnecessary_wraps,
228        reason = "the build seam is fallible by contract: a full buffer fails here"
229    )]
230    pub fn properties(
231        &mut self,
232        ordinal: Ordinal,
233        key: u32,
234        values: &[PropertyValue],
235    ) -> Result<(), BuildError> {
236        self.properties
237            .insert((ordinal.index(), key), PropertyValue::encode_list(values));
238        Ok(())
239    }
240
241    /// Writes every buffered row in key order, computes the preferred
242    /// designations per language reference set and use, records the concept
243    /// count, and commits.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`BuildError`] when a table cannot be written or the commit fails.
248    pub fn finish(self, rule: &PreferredRule) -> Result<PathBuf, BuildError> {
249        let txn = self.db.begin_write()?;
250        {
251            let mut meta = txn.open_table(tables::META)?;
252            meta.insert(tables::META_LAYOUT, tables::LAYOUT_VERSION)?;
253            meta.insert(tables::META_SYSTEM, self.system.as_str())?;
254            meta.insert(tables::META_VERSION, self.version.as_str())?;
255            meta.insert(
256                tables::META_CONCEPTS,
257                self.concepts.len().to_string().as_str(),
258            )?;
259        }
260        {
261            let mut codes = txn.open_table(tables::CODES)?;
262            for (code, ordinal) in &self.codes {
263                codes.insert(code.as_str(), *ordinal)?;
264            }
265        }
266        {
267            let mut concepts = txn.open_table(tables::CONCEPTS)?;
268            for (ordinal, bytes) in &self.concepts {
269                concepts.insert(*ordinal, bytes.as_slice())?;
270            }
271        }
272        {
273            let mut designations = txn.open_table(tables::DESIGNATIONS)?;
274            for (key, bytes) in &self.designations {
275                designations.insert(*key, bytes.as_slice())?;
276            }
277        }
278        {
279            let mut acceptability = txn.open_table(tables::ACCEPTABILITY)?;
280            for (key, value) in &self.acceptability {
281                acceptability.insert(*key, *value)?;
282            }
283        }
284        {
285            let mut properties = txn.open_table(tables::PROPERTIES)?;
286            for (key, bytes) in &self.properties {
287                properties.insert(*key, bytes.as_slice())?;
288            }
289        }
290        for table_def in [
291            tables::PROPERTY_KEYS,
292            tables::DESIGNATION_USES,
293            tables::LANGUAGE_REFSETS,
294            tables::ACCEPTABILITIES,
295        ] {
296            let mut table = txn.open_table(table_def)?;
297            if let Some(entries) = self.vocabularies.get(table_def.name()) {
298                for (ordinal, name) in entries {
299                    table.insert(*ordinal, name.as_str())?;
300                }
301            }
302        }
303        {
304            // The preferred designation per (concept, refset, use): the lowest
305            // index among those the refset marks preferred.
306            let mut chosen: BTreeMap<(u32, u32, u32), u32> = BTreeMap::new();
307            for ((concept, index, refset), acceptability) in &self.acceptability {
308                if *acceptability != rule.preferred {
309                    continue;
310                }
311                let Some(use_ordinal) = self.designation_uses.get(&(*concept, *index)) else {
312                    continue;
313                };
314                chosen
315                    .entry((*concept, *refset, *use_ordinal))
316                    .and_modify(|existing| *existing = (*existing).min(*index))
317                    .or_insert(*index);
318            }
319            let mut preferred = txn.open_table(tables::PREFERRED)?;
320            for (key, index) in &chosen {
321                preferred.insert(*key, *index)?;
322            }
323        }
324        txn.commit()?;
325        // redb grows the file in regions ahead of use; compaction returns the
326        // unused pages so the artifact is the size of its data. Repeated until
327        // redb reports nothing further to reclaim.
328        let mut db = self.db;
329        while db.compact()? {}
330        drop(db);
331        Ok(self.path)
332    }
333}