1use 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
50pub struct ObservationArchive {
52 corpus: Arc<ConCorpus>,
53 tx: Option<Sender<Msg>>,
54 worker: Option<JoinHandle<()>>,
55 committed: Arc<AtomicU64>,
56 dropped: Arc<AtomicU64>,
57 appended: AtomicU64,
61 natoms: usize,
62 dir: PathBuf,
63}
64
65impl ObservationArchive {
66 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 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 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 pub fn committed(&self) -> u64 {
155 self.committed.load(Ordering::Relaxed)
156 }
157
158 pub fn appended(&self) -> u64 {
162 self.appended.load(Ordering::Relaxed)
163 }
164
165 pub fn dropped(&self) -> u64 {
167 self.dropped.load(Ordering::Relaxed)
168 }
169
170 pub fn natoms(&self) -> usize {
172 self.natoms
173 }
174
175 pub fn dir(&self) -> &Path {
177 &self.dir
178 }
179
180 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 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
260use 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#[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#[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#[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#[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#[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#[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#[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#[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 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 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}