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,
256        &[frame],
257        "observation-archive",
258        FloatFormat::RoundTrip,
259    )?;
260    Ok(())
261}
262
263// ---------------------------------------------------------------------------
264// C ABI
265// ---------------------------------------------------------------------------
266
267use std::ffi::CStr;
268use std::os::raw::{c_char, c_int};
269use std::sync::Mutex;
270
271use crate::ffi::{RKRDB_ERR, RKRDB_NOT_FOUND, RKRDB_NULL, RKRDB_OK};
272
273static ARCHIVES: Mutex<Vec<Option<Box<ObservationArchive>>>> = Mutex::new(Vec::new());
274
275fn push_archive(a: ObservationArchive) -> usize {
276    let mut g = ARCHIVES.lock().unwrap();
277    for (i, slot) in g.iter_mut().enumerate() {
278        if slot.is_none() {
279            *slot = Some(Box::new(a));
280            return i;
281        }
282    }
283    g.push(Some(Box::new(a)));
284    g.len() - 1
285}
286
287fn with_archive<F, T>(id: usize, f: F) -> std::result::Result<T, c_int>
288where
289    F: FnOnce(&ObservationArchive) -> std::result::Result<T, c_int>,
290{
291    let g = ARCHIVES.lock().unwrap();
292    let slot = g.get(id).ok_or(RKRDB_NULL)?;
293    let a = slot.as_ref().ok_or(RKRDB_NULL)?;
294    f(a)
295}
296
297/// Open (or create) an observation archive at `dir` for a fixed
298/// composition: `z` is the per-atom atomic-number list in caller
299/// order (`natoms` entries), `cell3` the three orthorhombic box
300/// lengths. Writes the opaque handle to `out_id`.
301#[no_mangle]
302pub unsafe extern "C" fn rkrdb_archive_open(
303    dir: *const c_char,
304    z: *const u32,
305    natoms: u32,
306    cell3: *const f64,
307    out_id: *mut usize,
308) -> c_int {
309    if dir.is_null() || z.is_null() || cell3.is_null() || out_id.is_null() || natoms == 0 {
310        return RKRDB_NULL;
311    }
312    let cdir = unsafe { CStr::from_ptr(dir) };
313    let Ok(dir) = cdir.to_str() else {
314        return RKRDB_ERR;
315    };
316    let z = unsafe { std::slice::from_raw_parts(z, natoms as usize) }.to_vec();
317    let cell = unsafe { [*cell3, *cell3.add(1), *cell3.add(2)] };
318    match ObservationArchive::open(dir, z, cell) {
319        Ok(archive) => {
320            unsafe { *out_id = push_archive(archive) };
321            RKRDB_OK
322        }
323        Err(_) => RKRDB_ERR,
324    }
325}
326
327/// Enqueue one evaluation: flat `3 * natoms` position and force
328/// buffers in caller order plus the total energy. Non-blocking; disk
329/// failures are counted via `rkrdb_archive_dropped`.
330#[no_mangle]
331pub unsafe extern "C" fn rkrdb_archive_append(
332    id: usize,
333    positions: *const f64,
334    forces: *const f64,
335    energy: f64,
336) -> c_int {
337    if positions.is_null() || forces.is_null() {
338        return RKRDB_NULL;
339    }
340    with_archive(id, |a| {
341        let n3 = 3 * a.natoms();
342        let pos = unsafe { std::slice::from_raw_parts(positions, n3) };
343        let frc = unsafe { std::slice::from_raw_parts(forces, n3) };
344        Ok(if a.append(pos, frc, energy) {
345            RKRDB_OK
346        } else {
347            RKRDB_ERR
348        })
349    })
350    .unwrap_or(RKRDB_NULL)
351}
352
353/// Block until every enqueued row is committed or counted dropped.
354#[no_mangle]
355pub unsafe extern "C" fn rkrdb_archive_flush(id: usize) -> c_int {
356    with_archive(id, |a| {
357        a.flush();
358        Ok(RKRDB_OK)
359    })
360    .unwrap_or(RKRDB_NULL)
361}
362
363/// Rows committed to the corpus (including prior runs).
364#[no_mangle]
365pub unsafe extern "C" fn rkrdb_archive_count(id: usize, out_count: *mut u64) -> c_int {
366    if out_count.is_null() {
367        return RKRDB_NULL;
368    }
369    with_archive(id, |a| {
370        unsafe { *out_count = a.committed() };
371        Ok(RKRDB_OK)
372    })
373    .unwrap_or(RKRDB_NULL)
374}
375
376/// Rows accepted by append, including any still queued (prior runs
377/// included). Key cache refreshes on this; fetch bounds on
378/// rkrdb_archive_count after a flush.
379#[no_mangle]
380pub unsafe extern "C" fn rkrdb_archive_appended(id: usize, out_count: *mut u64) -> c_int {
381    if out_count.is_null() {
382        return RKRDB_NULL;
383    }
384    with_archive(id, |a| {
385        unsafe { *out_count = a.appended() };
386        Ok(RKRDB_OK)
387    })
388    .unwrap_or(RKRDB_NULL)
389}
390
391/// Rows the writer thread could not persist.
392#[no_mangle]
393pub unsafe extern "C" fn rkrdb_archive_dropped(id: usize, out_count: *mut u64) -> c_int {
394    if out_count.is_null() {
395        return RKRDB_NULL;
396    }
397    with_archive(id, |a| {
398        unsafe { *out_count = a.dropped() };
399        Ok(RKRDB_OK)
400    })
401    .unwrap_or(RKRDB_NULL)
402}
403
404/// Fetch committed row `index` in the caller's original atom order.
405/// `positions` and `forces` receive `3 * natoms` doubles; `out_energy`
406/// the total energy. Flush first for a complete snapshot.
407#[no_mangle]
408pub unsafe extern "C" fn rkrdb_archive_fetch(
409    id: usize,
410    index: u64,
411    positions: *mut f64,
412    forces: *mut f64,
413    capacity_atoms: u32,
414    out_energy: *mut f64,
415) -> c_int {
416    if positions.is_null() || forces.is_null() || out_energy.is_null() {
417        return RKRDB_NULL;
418    }
419    with_archive(id, |a| {
420        if (capacity_atoms as usize) < a.natoms() {
421            return Ok(RKRDB_ERR);
422        }
423        match a.fetch(index) {
424            Ok((pos, frc, energy)) => {
425                unsafe {
426                    std::ptr::copy_nonoverlapping(pos.as_ptr(), positions, pos.len());
427                    std::ptr::copy_nonoverlapping(frc.as_ptr(), forces, frc.len());
428                    *out_energy = energy;
429                }
430                Ok(RKRDB_OK)
431            }
432            Err(Error::MissingFrame(_)) => Ok(RKRDB_NOT_FOUND),
433            Err(_) => Ok(RKRDB_ERR),
434        }
435    })
436    .unwrap_or(RKRDB_NULL)
437}
438
439/// Drain the writer, close the corpus, release the handle.
440#[no_mangle]
441pub unsafe extern "C" fn rkrdb_archive_close(id: usize) -> c_int {
442    let mut g = ARCHIVES.lock().unwrap();
443    match g.get_mut(id) {
444        Some(slot) => {
445            *slot = None;
446            RKRDB_OK
447        }
448        None => RKRDB_NULL,
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn roundtrip_preserves_caller_order_and_energy() {
458        let dir = tempfile::tempdir().unwrap();
459        // Deliberately type-interleaved composition so the CON
460        // type-grouping permutes storage order: H, O, H.
461        let z = vec![1_u32, 8, 1];
462        let cell = [25.0, 25.0, 25.0];
463        let archive = ObservationArchive::open(dir.path(), z.clone(), cell).unwrap();
464
465        let pos: Vec<f64> = (0..9).map(|i| i as f64 * 0.1).collect();
466        let frc: Vec<f64> = (0..9).map(|i| -(i as f64) * 0.01).collect();
467        assert!(archive.append(&pos, &frc, -76.4));
468        assert!(archive.append(&frc, &pos, -75.9));
469        // The refresh key sees in-flight rows before any flush.
470        assert_eq!(archive.appended(), 2);
471        archive.flush();
472        assert_eq!(archive.appended(), 2);
473        assert_eq!(archive.committed(), 2);
474        assert_eq!(archive.dropped(), 0);
475
476        let (p0, f0, e0) = archive.fetch(0).unwrap();
477        assert_eq!(p0, pos);
478        assert_eq!(f0, frc);
479        assert!((e0 - (-76.4)).abs() < 1e-12);
480        let (p1, _f1, e1) = archive.fetch(1).unwrap();
481        assert_eq!(p1, frc);
482        assert!((e1 - (-75.9)).abs() < 1e-12);
483    }
484
485    #[test]
486    fn roundtrip_preserves_small_observations_exactly() {
487        let dir = tempfile::tempdir().unwrap();
488        let z = vec![1_u32, 8, 1];
489        let cell = [25.0, 25.0, 25.0];
490        let positions = vec![
491            0.001_527_890_189_282_565_8,
492            -0.0,
493            1.0,
494            1.234_567_890_123_456_7e-20,
495            2.0,
496            3.0,
497            4.0,
498            5.0,
499            6.0,
500        ];
501        let forces = vec![
502            0.001_527_890_189_282_565_8,
503            -0.001_527_890_189_282_565_8,
504            -0.0,
505            f64::MIN_POSITIVE,
506            f64::from_bits(1),
507            -f64::from_bits(1),
508            1.234_567_890_123_456_7e30,
509            f64::MAX,
510            -f64::MAX,
511        ];
512        let energy = 0.001_527_890_189_282_565_8_f64;
513        {
514            let archive = ObservationArchive::open(dir.path(), z.clone(), cell).unwrap();
515            assert!(archive.append(&positions, &forces, energy));
516            archive.flush();
517            assert_eq!(archive.committed(), 1);
518            assert_eq!(archive.dropped(), 0);
519        }
520        let archive = ObservationArchive::open(dir.path(), z, cell).unwrap();
521        let (actual_positions, actual_forces, actual_energy) = archive.fetch(0).unwrap();
522        for (actual, expected) in actual_positions.iter().zip(&positions) {
523            assert_eq!(
524                actual.to_bits(),
525                expected.to_bits(),
526                "position {expected:e}"
527            );
528        }
529        for (actual, expected) in actual_forces.iter().zip(&forces) {
530            assert_eq!(actual.to_bits(), expected.to_bits(), "force {expected:e}");
531        }
532        assert_eq!(actual_energy.to_bits(), energy.to_bits());
533    }
534
535    #[test]
536    fn restart_continues_trajectory_ids() {
537        let dir = tempfile::tempdir().unwrap();
538        let z = vec![6_u32, 1];
539        let cell = [10.0, 10.0, 10.0];
540        let pos = vec![0.0; 6];
541        let frc = vec![0.0; 6];
542        {
543            let archive = ObservationArchive::open(dir.path(), z.clone(), cell).unwrap();
544            assert!(archive.append(&pos, &frc, 1.0));
545            archive.flush();
546            assert_eq!(archive.committed(), 1);
547        }
548        {
549            let archive = ObservationArchive::open(dir.path(), z, cell).unwrap();
550            assert_eq!(archive.committed(), 1);
551            assert!(archive.append(&pos, &frc, 2.0));
552            archive.flush();
553            assert_eq!(archive.committed(), 2);
554            let (_, _, e) = archive.fetch(1).unwrap();
555            assert!((e - 2.0).abs() < 1e-12);
556        }
557    }
558
559    #[test]
560    fn append_rejects_wrong_length() {
561        let dir = tempfile::tempdir().unwrap();
562        let archive = ObservationArchive::open(dir.path(), vec![1, 1], [5.0, 5.0, 5.0]).unwrap();
563        assert!(!archive.append(&[0.0; 3], &[0.0; 6], 0.0));
564    }
565}