Skip to main content

snomed_ecl_engine/store/
history.rs

1//! Historical association rows, indexed from both ends.
2//!
3//! A retired concept records what it became in one of several association
4//! reference sets: SAME AS, REPLACED BY, POSSIBLY EQUIVALENT TO and so on. The
5//! member tables hold those rows, but only in the order the release shipped
6//! them, so asking "what did this code become" or "which retired codes pointed
7//! here" meant scanning every row of every association table: about 430,000
8//! rows and two binary searches per row, for every question.
9//!
10//! This keeps the same active rows twice, once keyed by the retired concept
11//! and once keyed by the concept it points at. Each direction is a sorted key
12//! list with the rows for each key stored contiguously, so a lookup is one
13//! binary search and a slice.
14use super::*;
15use std::sync::OnceLock;
16
17const MAGIC: &[u8; 8] = b"SNECLH01";
18/// The parent of every historical association reference set.
19pub const ASSOCIATIONS: u64 = 900000000000522004;
20
21#[derive(Debug, Serialize, Deserialize, Clone)]
22#[serde(deny_unknown_fields)]
23pub struct HistoryManifest {
24    pub bytes: u64,
25    pub sha256: String,
26    pub rows: usize,
27    /// The association reference sets the rows came from.
28    pub refsets: Vec<u64>,
29    /// Active rows left out because one end is not a concept in this edition.
30    #[serde(default)]
31    pub skipped: usize,
32}
33
34/// One direction: keys, and for each key the concept at the other end of each
35/// row together with the association that links them.
36#[derive(Debug, Default)]
37struct Keyed {
38    keys: Vec<u32>,
39    offsets: Vec<u32>,
40    others: Vec<u32>,
41    kinds: Vec<u8>,
42}
43
44impl Keyed {
45    fn build(mut rows: Vec<(u32, u32, u8)>) -> Result<Self> {
46        rows.sort_unstable();
47        rows.dedup();
48        let mut keyed = Self {
49            offsets: vec![0],
50            ..Self::default()
51        };
52        for (key, other, kind) in rows {
53            if keyed.keys.last() != Some(&key) {
54                keyed.keys.push(key);
55                keyed.offsets.push(*keyed.offsets.last().expect("seeded"));
56            }
57            keyed.others.push(other);
58            keyed.kinds.push(kind);
59            *keyed.offsets.last_mut().expect("seeded") =
60                u32::try_from(keyed.others.len()).context("History rows exceed u32")?;
61        }
62        Ok(keyed)
63    }
64
65    fn get(&self, key: u32) -> (&[u32], &[u8]) {
66        match self.keys.binary_search(&key) {
67            Ok(i) => {
68                let range = self.offsets[i] as usize..self.offsets[i + 1] as usize;
69                (&self.others[range.clone()], &self.kinds[range])
70            }
71            Err(_) => (&[], &[]),
72        }
73    }
74
75    fn validate(&self, concepts: usize, kinds: usize) -> Result<()> {
76        ensure!(
77            self.offsets.len() == self.keys.len() + 1
78                && self.offsets.first() == Some(&0)
79                && self.offsets.last().map(|&v| v as usize) == Some(self.others.len())
80                && self.others.len() == self.kinds.len(),
81            "History index offsets do not cover their rows"
82        );
83        ensure!(
84            self.offsets.windows(2).all(|w| w[0] < w[1]),
85            "History index has an empty or unordered key"
86        );
87        ensure!(
88            self.keys.windows(2).all(|w| w[0] < w[1]),
89            "History keys are not sorted and unique"
90        );
91        ensure!(
92            self.keys.last().is_none_or(|&k| (k as usize) < concepts)
93                && self.others.iter().all(|&o| (o as usize) < concepts),
94            "History index names a concept outside the store"
95        );
96        ensure!(
97            self.kinds.iter().all(|&k| (k as usize) < kinds),
98            "History index names an unknown association"
99        );
100        Ok(())
101    }
102
103    fn write(&self, out: &mut impl Write) -> Result<()> {
104        put_u32s(out, &self.keys)?;
105        put_u32s(out, &self.offsets)?;
106        put_u32s(out, &self.others)?;
107        put_u64(out, self.kinds.len() as u64)?;
108        out.write_all(&self.kinds)?;
109        Ok(())
110    }
111
112    fn read(input: &mut Input) -> Result<Self> {
113        Ok(Self {
114            keys: input.u32s()?,
115            offsets: input.u32s()?,
116            others: input.u32s()?,
117            kinds: input.bytes()?,
118        })
119    }
120}
121
122/// A row as a caller sees it: the concept at the other end, and which
123/// association links the two.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct Association {
126    pub concept: u32,
127    pub refset: u64,
128}
129
130#[derive(Debug, Default)]
131pub struct HistoryIndex {
132    refsets: Vec<u64>,
133    /// Keyed by the referenced component: this concept points at those.
134    forward: Keyed,
135    /// Keyed by the target: these concepts point at this one.
136    backward: Keyed,
137}
138
139impl HistoryIndex {
140    /// Builds from every active row of every association table in the store.
141    ///
142    /// A row whose referenced component or target is not a concept in this
143    /// edition is left out: REFERS TO, for one, points descriptions at
144    /// concepts, and neither lookup has a concept to key it by.
145    pub fn build(store: &NumericStore) -> Result<(Self, usize)> {
146        let associations: Vec<u64> = store
147            .hierarchy(ASSOCIATIONS, false, false, false)
148            .into_iter()
149            .filter(|refset| store.member_tables.fields(*refset).is_some())
150            .collect();
151        let mut refsets = Vec::new();
152        let mut rows = Vec::new();
153        let mut skipped = 0;
154        for refset in associations {
155            let Some(table) = store.member_tables.get(refset)? else {
156                continue;
157            };
158            let Some(MemberColumn::Id(targets)) = table.column("targetComponentId") else {
159                continue;
160            };
161            let Some(MemberColumn::Id(references)) = table.column("referencedComponentId") else {
162                bail!("Association table {refset} has no referenced components");
163            };
164            let Some(MemberColumn::Boolean(active)) = table.column("active") else {
165                bail!("Association table {refset} has no active flags");
166            };
167            let kind = u8::try_from(refsets.len()).context("More than 255 associations")?;
168            refsets.push(refset);
169            for row in 0..table.len() {
170                if active[row] == 0 {
171                    continue;
172                }
173                match (store.ordinal(references[row]), store.ordinal(targets[row])) {
174                    (Some(source), Some(target)) => rows.push((source, target, kind)),
175                    _ => skipped += 1,
176                }
177            }
178        }
179        let backward = Keyed::build(rows.iter().map(|&(s, t, k)| (t, s, k)).collect())?;
180        let forward = Keyed::build(rows)?;
181        let index = Self {
182            refsets,
183            forward,
184            backward,
185        };
186        index.validate(store.ids.len())?;
187        Ok((index, skipped))
188    }
189
190    pub fn rows(&self) -> usize {
191        self.forward.others.len()
192    }
193
194    pub fn refsets(&self) -> &[u64] {
195        &self.refsets
196    }
197
198    fn collect(&self, (others, kinds): (&[u32], &[u8])) -> Vec<Association> {
199        others
200            .iter()
201            .zip(kinds)
202            .map(|(&concept, &kind)| Association {
203                concept,
204                refset: self.refsets[kind as usize],
205            })
206            .collect()
207    }
208
209    /// What `concept` was retired in favour of, by association.
210    pub fn successors(&self, concept: u32) -> Vec<Association> {
211        self.collect(self.forward.get(concept))
212    }
213
214    /// The retired concepts that point at `concept`, by association.
215    pub fn predecessors(&self, concept: u32) -> Vec<Association> {
216        self.collect(self.backward.get(concept))
217    }
218
219    /// Calls `visit` with each of `concepts` that has rows, and those rows.
220    ///
221    /// `concepts` is sorted, as are the keys, so a large input is merged with
222    /// the key list in one pass while a small one binary-searches it. Either
223    /// way the cost follows whichever of the two lists is shorter.
224    pub(crate) fn walk(
225        &self,
226        backward: bool,
227        concepts: &[u32],
228        mut visit: impl FnMut(u32, &[u32], &[u8]),
229    ) {
230        let keyed = if backward {
231            &self.backward
232        } else {
233            &self.forward
234        };
235        let rows = |i: usize| {
236            let range = keyed.offsets[i] as usize..keyed.offsets[i + 1] as usize;
237            (&keyed.others[range.clone()], &keyed.kinds[range])
238        };
239        if concepts.len().saturating_mul(16) < keyed.keys.len() {
240            for &concept in concepts {
241                if let Ok(i) = keyed.keys.binary_search(&concept) {
242                    let (others, kinds) = rows(i);
243                    visit(concept, others, kinds);
244                }
245            }
246        } else {
247            let (mut a, mut b) = (0, 0);
248            while a < concepts.len() && b < keyed.keys.len() {
249                match concepts[a].cmp(&keyed.keys[b]) {
250                    std::cmp::Ordering::Less => a += 1,
251                    std::cmp::Ordering::Greater => b += 1,
252                    std::cmp::Ordering::Equal => {
253                        let (others, kinds) = rows(b);
254                        visit(concepts[a], others, kinds);
255                        a += 1;
256                        b += 1;
257                    }
258                }
259            }
260        }
261    }
262
263    pub(crate) fn kind_of(&self, refset: u64) -> Option<u8> {
264        self.refsets
265            .iter()
266            .position(|&r| r == refset)
267            .map(|i| i as u8)
268    }
269
270    fn validate(&self, concepts: usize) -> Result<()> {
271        self.forward.validate(concepts, self.refsets.len())?;
272        self.backward.validate(concepts, self.refsets.len())?;
273        ensure!(
274            self.forward.others.len() == self.backward.others.len(),
275            "History directions disagree on the number of rows"
276        );
277        Ok(())
278    }
279
280    pub fn write(&self, path: &Path, skipped: usize) -> Result<HistoryManifest> {
281        let mut out = BufWriter::new(File::create_new(path)?);
282        out.write_all(MAGIC)?;
283        put_u64(&mut out, self.refsets.len() as u64)?;
284        for &refset in &self.refsets {
285            put_u64(&mut out, refset)?;
286        }
287        self.forward.write(&mut out)?;
288        self.backward.write(&mut out)?;
289        out.flush()?;
290        out.get_ref().sync_all()?;
291        Ok(HistoryManifest {
292            bytes: path.metadata()?.len(),
293            sha256: sha256(path)?,
294            rows: self.rows(),
295            refsets: self.refsets.clone(),
296            skipped,
297        })
298    }
299
300    pub(super) fn open(
301        section: &Section,
302        manifest: &HistoryManifest,
303        concepts: usize,
304    ) -> Result<Self> {
305        let mut input = Input::open(section, MAGIC)?;
306        let count = input.count(8)?;
307        let mut refsets = Vec::with_capacity(count);
308        for _ in 0..count {
309            refsets.push(input.u64()?);
310        }
311        let forward = Keyed::read(&mut input)?;
312        let backward = Keyed::read(&mut input)?;
313        ensure!(input.remaining == 0, "Trailing history bytes");
314        let index = Self {
315            refsets,
316            forward,
317            backward,
318        };
319        index.validate(concepts)?;
320        ensure!(
321            index.rows() == manifest.rows && index.refsets == manifest.refsets,
322            "History index differs from manifest"
323        );
324        Ok(index)
325    }
326}
327
328/// Builds the history index for a staged store and records it in the manifest.
329///
330/// Import and supplements both end here, so every path that
331/// produces a store produces the same index from the same rows.
332pub fn add_history(directory: &Path) -> Result<Option<HistoryManifest>> {
333    let store = NumericStore::open(directory)?;
334    if !store.member_tables.is_available() {
335        return Ok(None);
336    }
337    let (index, skipped) = HistoryIndex::build(&store)?;
338    drop(store);
339    let path = directory.join("history.bin");
340    if path.exists() {
341        std::fs::remove_file(&path)?;
342    }
343    let written = index.write(&path, skipped)?;
344    let mut manifest = Manifest::read(directory)?;
345    manifest.history = Some(written.clone());
346    let file = directory.join("manifest.json");
347    let mut out = BufWriter::new(File::create(&file)?);
348    serde_json::to_writer_pretty(&mut out, &manifest)?;
349    out.flush()?;
350    out.get_ref().sync_all()?;
351    Ok(Some(written))
352}
353
354/// Opens the section on first use, like descriptions and the word index.
355#[derive(Debug, Default)]
356pub struct HistoryStore {
357    source: Option<(Section, HistoryManifest, usize)>,
358    loaded: OnceLock<std::result::Result<HistoryIndex, String>>,
359}
360
361impl HistoryStore {
362    pub(super) fn lazy(
363        source: &IndexSource,
364        metadata: HistoryManifest,
365        concepts: usize,
366    ) -> Result<Self> {
367        Ok(Self {
368            source: Some((source.section("history.bin")?, metadata, concepts)),
369            loaded: OnceLock::new(),
370        })
371    }
372    /// For tests and for callers that built an index in memory.
373    pub fn loaded(index: HistoryIndex) -> Self {
374        Self {
375            source: None,
376            loaded: OnceLock::from(Ok(index)),
377        }
378    }
379    pub fn get(&self) -> Result<Option<&HistoryIndex>> {
380        if self.source.is_none() && self.loaded.get().is_none() {
381            return Ok(None);
382        }
383        match self.loaded.get_or_init(|| {
384            let (section, manifest, concepts) = self.source.as_ref().unwrap();
385            HistoryIndex::open(section, manifest, *concepts).map_err(|e| e.to_string())
386        }) {
387            Ok(index) => Ok(Some(index)),
388            Err(message) => bail!("History index: {message}"),
389        }
390    }
391}