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