Skip to main content

readcon_db/
archive.rs

1//! Asynchronous observation archive over a [`ConCorpus`].
2//!
3//! Active-learning consumers (GP surrogates pruning their training
4//! sets) need a durable record of every configuration an expensive
5//! oracle evaluated: the active set is a view, the archive is the
6//! ledger. This module owns that contract once, behind a C ABI, so
7//! every consumer shares one implementation instead of re-deriving
8//! frame serialization, threading, and read-back ordering.
9//!
10//! Writes follow the async-logger shape: [`ObservationArchive::append`]
11//! copies the row into a channel and returns; a dedicated writer
12//! thread drains the channel and commits each row as a single-frame
13//! trajectory built natively with [`ConFrameBuilder`] (no temporary
14//! files, no serialization on the caller thread). Disk-side failures
15//! are counted, never raised into the caller. Readers synchronize with
16//! [`ObservationArchive::flush`]; LMDB's MVCC makes the read snapshot
17//! safe against the concurrent writer.
18//!
19//! Atom ordering: CON groups atoms by type, so stored frames are a
20//! permutation of the caller's order. [`ObservationArchive::fetch`]
21//! inverts that permutation through the stored `atom_id`s and returns
22//! rows in the caller's original order — the property a training-set
23//! consumer actually needs.
24
25use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::mpsc::{self, Sender};
28use std::sync::Arc;
29use std::thread::JoinHandle;
30
31use readcon_core::helpers::atomic_number_to_symbol;
32use readcon_core::types::ConFrameBuilder;
33use readcon_core::writer::FloatFormat;
34
35use crate::corpus::ConCorpus;
36use crate::error::{Error, Result};
37use crate::keys::FrameKey;
38
39struct Row {
40    positions: Vec<f64>,
41    forces: Vec<f64>,
42    energy: f64,
43}
44
45enum Msg {
46    Row(Row),
47    Flush(Sender<()>),
48}
49
50/// Fixed-composition archive of oracle evaluations.
51pub struct ObservationArchive {
52    corpus: Arc<ConCorpus>,
53    tx: Option<Sender<Msg>>,
54    worker: Option<JoinHandle<()>>,
55    committed: Arc<AtomicU64>,
56    dropped: Arc<AtomicU64>,
57    /// Rows accepted by append (existing + enqueued), ahead of
58    /// `committed` while the writer drains. Readers key refreshes on
59    /// this so an in-flight row still triggers a flush + refetch.
60    appended: AtomicU64,
61    natoms: usize,
62    dir: PathBuf,
63}
64
65impl ObservationArchive {
66    /// Open (or create) the archive at `dir` for one fixed composition.
67    /// `z` is the per-atom atomic-number list in caller order; `cell`
68    /// the orthorhombic box lengths. The corpus lives at
69    /// `dir/observations.rdb`; trajectory ids continue across restarts.
70    pub fn open(dir: impl AsRef<Path>, z: Vec<u32>, cell: [f64; 3]) -> Result<Self> {
71        let dir = dir.as_ref().to_path_buf();
72        std::fs::create_dir_all(&dir)?;
73        let corpus = Arc::new(ConCorpus::open(dir.join("observations.rdb"))?);
74        let existing = corpus.list_frame_keys()?.len() as u64;
75        let committed = Arc::new(AtomicU64::new(existing));
76        let dropped = Arc::new(AtomicU64::new(0));
77        let (tx, rx) = mpsc::channel::<Msg>();
78        let natoms = z.len();
79
80        let worker_corpus = Arc::clone(&corpus);
81        let worker_committed = Arc::clone(&committed);
82        let worker_dropped = Arc::clone(&dropped);
83        let worker = std::thread::Builder::new()
84            .name("rkrdb-archive".into())
85            .spawn(move || {
86                while let Ok(msg) = rx.recv() {
87                    match msg {
88                        Msg::Row(row) => {
89                            let traj = worker_committed.load(Ordering::Relaxed);
90                            match commit_row(&worker_corpus, traj, &z, cell, &row) {
91                                Ok(()) => {
92                                    worker_committed.store(traj + 1, Ordering::Relaxed);
93                                }
94                                Err(_) => {
95                                    worker_dropped.fetch_add(1, Ordering::Relaxed);
96                                }
97                            }
98                        }
99                        Msg::Flush(ack) => {
100                            let _ = ack.send(());
101                        }
102                    }
103                }
104            })
105            .map_err(|e| Error::Message(format!("archive worker spawn failed: {e}")))?;
106
107        Ok(Self {
108            corpus,
109            tx: Some(tx),
110            worker: Some(worker),
111            committed,
112            dropped,
113            appended: AtomicU64::new(existing),
114            natoms,
115            dir,
116        })
117    }
118
119    /// Enqueue one evaluation. `positions` and `forces` are flat
120    /// `3 * natoms` buffers in caller order. Returns `false` only when
121    /// the row cannot be enqueued (wrong length, archive closing);
122    /// disk-side failures are counted in [`Self::dropped`] instead.
123    pub fn append(&self, positions: &[f64], forces: &[f64], energy: f64) -> bool {
124        if positions.len() != 3 * self.natoms || forces.len() != 3 * self.natoms {
125            return false;
126        }
127        let Some(tx) = self.tx.as_ref() else {
128            return false;
129        };
130        let sent = tx
131            .send(Msg::Row(Row {
132                positions: positions.to_vec(),
133                forces: forces.to_vec(),
134                energy,
135            }))
136            .is_ok();
137        if sent {
138            self.appended.fetch_add(1, Ordering::Relaxed);
139        }
140        sent
141    }
142
143    /// Block until every previously enqueued row is committed (or
144    /// counted dropped). Call before reading the corpus back.
145    pub fn flush(&self) {
146        let Some(tx) = self.tx.as_ref() else { return };
147        let (ack_tx, ack_rx) = mpsc::channel();
148        if tx.send(Msg::Flush(ack_tx)).is_ok() {
149            let _ = ack_rx.recv();
150        }
151    }
152
153    /// Rows committed to the corpus (including prior runs).
154    pub fn committed(&self) -> u64 {
155        self.committed.load(Ordering::Relaxed)
156    }
157
158    /// Rows accepted by append, including any still in the writer
159    /// queue (and prior runs). `appended - dropped` equals `committed`
160    /// once flushed.
161    pub fn appended(&self) -> u64 {
162        self.appended.load(Ordering::Relaxed)
163    }
164
165    /// Rows the writer could not persist.
166    pub fn dropped(&self) -> u64 {
167        self.dropped.load(Ordering::Relaxed)
168    }
169
170    /// Atoms per row.
171    pub fn natoms(&self) -> usize {
172        self.natoms
173    }
174
175    /// Archive directory.
176    pub fn dir(&self) -> &Path {
177        &self.dir
178    }
179
180    /// Fetch committed row `index` in the caller's original atom
181    /// order (the stored type-grouped permutation is inverted through
182    /// the frame's `atom_id`s). Flush first for a complete snapshot.
183    pub fn fetch(&self, index: u64) -> Result<(Vec<f64>, Vec<f64>, f64)> {
184        let frame = self.corpus.get_frame(FrameKey {
185            traj_id: index,
186            frame_idx: 0,
187        })?;
188        let n = self.natoms;
189        if frame.atom_ids.len() != n {
190            return Err(Error::Message(format!(
191                "archive row {index}: expected {n} atoms, found {}",
192                frame.atom_ids.len()
193            )));
194        }
195        if frame.positions.nrows() != n || frame.forces.nrows() != n {
196            return Err(Error::Parse(format!(
197                "archive row {index}: expected {n} position and force rows, \
198                 found {} and {}",
199                frame.positions.nrows(),
200                frame.forces.nrows()
201            )));
202        }
203        let mut positions = vec![0.0; 3 * n];
204        let mut forces = vec![0.0; 3 * n];
205        for (stored, &atom_id) in frame.atom_ids.iter().enumerate() {
206            let orig = atom_id as usize;
207            if orig >= n {
208                return Err(Error::Parse(format!(
209                    "archive row {index}: atom id {orig} out of range"
210                )));
211            }
212            positions[3 * orig..3 * orig + 3].copy_from_slice(&frame.positions.as_f64_row(stored));
213            forces[3 * orig..3 * orig + 3].copy_from_slice(&frame.forces.as_f64_row(stored));
214        }
215        let energy = frame
216            .header
217            .energy()
218            .ok_or_else(|| Error::Message(format!("archive row {index} has no energy")))?;
219        Ok((positions, forces, energy))
220    }
221}
222
223impl Drop for ObservationArchive {
224    fn drop(&mut self) {
225        // Close the channel so the worker drains and exits, then join.
226        self.tx.take();
227        if let Some(worker) = self.worker.take() {
228            let _ = worker.join();
229        }
230    }
231}
232
233fn commit_row(corpus: &ConCorpus, traj: u64, z: &[u32], cell: [f64; 3], row: &Row) -> Result<()> {
234    let mut builder = ConFrameBuilder::new(cell, [90.0, 90.0, 90.0]);
235    builder.prebox_header("observation archive");
236    builder.set_energy(row.energy);
237    for (i, &zi) in z.iter().enumerate() {
238        builder.add_atom(
239            atomic_number_to_symbol(u64::from(zi)),
240            row.positions[3 * i],
241            row.positions[3 * i + 1],
242            row.positions[3 * i + 2],
243            [false; 3],
244            i as u64,
245            f64::from(zi),
246        );
247    }
248    builder
249        .set_forces_from_flat(&row.forces)
250        .map_err(|e| Error::Message(format!("archive forces: {e}")))?;
251    let frame = builder
252        .build()
253        .map_err(|e| Error::Message(format!("archive build: {e}")))?;
254    corpus.append_trajectory_frames_with_float_format(
255        traj, &[frame], "observation-archive", FloatFormat::RoundTrip,
256    )?;
257    Ok(())
258}
259
260// ---------------------------------------------------------------------------
261// C ABI
262// ---------------------------------------------------------------------------
263
264use std::ffi::CStr;
265use std::os::raw::{c_char, c_int};
266use std::sync::Mutex;
267
268use crate::ffi::{RKRDB_ERR, RKRDB_NOT_FOUND, RKRDB_NULL, RKRDB_OK};
269
270static ARCHIVES: Mutex<Vec<Option<Box<ObservationArchive>>>> = Mutex::new(Vec::new());
271
272fn push_archive(a: ObservationArchive) -> usize {
273    let mut g = ARCHIVES.lock().unwrap();
274    for (i, slot) in g.iter_mut().enumerate() {
275        if slot.is_none() {
276            *slot = Some(Box::new(a));
277            return i;
278        }
279    }
280    g.push(Some(Box::new(a)));
281    g.len() - 1
282}
283
284fn with_archive<F, T>(id: usize, f: F) -> std::result::Result<T, c_int>
285where
286    F: FnOnce(&ObservationArchive) -> std::result::Result<T, c_int>,
287{
288    let g = ARCHIVES.lock().unwrap();
289    let slot = g.get(id).ok_or(RKRDB_NULL)?;
290    let a = slot.as_ref().ok_or(RKRDB_NULL)?;
291    f(a)
292}
293
294/// Open (or create) an observation archive at `dir` for a fixed
295/// composition: `z` is the per-atom atomic-number list in caller
296/// order (`natoms` entries), `cell3` the three orthorhombic box
297/// lengths. Writes the opaque handle to `out_id`.
298#[no_mangle]
299pub unsafe extern "C" fn rkrdb_archive_open(
300    dir: *const c_char,
301    z: *const u32,
302    natoms: u32,
303    cell3: *const f64,
304    out_id: *mut usize,
305) -> c_int {
306    if dir.is_null() || z.is_null() || cell3.is_null() || out_id.is_null() || natoms == 0 {
307        return RKRDB_NULL;
308    }
309    let cdir = unsafe { CStr::from_ptr(dir) };
310    let Ok(dir) = cdir.to_str() else {
311        return RKRDB_ERR;
312    };
313    let z = unsafe { std::slice::from_raw_parts(z, natoms as usize) }.to_vec();
314    let cell = unsafe { [*cell3, *cell3.add(1), *cell3.add(2)] };
315    match ObservationArchive::open(dir, z, cell) {
316        Ok(archive) => {
317            unsafe { *out_id = push_archive(archive) };
318            RKRDB_OK
319        }
320        Err(_) => RKRDB_ERR,
321    }
322}
323
324/// Enqueue one evaluation: flat `3 * natoms` position and force
325/// buffers in caller order plus the total energy. Non-blocking; disk
326/// failures are counted via `rkrdb_archive_dropped`.
327#[no_mangle]
328pub unsafe extern "C" fn rkrdb_archive_append(
329    id: usize,
330    positions: *const f64,
331    forces: *const f64,
332    energy: f64,
333) -> c_int {
334    if positions.is_null() || forces.is_null() {
335        return RKRDB_NULL;
336    }
337    with_archive(id, |a| {
338        let n3 = 3 * a.natoms();
339        let pos = unsafe { std::slice::from_raw_parts(positions, n3) };
340        let frc = unsafe { std::slice::from_raw_parts(forces, n3) };
341        Ok(if a.append(pos, frc, energy) {
342            RKRDB_OK
343        } else {
344            RKRDB_ERR
345        })
346    })
347    .unwrap_or(RKRDB_NULL)
348}
349
350/// Block until every enqueued row is committed or counted dropped.
351#[no_mangle]
352pub unsafe extern "C" fn rkrdb_archive_flush(id: usize) -> c_int {
353    with_archive(id, |a| {
354        a.flush();
355        Ok(RKRDB_OK)
356    })
357    .unwrap_or(RKRDB_NULL)
358}
359
360/// Rows committed to the corpus (including prior runs).
361#[no_mangle]
362pub unsafe extern "C" fn rkrdb_archive_count(id: usize, out_count: *mut u64) -> c_int {
363    if out_count.is_null() {
364        return RKRDB_NULL;
365    }
366    with_archive(id, |a| {
367        unsafe { *out_count = a.committed() };
368        Ok(RKRDB_OK)
369    })
370    .unwrap_or(RKRDB_NULL)
371}
372
373/// Rows accepted by append, including any still queued (prior runs
374/// included). Key cache refreshes on this; fetch bounds on
375/// rkrdb_archive_count after a flush.
376#[no_mangle]
377pub unsafe extern "C" fn rkrdb_archive_appended(id: usize, out_count: *mut u64) -> c_int {
378    if out_count.is_null() {
379        return RKRDB_NULL;
380    }
381    with_archive(id, |a| {
382        unsafe { *out_count = a.appended() };
383        Ok(RKRDB_OK)
384    })
385    .unwrap_or(RKRDB_NULL)
386}
387
388/// Rows the writer thread could not persist.
389#[no_mangle]
390pub unsafe extern "C" fn rkrdb_archive_dropped(id: usize, out_count: *mut u64) -> c_int {
391    if out_count.is_null() {
392        return RKRDB_NULL;
393    }
394    with_archive(id, |a| {
395        unsafe { *out_count = a.dropped() };
396        Ok(RKRDB_OK)
397    })
398    .unwrap_or(RKRDB_NULL)
399}
400
401/// Fetch committed row `index` in the caller's original atom order.
402/// `positions` and `forces` receive `3 * natoms` doubles; `out_energy`
403/// the total energy. Flush first for a complete snapshot.
404#[no_mangle]
405pub unsafe extern "C" fn rkrdb_archive_fetch(
406    id: usize,
407    index: u64,
408    positions: *mut f64,
409    forces: *mut f64,
410    capacity_atoms: u32,
411    out_energy: *mut f64,
412) -> c_int {
413    if positions.is_null() || forces.is_null() || out_energy.is_null() {
414        return RKRDB_NULL;
415    }
416    with_archive(id, |a| {
417        if (capacity_atoms as usize) < a.natoms() {
418            return Ok(RKRDB_ERR);
419        }
420        match a.fetch(index) {
421            Ok((pos, frc, energy)) => {
422                unsafe {
423                    std::ptr::copy_nonoverlapping(pos.as_ptr(), positions, pos.len());
424                    std::ptr::copy_nonoverlapping(frc.as_ptr(), forces, frc.len());
425                    *out_energy = energy;
426                }
427                Ok(RKRDB_OK)
428            }
429            Err(Error::MissingFrame(_)) => Ok(RKRDB_NOT_FOUND),
430            Err(_) => Ok(RKRDB_ERR),
431        }
432    })
433    .unwrap_or(RKRDB_NULL)
434}
435
436/// Drain the writer, close the corpus, release the handle.
437#[no_mangle]
438pub unsafe extern "C" fn rkrdb_archive_close(id: usize) -> c_int {
439    let mut g = ARCHIVES.lock().unwrap();
440    match g.get_mut(id) {
441        Some(slot) => {
442            *slot = None;
443            RKRDB_OK
444        }
445        None => RKRDB_NULL,
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn roundtrip_preserves_caller_order_and_energy() {
455        let dir = tempfile::tempdir().unwrap();
456        // Deliberately type-interleaved composition so the CON
457        // type-grouping permutes storage order: H, O, H.
458        let z = vec![1_u32, 8, 1];
459        let cell = [25.0, 25.0, 25.0];
460        let archive = ObservationArchive::open(dir.path(), z.clone(), cell).unwrap();
461
462        let pos: Vec<f64> = (0..9).map(|i| i as f64 * 0.1).collect();
463        let frc: Vec<f64> = (0..9).map(|i| -(i as f64) * 0.01).collect();
464        assert!(archive.append(&pos, &frc, -76.4));
465        assert!(archive.append(&frc, &pos, -75.9));
466        // The refresh key sees in-flight rows before any flush.
467        assert_eq!(archive.appended(), 2);
468        archive.flush();
469        assert_eq!(archive.appended(), 2);
470        assert_eq!(archive.committed(), 2);
471        assert_eq!(archive.dropped(), 0);
472
473        let (p0, f0, e0) = archive.fetch(0).unwrap();
474        assert_eq!(p0, pos);
475        assert_eq!(f0, frc);
476        assert!((e0 - (-76.4)).abs() < 1e-12);
477        let (p1, _f1, e1) = archive.fetch(1).unwrap();
478        assert_eq!(p1, frc);
479        assert!((e1 - (-75.9)).abs() < 1e-12);
480    }
481
482    #[test]
483    fn roundtrip_preserves_small_observations_exactly() {
484        let dir = tempfile::tempdir().unwrap();
485        let z = vec![1_u32, 8, 1];
486        let cell = [25.0, 25.0, 25.0];
487        let positions = vec![
488            0.001_527_890_189_282_565_8,
489            -0.0,
490            1.0,
491            1.234_567_890_123_456_7e-20,
492            2.0,
493            3.0,
494            4.0,
495            5.0,
496            6.0,
497        ];
498        let forces = vec![
499            0.001_527_890_189_282_565_8,
500            -0.001_527_890_189_282_565_8,
501            -0.0,
502            f64::MIN_POSITIVE,
503            f64::from_bits(1),
504            -f64::from_bits(1),
505            1.234_567_890_123_456_7e30,
506            f64::MAX,
507            -f64::MAX,
508        ];
509        let energy = 0.001_527_890_189_282_565_8_f64;
510        {
511            let archive = ObservationArchive::open(dir.path(), z.clone(), cell).unwrap();
512            assert!(archive.append(&positions, &forces, energy));
513            archive.flush();
514            assert_eq!(archive.committed(), 1);
515            assert_eq!(archive.dropped(), 0);
516        }
517        let archive = ObservationArchive::open(dir.path(), z, cell).unwrap();
518        let (actual_positions, actual_forces, actual_energy) = archive.fetch(0).unwrap();
519        for (actual, expected) in actual_positions.iter().zip(&positions) {
520            assert_eq!(
521                actual.to_bits(),
522                expected.to_bits(),
523                "position {expected:e}"
524            );
525        }
526        for (actual, expected) in actual_forces.iter().zip(&forces) {
527            assert_eq!(actual.to_bits(), expected.to_bits(), "force {expected:e}");
528        }
529        assert_eq!(actual_energy.to_bits(), energy.to_bits());
530    }
531
532    #[test]
533    fn restart_continues_trajectory_ids() {
534        let dir = tempfile::tempdir().unwrap();
535        let z = vec![6_u32, 1];
536        let cell = [10.0, 10.0, 10.0];
537        let pos = vec![0.0; 6];
538        let frc = vec![0.0; 6];
539        {
540            let archive = ObservationArchive::open(dir.path(), z.clone(), cell).unwrap();
541            assert!(archive.append(&pos, &frc, 1.0));
542            archive.flush();
543            assert_eq!(archive.committed(), 1);
544        }
545        {
546            let archive = ObservationArchive::open(dir.path(), z, cell).unwrap();
547            assert_eq!(archive.committed(), 1);
548            assert!(archive.append(&pos, &frc, 2.0));
549            archive.flush();
550            assert_eq!(archive.committed(), 2);
551            let (_, _, e) = archive.fetch(1).unwrap();
552            assert!((e - 2.0).abs() < 1e-12);
553        }
554    }
555
556    #[test]
557    fn append_rejects_wrong_length() {
558        let dir = tempfile::tempdir().unwrap();
559        let archive = ObservationArchive::open(dir.path(), vec![1, 1], [5.0, 5.0, 5.0]).unwrap();
560        assert!(!archive.append(&[0.0; 3], &[0.0; 6], 0.0));
561    }
562}