Skip to main content

readcon_db/
corpus.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::io::Cursor;
4use std::path::{Path, PathBuf};
5
6use heed::types::{Bytes, Str, Unit};
7use heed::{Database, Env, EnvOpenOptions, RwTxn};
8use readcon_core::iterators::ConFrameIterator;
9use readcon_core::types::ConFrame;
10use readcon_core::writer::ConFrameWriter;
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14use crate::export_xyz::write_frame_extxyz;
15use crate::frame_scalars::{
16    frame_cell_volume, frame_charge, frame_frame_index, frame_magmom, frame_neb_band, frame_neb_bead,
17    frame_pbc_mask, frame_time, frame_timestep, frame_total_mass,
18};
19use crate::keys::{
20    composition_formula, elem_count_key, elem_count_symbol_prefix, energy_bin_key, flag_key,
21    fmax_bin_key, formula_key, formula_prefix, hash_frame_bytes, mass_bin_key, meta_scalar_key,
22    natoms_key, ordered_f64_bits, parse_elem_count_key, pbc_key, pbc_mask_from_bools,
23    species_counts_from_symbols, symbol_key, symbol_prefix, volume_bin_key, ContentHash, FrameKey,
24    TrajId, FLAG_HAS_ENERGY, FLAG_HAS_FORCES, FLAG_HAS_VELOCITIES, META_CHARGE, META_FRAME_INDEX,
25    META_MAGMOM, META_NEB_BAND, META_NEB_BEAD, META_TIME, META_TIMESTEP,
26};
27use crate::select::Select;
28
29/// Default mmap region (2 GiB). Raise via re-open with a larger map if the corpus grows.
30const MAP_SIZE: usize = 2 * 1024 * 1024 * 1024;
31const MAX_DBS: u32 = 48;
32/// LMDB multi-process reader slots (analysis farms / concurrent CLI processes).
33/// Classic embedded SOTA: one writer, many readers across OS processes sharing the mmap
34/// (Gray/Reuter MVCC; LMDB design notes — not a cluster consensus protocol).
35const MAX_READERS: u32 = 512;
36
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub struct TrajMeta {
39    pub n_frames: u32,
40    pub source: String,
41}
42
43/// All secondary index keys precomputed **outside** exclusive write_txn (commit = puts only).
44struct PreparedIndexPuts {
45    fk: FrameKey,
46    blob: String,
47    hash: [u8; 16],
48    natoms_k: [u8; 16],
49    symbol_keys: Vec<Vec<u8>>,
50    elem_count_keys: Vec<Vec<u8>>,
51    formula_k: Option<Vec<u8>>,
52    energy_k: Option<[u8; 20]>,
53    fmax_k: Option<[u8; 20]>,
54    mass_k: Option<[u8; 20]>,
55    volume_k: Option<[u8; 20]>,
56    pbc_k: Option<[u8; 13]>,
57    flag_keys: Vec<[u8; 13]>,
58    meta_keys: Vec<[u8; 21]>,
59}
60
61pub struct ConCorpus {
62    path: PathBuf,
63    env: Env,
64    frames: Database<Bytes, Str>,
65    traj_meta: Database<Bytes, Str>,
66    idx_natoms: Database<Bytes, Unit>,
67    idx_symbol: Database<Bytes, Unit>,
68    idx_energy: Database<Bytes, Unit>,
69    idx_flags: Database<Bytes, Unit>,
70    /// Per-element atom counts: symbol ‖ 0xff ‖ BE count ‖ FrameKey
71    idx_elem_count: Database<Bytes, Unit>,
72    /// Canonical formula ‖ 0xff ‖ FrameKey
73    idx_formula: Database<Bytes, Unit>,
74    /// Ordered max force magnitude (forces present only)
75    idx_fmax: Database<Bytes, Unit>,
76    idx_mass: Database<Bytes, Unit>,
77    idx_volume: Database<Bytes, Unit>,
78    /// PBC mask (metadata present only)
79    idx_pbc: Database<Bytes, Unit>,
80    /// Meta scalars: channel || ord(value) || FrameKey
81    idx_meta: Database<Bytes, Unit>,
82    frame_by_hash: Database<Bytes, Bytes>,
83    hash_by_frame: Database<Bytes, Bytes>,
84    /// Optional derived SoA numerics (`FrameKey` → cooked bytes). Not authoritative.
85    frames_soa: Database<Bytes, Bytes>,
86}
87
88fn frame_has_forces(frame: &ConFrame) -> bool {
89    readcon_core::index_proj::frame_has_forces(frame)
90}
91
92fn frame_has_velocities(frame: &ConFrame) -> bool {
93    readcon_core::index_proj::frame_has_velocities(frame)
94}
95
96fn frame_energy(frame: &ConFrame) -> Option<f64> {
97    readcon_core::index_proj::finite_energy(frame)
98}
99
100/// Euclidean max ||F_i|| over atoms with force data; None if no forces.
101pub fn frame_fmax(frame: &ConFrame) -> Option<f64> {
102    readcon_core::index_proj::frame_fmax(frame)
103}
104
105fn frame_species(frame: &ConFrame) -> Vec<(String, u32)> {
106    readcon_core::index_proj::frame_species_counts(frame)
107}
108
109impl ConCorpus {
110    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
111        let path = path.as_ref().to_path_buf();
112        fs::create_dir_all(&path)?;
113        let mut opts = EnvOpenOptions::new();
114        opts.map_size(MAP_SIZE);
115        opts.max_dbs(MAX_DBS);
116        opts.max_readers(MAX_READERS);
117        // Mmap + MVCC: readers do not block writers beyond LMDB page COW; writers serialize.
118        let env = unsafe { opts.open(&path)? };
119
120        let mut wtxn = env.write_txn()?;
121        let frames = env.create_database(&mut wtxn, Some("frames"))?;
122        let traj_meta = env.create_database(&mut wtxn, Some("traj_meta"))?;
123        let idx_natoms = env.create_database(&mut wtxn, Some("idx_natoms"))?;
124        let idx_symbol = env.create_database(&mut wtxn, Some("idx_symbol"))?;
125        let idx_energy = env.create_database(&mut wtxn, Some("idx_energy"))?;
126        let idx_flags = env.create_database(&mut wtxn, Some("idx_flags"))?;
127        let idx_elem_count = env.create_database(&mut wtxn, Some("idx_elem_count"))?;
128        let idx_formula = env.create_database(&mut wtxn, Some("idx_formula"))?;
129        let idx_fmax = env.create_database(&mut wtxn, Some("idx_fmax"))?;
130        let idx_mass = env.create_database(&mut wtxn, Some("idx_mass"))?;
131        let idx_volume = env.create_database(&mut wtxn, Some("idx_volume"))?;
132        let idx_pbc = env.create_database(&mut wtxn, Some("idx_pbc"))?;
133        let idx_meta = env.create_database(&mut wtxn, Some("idx_meta"))?;
134        let frame_by_hash = env.create_database(&mut wtxn, Some("frame_by_hash"))?;
135        let hash_by_frame = env.create_database(&mut wtxn, Some("hash_by_frame"))?;
136        let frames_soa = env.create_database(&mut wtxn, Some("frames_soa"))?;
137        wtxn.commit()?;
138
139        Ok(Self {
140            path,
141            env,
142            frames,
143            traj_meta,
144            idx_natoms,
145            idx_symbol,
146            idx_energy,
147            idx_flags,
148            idx_elem_count,
149            idx_formula,
150            idx_fmax,
151            idx_mass,
152            idx_volume,
153            idx_pbc,
154            idx_meta,
155            frame_by_hash,
156            hash_by_frame,
157            frames_soa,
158        })
159    }
160
161    pub fn path(&self) -> &Path {
162        &self.path
163    }
164
165    /// All frame keys in this env (for compaction / export pipelines).
166    pub fn list_frame_keys(&self) -> Result<Vec<FrameKey>> {
167        let rtxn = self.env.read_txn()?;
168        let mut out = Vec::new();
169        let mut iter = self.frames.iter(&rtxn)?;
170        while let Some(Ok((k, _))) = iter.next() {
171            if let Some(fk) = FrameKey::from_bytes(k) {
172                out.push(fk);
173            }
174        }
175        out.sort();
176        Ok(out)
177    }
178
179    /// Copy one frame blob+indexes from this corpus into `dst` (same FrameKey).
180    /// Used by join compaction; prepare keys outside dst exclusive section via append path on text.
181    pub fn export_frame_blob(&self, key: FrameKey) -> Result<String> {
182        self.get_frame_text(key)
183    }
184
185
186    /// CPU-only: derive every secondary key for one frame (call **outside** write_txn).
187    /// Screening scalars come from [`readcon_core::index_proj::FrameIndexProjection`].
188    fn build_index_puts(fk: FrameKey, frame: &ConFrame, blob: String) -> PreparedIndexPuts {
189        use readcon_core::index_proj::FrameIndexProjection;
190        let hash = hash_frame_bytes(blob.as_bytes()).to_bytes();
191        let proj = FrameIndexProjection::from_frame(frame);
192        let mut symbol_keys = Vec::new();
193        let mut elem_count_keys = Vec::new();
194        let mut seen = BTreeSet::new();
195        for (sym, cnt) in &proj.species_counts {
196            elem_count_keys.push(elem_count_key(sym, *cnt, fk));
197            if seen.insert(sym.clone()) {
198                symbol_keys.push(symbol_key(sym, fk));
199            }
200        }
201        let formula_k = if proj.formula.is_empty() {
202            None
203        } else {
204            Some(formula_key(&proj.formula, fk))
205        };
206        let mut flag_keys = Vec::new();
207        let energy_k = proj.energy.and_then(|e| {
208            flag_keys.push(flag_key(FLAG_HAS_ENERGY, fk));
209            energy_bin_key(e, fk)
210        });
211        let fmax_k = if proj.has_forces {
212            flag_keys.push(flag_key(FLAG_HAS_FORCES, fk));
213            proj.fmax.and_then(|fm| fmax_bin_key(fm, fk))
214        } else {
215            None
216        };
217        if proj.has_velocities {
218            flag_keys.push(flag_key(FLAG_HAS_VELOCITIES, fk));
219        }
220        let mass_k = proj.total_mass.and_then(|m| mass_bin_key(m, fk));
221        let volume_k = proj.cell_volume.and_then(|v| volume_bin_key(v, fk));
222        let pbc_k = proj
223            .pbc
224            .map(|p| pbc_key(crate::keys::pbc_mask_from_bools(p), fk));
225        let mut meta_keys = Vec::new();
226        for (ch, val) in [
227            (META_TIME, proj.time),
228            (META_TIMESTEP, proj.timestep),
229            (META_FRAME_INDEX, proj.frame_index),
230            (META_NEB_BEAD, proj.neb_bead),
231            (META_NEB_BAND, proj.neb_band),
232            (META_CHARGE, proj.charge),
233            (META_MAGMOM, proj.magmom),
234        ] {
235            if let Some(x) = val {
236                if let Some(k) = meta_scalar_key(ch, x, fk) {
237                    meta_keys.push(k);
238                }
239            }
240        }
241        PreparedIndexPuts {
242            fk,
243            blob,
244            hash,
245            natoms_k: natoms_key(proj.n_atoms, fk),
246            symbol_keys,
247            elem_count_keys,
248            formula_k,
249            energy_k,
250            fmax_k,
251            mass_k,
252            volume_k,
253            pbc_k,
254            flag_keys,
255            meta_keys,
256        }
257    }
258
259    /// Exclusive section: LMDB puts only (no hash/species/bin derivation).
260    fn put_index_puts(&self, wtxn: &mut RwTxn, p: &PreparedIndexPuts) -> Result<()> {
261        let fk_b = p.fk.to_bytes();
262        self.frames.put(wtxn, &fk_b[..], p.blob.as_str())?;
263        self.hash_by_frame.put(wtxn, &fk_b[..], &p.hash[..])?;
264        if self.frame_by_hash.get(wtxn, &p.hash[..])?.is_none() {
265            self.frame_by_hash.put(wtxn, &p.hash[..], &fk_b[..])?;
266        }
267        self.idx_natoms.put(wtxn, &p.natoms_k[..], &())?;
268        for sk in &p.symbol_keys {
269            self.idx_symbol.put(wtxn, &sk[..], &())?;
270        }
271        for ek in &p.elem_count_keys {
272            self.idx_elem_count.put(wtxn, &ek[..], &())?;
273        }
274        if let Some(ref fk_form) = p.formula_k {
275            self.idx_formula.put(wtxn, &fk_form[..], &())?;
276        }
277        if let Some(ek) = p.energy_k {
278            self.idx_energy.put(wtxn, &ek[..], &())?;
279        }
280        if let Some(fk_fm) = p.fmax_k {
281            self.idx_fmax.put(wtxn, &fk_fm[..], &())?;
282        }
283        if let Some(k) = p.mass_k {
284            self.idx_mass.put(wtxn, &k[..], &())?;
285        }
286        if let Some(k) = p.volume_k {
287            self.idx_volume.put(wtxn, &k[..], &())?;
288        }
289        if let Some(pk) = p.pbc_k {
290            self.idx_pbc.put(wtxn, &pk[..], &())?;
291        }
292        for fk_flag in &p.flag_keys {
293            self.idx_flags.put(wtxn, &fk_flag[..], &())?;
294        }
295        for mk in &p.meta_keys {
296            self.idx_meta.put(wtxn, &mk[..], &())?;
297        }
298        Ok(())
299    }
300
301    /// Reindex path: build puts then put (derivation not under a multi-frame exclusive prepare loop).
302    fn index_frame(&self, wtxn: &mut RwTxn, fk: FrameKey, frame: &ConFrame, blob: &str) -> Result<()> {
303        let puts = Self::build_index_puts(fk, frame, blob.to_owned());
304        self.put_index_puts(wtxn, &puts)
305    }
306
307    /// Parse path/string CON into owned blobs + frames (no exclusive LMDB write section).
308    fn prepare_trajectory_str(
309        traj_id: TrajId,
310        file_contents: &str,
311    ) -> Result<Vec<PreparedIndexPuts>> {
312        let mut out = Vec::new();
313        let mut frame_idx: u32 = 0;
314        let mut iter = ConFrameIterator::new(file_contents);
315        loop {
316            let (frame, blob) = match iter.next_with_raw_span(file_contents) {
317                None => break,
318                Some(Err(e)) => return Err(Error::Parse(e.to_string())),
319                Some(Ok(x)) => x,
320            };
321            let fk = FrameKey {
322                traj_id,
323                frame_idx,
324            };
325            out.push(Self::build_index_puts(fk, &frame, blob.to_owned()));
326            frame_idx += 1;
327        }
328        Ok(out)
329    }
330
331    /// Serialize ConFrames to CON text **outside** write_txn (in-memory ingress path).
332    fn prepare_trajectory_frames(
333        traj_id: TrajId,
334        frames: &[ConFrame],
335        start_idx: u32,
336    ) -> Result<Vec<PreparedIndexPuts>> {
337        let mut out = Vec::with_capacity(frames.len());
338        let mut frame_idx = start_idx;
339        for fr in frames {
340            let mut buf = Cursor::new(Vec::new());
341            {
342                let mut w = ConFrameWriter::new(&mut buf);
343                w.write_frame(fr)
344                    .map_err(|e| Error::Parse(format!("serialize: {e}")))?;
345            }
346            let blob = String::from_utf8(buf.into_inner())
347                .map_err(|e| Error::Message(format!("utf8: {e}")))?;
348            let fk = FrameKey {
349                traj_id,
350                frame_idx,
351            };
352            out.push(Self::build_index_puts(fk, fr, blob));
353            frame_idx += 1;
354        }
355        Ok(out)
356    }
357
358    /// Short exclusive commit: traj_meta guard + puts only (no parse / no ConFrameWriter).
359    fn commit_prepared(
360        &self,
361        traj_id: TrajId,
362        prepared: &[PreparedIndexPuts],
363        source: String,
364        replace_meta: bool,
365    ) -> Result<u32> {
366        let mut wtxn = self.env.write_txn()?;
367        let tid_key = traj_id.to_be_bytes();
368        if !replace_meta {
369            if let Some(existing) = self.traj_meta.get(&wtxn, &tid_key[..])? {
370                let meta: TrajMeta = serde_json::from_str(existing)?;
371                return Err(Error::TrajExists(traj_id, meta.n_frames));
372            }
373        }
374        for p in prepared {
375            self.put_index_puts(&mut wtxn, p)?;
376        }
377        let n_frames = prepared
378            .last()
379            .map(|p| p.fk.frame_idx + 1)
380            .unwrap_or(0);
381        let meta = TrajMeta {
382            n_frames,
383            source,
384        };
385        self.traj_meta
386            .put(&mut wtxn, &tid_key[..], &serde_json::to_string(&meta)?)?;
387        wtxn.commit()?;
388        Ok(n_frames)
389    }
390
391    pub fn append_trajectory_path(&self, traj_id: TrajId, file: impl AsRef<Path>) -> Result<u32> {
392        let text = fs::read_to_string(file.as_ref())?;
393        self.append_trajectory_str(traj_id, &text, file.as_ref().display().to_string())
394    }
395
396    pub fn append_trajectory_str(
397        &self,
398        traj_id: TrajId,
399        file_contents: &str,
400        source: impl Into<String>,
401    ) -> Result<u32> {
402        let source = source.into();
403        // Prepare outside exclusive write section (parallel writers can parse concurrently).
404        let prepared = Self::prepare_trajectory_str(traj_id, file_contents)?;
405        self.commit_prepared(traj_id, &prepared, source, false)
406    }
407
408    /// Ingest already-parsed frames (chemfiles / builder path). Serialize in prepare, not under write_txn.
409    pub fn append_trajectory_frames(
410        &self,
411        traj_id: TrajId,
412        frames: &[ConFrame],
413        source: impl Into<String>,
414    ) -> Result<u32> {
415        let source = source.into();
416        let prepared = Self::prepare_trajectory_frames(traj_id, frames, 0)?;
417        self.commit_prepared(traj_id, &prepared, source, false)
418    }
419
420    /// Append frames to an existing trajectory (or create it). Returns new total frame count.
421    /// Concurrent extend of the **same** `traj_id` is rejected if `n_frames` moved (TOCTOU guard).
422    pub fn extend_trajectory_frames(
423        &self,
424        traj_id: TrajId,
425        frames: &[ConFrame],
426        source_hint: impl Into<String>,
427    ) -> Result<u32> {
428        let (start_idx, source) = {
429            let rtxn = self.env.read_txn()?;
430            let tid_key = traj_id.to_be_bytes();
431            if let Some(existing) = self.traj_meta.get(&rtxn, &tid_key[..])? {
432                let meta: TrajMeta = serde_json::from_str(existing)?;
433                (meta.n_frames, meta.source)
434            } else {
435                (0u32, source_hint.into())
436            }
437        };
438        // Key derivation fully outside exclusive section.
439        let prepared = Self::prepare_trajectory_frames(traj_id, frames, start_idx)?;
440        let mut wtxn = self.env.write_txn()?;
441        let tid_key = traj_id.to_be_bytes();
442        let live_n = if let Some(existing) = self.traj_meta.get(&wtxn, &tid_key[..])? {
443            let meta: TrajMeta = serde_json::from_str(existing)?;
444            meta.n_frames
445        } else {
446            0u32
447        };
448        if live_n != start_idx {
449            return Err(Error::Message(format!(
450                "concurrent extend race on traj {traj_id}: expected start {start_idx}, live {live_n}"
451            )));
452        }
453        for p in &prepared {
454            self.put_index_puts(&mut wtxn, p)?;
455        }
456        let n_frames = prepared
457            .last()
458            .map(|p| p.fk.frame_idx + 1)
459            .unwrap_or(start_idx);
460        let meta = TrajMeta {
461            n_frames,
462            source,
463        };
464        self.traj_meta
465            .put(&mut wtxn, &tid_key[..], &serde_json::to_string(&meta)?)?;
466        wtxn.commit()?;
467        Ok(n_frames)
468    }
469
470    fn clear_secondary(&self, wtxn: &mut RwTxn) -> Result<()> {
471        self.idx_natoms.clear(wtxn)?;
472        self.idx_symbol.clear(wtxn)?;
473        self.idx_energy.clear(wtxn)?;
474        self.idx_flags.clear(wtxn)?;
475        self.idx_elem_count.clear(wtxn)?;
476        self.idx_formula.clear(wtxn)?;
477        self.idx_fmax.clear(wtxn)?;
478        self.idx_mass.clear(wtxn)?;
479        self.idx_volume.clear(wtxn)?;
480        self.idx_pbc.clear(wtxn)?;
481        self.idx_meta.clear(wtxn)?;
482        self.frame_by_hash.clear(wtxn)?;
483        self.hash_by_frame.clear(wtxn)?;
484        Ok(())
485    }
486
487    /// Rebuild all secondary indexes from authoritative `frames` blobs (schema upgrade path).
488    pub fn reindex(&self) -> Result<u32> {
489        let mut wtxn = self.env.write_txn()?;
490        self.clear_secondary(&mut wtxn)?;
491
492        let mut n = 0u32;
493        // Collect keys first (heed iterator + puts on same txn is safer with snapshot list)
494        let mut keys: Vec<Vec<u8>> = Vec::new();
495        {
496            let mut iter = self.frames.iter(&wtxn)?;
497            while let Some(Ok((k, _))) = iter.next() {
498                keys.push(k.to_vec());
499            }
500        }
501        for k in keys {
502            let Some(fk) = FrameKey::from_bytes(&k) else {
503                continue;
504            };
505            let Some(blob) = self.frames.get(&wtxn, &k)? else {
506                continue;
507            };
508            let blob_owned = blob.to_owned();
509            let mut it = ConFrameIterator::new(&blob_owned);
510            let frame = it
511                .next()
512                .ok_or_else(|| Error::Parse("empty blob on reindex".into()))?
513                .map_err(|e| Error::Parse(e.to_string()))?;
514            // index_frame would put frames again — only indexes + hashes
515            let hash = hash_frame_bytes(blob_owned.as_bytes());
516            let hb = hash.to_bytes();
517            let fk_b = fk.to_bytes();
518            self.hash_by_frame.put(&mut wtxn, &fk_b[..], &hb[..])?;
519            if self.frame_by_hash.get(&wtxn, &hb[..])?.is_none() {
520                self.frame_by_hash.put(&mut wtxn, &hb[..], &fk_b[..])?;
521            }
522            let n_atoms = frame.atom_data.len() as u32;
523            self.idx_natoms
524                .put(&mut wtxn, &natoms_key(n_atoms, fk)[..], &())?;
525            let counts = frame_species(&frame);
526            for (sym, cnt) in &counts {
527                self.idx_elem_count
528                    .put(&mut wtxn, &elem_count_key(sym, *cnt, fk)[..], &())?;
529                self.idx_symbol
530                    .put(&mut wtxn, &symbol_key(sym, fk)[..], &())?;
531            }
532            let formula = composition_formula(&counts);
533            if !formula.is_empty() {
534                self.idx_formula
535                    .put(&mut wtxn, &formula_key(&formula, fk)[..], &())?;
536            }
537            if let Some(e) = frame_energy(&frame) {
538                if let Some(ek) = energy_bin_key(e, fk) {
539                    self.idx_energy.put(&mut wtxn, &ek[..], &())?;
540                }
541                self.idx_flags
542                    .put(&mut wtxn, &flag_key(FLAG_HAS_ENERGY, fk)[..], &())?;
543            }
544            if frame_has_forces(&frame) {
545                self.idx_flags
546                    .put(&mut wtxn, &flag_key(FLAG_HAS_FORCES, fk)[..], &())?;
547                if let Some(fm) = frame_fmax(&frame) {
548                    if let Some(fk_fm) = fmax_bin_key(fm, fk) {
549                        self.idx_fmax.put(&mut wtxn, &fk_fm[..], &())?;
550                    }
551                }
552            }
553            if frame_has_velocities(&frame) {
554                self.idx_flags
555                    .put(&mut wtxn, &flag_key(FLAG_HAS_VELOCITIES, fk)[..], &())?;
556            }
557            if let Some(m) = frame_total_mass(&frame) {
558                if let Some(k) = mass_bin_key(m, fk) {
559                    self.idx_mass.put(&mut wtxn, &k[..], &())?;
560                }
561            }
562            if let Some(v) = frame_cell_volume(&frame) {
563                if let Some(k) = volume_bin_key(v, fk) {
564                    self.idx_volume.put(&mut wtxn, &k[..], &())?;
565                }
566            }
567            if let Some(mask) = frame_pbc_mask(&frame) {
568                self.idx_pbc.put(&mut wtxn, &pbc_key(mask, fk)[..], &())?;
569            }
570            let meta_puts: [(u8, Option<f64>); 7] = [
571                (META_TIME, frame_time(&frame)),
572                (META_TIMESTEP, frame_timestep(&frame)),
573                (META_FRAME_INDEX, frame_frame_index(&frame)),
574                (META_NEB_BEAD, frame_neb_bead(&frame)),
575                (META_NEB_BAND, frame_neb_band(&frame)),
576                (META_CHARGE, frame_charge(&frame)),
577                (META_MAGMOM, frame_magmom(&frame)),
578            ];
579            for (ch, val) in meta_puts {
580                if let Some(x) = val {
581                    if let Some(k) = meta_scalar_key(ch, x, fk) {
582                        self.idx_meta.put(&mut wtxn, &k[..], &())?;
583                    }
584                }
585            }
586            n += 1;
587        }
588        wtxn.commit()?;
589        Ok(n)
590    }
591
592    pub fn traj_meta(&self, traj_id: TrajId) -> Result<Option<TrajMeta>> {
593        let rtxn = self.env.read_txn()?;
594        let tid_key = traj_id.to_be_bytes();
595        match self.traj_meta.get(&rtxn, &tid_key[..])? {
596            None => Ok(None),
597            Some(s) => Ok(Some(serde_json::from_str(s)?)),
598        }
599    }
600
601    pub fn get_frame_text(&self, key: FrameKey) -> Result<String> {
602        let rtxn = self.env.read_txn()?;
603        self.get_frame_text_txn(&rtxn, key)
604    }
605
606    fn get_frame_text_txn(
607        &self,
608        rtxn: &heed::RoTxn<'_>,
609        key: FrameKey,
610    ) -> Result<String> {
611        let fk_b = key.to_bytes();
612        match self.frames.get(rtxn, &fk_b[..])? {
613            Some(s) => Ok(s.to_owned()),
614            None => Err(Error::MissingFrame(key)),
615        }
616    }
617
618    /// Batch point-gets under **one** read transaction (extract / multi-reader hot path).
619    pub fn get_frame_texts(&self, keys: &[FrameKey]) -> Result<Vec<String>> {
620        let rtxn = self.env.read_txn()?;
621        let mut out = Vec::with_capacity(keys.len());
622        for &key in keys {
623            out.push(self.get_frame_text_txn(&rtxn, key)?);
624        }
625        Ok(out)
626    }
627
628    /// Materialize every frame blob for `traj_id` in **one** read txn (full extract hot path).
629    ///
630    /// Copies each LMDB value into an owned `String` and folds bytes so the fair
631    /// campaign pays for payload access—not merely `mmap` slice length.
632    /// Returns `(sum of lengths, byte checksum)` for observability.
633    pub fn touch_trajectory_blobs(&self, traj_id: TrajId, n_frames: u32) -> Result<(u64, u64)> {
634        let rtxn = self.env.read_txn()?;
635        let mut total = 0u64;
636        let mut checksum = 0u64;
637        for frame_idx in 0..n_frames {
638            let key = FrameKey {
639                traj_id,
640                frame_idx,
641            };
642            // Owned copy — same cost class as get_frame_text, batched under one txn.
643            let owned = self.get_frame_text_txn(&rtxn, key)?;
644            total += owned.len() as u64;
645            for (i, b) in owned.as_bytes().iter().enumerate() {
646                checksum = checksum.wrapping_add((*b as u64).wrapping_mul((i as u64).wrapping_add(1)));
647            }
648        }
649        // Prevent dead-code elimination of the fold in optimized builds used by benchmarks.
650        std::hint::black_box(checksum);
651        Ok((total, checksum))
652    }
653
654    pub fn get_frame(&self, key: FrameKey) -> Result<ConFrame> {
655        let text = self.get_frame_text(key)?;
656        let mut it = ConFrameIterator::new(&text);
657        let fr = it
658            .next()
659            .ok_or_else(|| Error::Parse("empty blob".into()))?
660            .map_err(|e| Error::Parse(e.to_string()))?;
661        Ok(fr)
662    }
663
664    /// Raw optional cooked SoA bytes for `key` (None if never cooked).
665    /// Does **not** read `frames` (CON text).
666    pub fn get_cooked_soa_bytes(&self, key: FrameKey) -> Result<Option<Vec<u8>>> {
667        let rtxn = self.env.read_txn()?;
668        let fk_b = key.to_bytes();
669        Ok(self
670            .frames_soa
671            .get(&rtxn, &fk_b[..])?
672            .map(|b| b.to_vec()))
673    }
674
675    /// True if a **valid** RCSO blob is stored (decode succeeds). Missing/corrupt → false.
676    /// Authority APIs must not use this flag to skip CON text.
677    pub fn has_valid_cooked_soa(&self, key: FrameKey) -> Result<bool> {
678        Ok(self.get_cooked_soa(key)?.is_some())
679    }
680
681    /// Decode cooked SoA if present and valid; `None` if missing or corrupt (CON text unchanged).
682    /// Reads **only** `frames_soa`—no CON parse.
683    pub fn get_cooked_soa(&self, key: FrameKey) -> Result<Option<crate::cooked_soa::CookedSoa>> {
684        Ok(self
685            .get_cooked_soa_bytes(key)?
686            .and_then(|b| crate::cooked_soa::CookedSoa::try_decode(&b)))
687    }
688
689    /// Prefer valid cooked SoA (**no CON parse** on hit); otherwise parse CON text in `frames`.
690    /// RCSO is not fully equivalent to CON (no symbols/metadata/exact bytes)—do not omit `frames`.
691    pub fn get_positions(&self, key: FrameKey) -> Result<Vec<[f64; 3]>> {
692        // Fast path: frames_soa only.
693        if let Some(c) = self.get_cooked_soa(key)? {
694            return Ok(c.positions);
695        }
696        // Fallback: authoritative CON text → parse (never invent from indexes alone).
697        let fr = self.get_frame(key)?;
698        Ok(fr
699            .atom_data
700            .iter()
701            .map(|a| [a.x, a.y, a.z])
702            .collect())
703    }
704
705    /// Prefer cooked forces when the RCSO flag block is present (**no CON parse** on hit);
706    /// else parse CON (`None` if no forces on frame).
707    pub fn get_forces(&self, key: FrameKey) -> Result<Option<Vec<[f64; 3]>>> {
708        if let Some(c) = self.get_cooked_soa(key)? {
709            if c.forces.is_some() {
710                return Ok(c.forces);
711            }
712            // Cooked present but no forces block — fall through to CON for fidelity.
713        }
714        let fr = self.get_frame(key)?;
715        if !fr.atom_data.iter().any(|a| a.force.is_some()) {
716            return Ok(None);
717        }
718        Ok(Some(
719            fr.atom_data
720                .iter()
721                .map(|a| a.force.unwrap_or([0.0; 3]))
722                .collect(),
723        ))
724    }
725
726    /// Prefer cooked velocities when present; else parse CON.
727    pub fn get_velocities(&self, key: FrameKey) -> Result<Option<Vec<[f64; 3]>>> {
728        if let Some(c) = self.get_cooked_soa(key)? {
729            if c.velocities.is_some() {
730                return Ok(c.velocities);
731            }
732        }
733        let fr = self.get_frame(key)?;
734        if !fr.atom_data.iter().any(|a| a.velocity.is_some()) {
735            return Ok(None);
736        }
737        Ok(Some(
738            fr.atom_data
739                .iter()
740                .map(|a| a.velocity.unwrap_or([0.0; 3]))
741                .collect(),
742        ))
743    }
744
745    /// Cook one frame from authoritative CON text (overwrite existing cooked entry).
746    pub fn cook_frame(&self, key: FrameKey) -> Result<usize> {
747        let fr = self.get_frame(key)?;
748        let bytes = crate::cooked_soa::CookedSoa::encode_frame(&fr)?;
749        let mut wtxn = self.env.write_txn()?;
750        let fk_b = key.to_bytes();
751        self.frames_soa.put(&mut wtxn, &fk_b[..], &bytes[..])?;
752        wtxn.commit()?;
753        Ok(bytes.len())
754    }
755
756    /// Cook all frames that have CON text; returns number of frames cooked.
757    pub fn recook_all(&self) -> Result<u32> {
758        let keys = self.list_frame_keys()?;
759        let mut n = 0u32;
760        for k in keys {
761            self.cook_frame(k)?;
762            n += 1;
763        }
764        Ok(n)
765    }
766
767    /// Drop cooked tier for one key (CON text and indexes untouched).
768    pub fn delete_cooked_soa(&self, key: FrameKey) -> Result<()> {
769        let mut wtxn = self.env.write_txn()?;
770        let fk_b = key.to_bytes();
771        let _ = self.frames_soa.delete(&mut wtxn, &fk_b[..])?;
772        wtxn.commit()?;
773        Ok(())
774    }
775
776    /// Ingest path with optional cook-on-write (still stores CON text as authority).
777    pub fn append_trajectory_path_cook(
778        &self,
779        traj_id: TrajId,
780        file: impl AsRef<Path>,
781        cook: bool,
782    ) -> Result<u32> {
783        let n = self.append_trajectory_path(traj_id, file)?;
784        if cook {
785            for i in 0..n {
786                self.cook_frame(FrameKey {
787                    traj_id,
788                    frame_idx: i,
789                })?;
790            }
791        }
792        Ok(n)
793    }
794
795    pub fn frame_hash(&self, key: FrameKey) -> Result<ContentHash> {
796        let rtxn = self.env.read_txn()?;
797        let fk_b = key.to_bytes();
798        match self.hash_by_frame.get(&rtxn, &fk_b[..])? {
799            Some(b) => ContentHash::from_bytes(b).ok_or_else(|| Error::Message("bad hash".into())),
800            None => {
801                let text = self.get_frame_text(key)?;
802                Ok(hash_frame_bytes(text.as_bytes()))
803            }
804        }
805    }
806
807    pub fn find_by_hash(&self, hash: ContentHash) -> Result<Option<FrameKey>> {
808        let rtxn = self.env.read_txn()?;
809        let hb = hash.to_bytes();
810        match self.frame_by_hash.get(&rtxn, &hb[..])? {
811            Some(b) => Ok(FrameKey::from_bytes(b)),
812            None => Ok(None),
813        }
814    }
815
816    pub fn hash_con_text(text: &str) -> Result<ContentHash> {
817        let mut it = ConFrameIterator::new(text);
818        let frame = it
819            .next()
820            .ok_or_else(|| Error::Parse("no frame".into()))?
821            .map_err(|e| Error::Parse(e.to_string()))?;
822        let mut buf = Cursor::new(Vec::new());
823        {
824            let mut w = ConFrameWriter::new(&mut buf);
825            w.write_frame(&frame)
826                .map_err(|e| Error::Parse(format!("re-serialize: {e}")))?;
827        }
828        Ok(hash_frame_bytes(&buf.into_inner()))
829    }
830
831    /// Canonical formula for a stored frame (decode once).
832    pub fn frame_formula(&self, key: FrameKey) -> Result<String> {
833        let fr = self.get_frame(key)?;
834        Ok(composition_formula(&frame_species(&fr)))
835    }
836
837    pub fn select(&self, sel: &Select) -> Result<Vec<FrameKey>> {
838        let rtxn = self.env.read_txn()?;
839        let mut sets: Vec<BTreeSet<FrameKey>> = Vec::new();
840
841        if let Some(h) = sel.content_hash {
842            let mut s = BTreeSet::new();
843            if let Some(b) = self.frame_by_hash.get(&rtxn, &h[..])? {
844                if let Some(fk) = FrameKey::from_bytes(b) {
845                    if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
846                        s.insert(fk);
847                    }
848                }
849            }
850            sets.push(s);
851        }
852
853        if sel.natoms_min.is_some() || sel.natoms_max.is_some() {
854            let lo = sel.natoms_min.unwrap_or(0);
855            let hi = sel.natoms_max.unwrap_or(u32::MAX);
856            let mut s = BTreeSet::new();
857            let mut iter = self.idx_natoms.iter(&rtxn)?;
858            while let Some(Ok((k, _))) = iter.next() {
859                if k.len() < 16 {
860                    continue;
861                }
862                let mut nb = [0u8; 4];
863                nb.copy_from_slice(&k[..4]);
864                let n = u32::from_be_bytes(nb);
865                if n > hi {
866                    break;
867                }
868                if n >= lo {
869                    if let Some(fk) = FrameKey::from_bytes(&k[4..16]) {
870                        if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
871                            s.insert(fk);
872                        }
873                    }
874                }
875            }
876            sets.push(s);
877        }
878
879        for sym in &sel.symbols_all {
880            let mut s = BTreeSet::new();
881            let pref = symbol_prefix(sym);
882            let mut iter = self.idx_symbol.prefix_iter(&rtxn, &pref)?;
883            while let Some(Ok((k, _))) = iter.next() {
884                if k.len() < 12 {
885                    continue;
886                }
887                let fk_bytes = &k[k.len() - 12..];
888                if let Some(fk) = FrameKey::from_bytes(fk_bytes) {
889                    if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
890                        s.insert(fk);
891                    }
892                }
893            }
894            sets.push(s);
895        }
896
897        if let Some(ref formula) = sel.exact_formula {
898            let mut s = BTreeSet::new();
899            let pref = formula_prefix(formula);
900            let mut iter = self.idx_formula.prefix_iter(&rtxn, &pref)?;
901            while let Some(Ok((k, _))) = iter.next() {
902                if k.len() < 12 {
903                    continue;
904                }
905                let fk_bytes = &k[k.len() - 12..];
906                if let Some(fk) = FrameKey::from_bytes(fk_bytes) {
907                    if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
908                        s.insert(fk);
909                    }
910                }
911            }
912            sets.push(s);
913        }
914
915        for (sym, exact_c) in &sel.element_count_exact {
916            let mut s = BTreeSet::new();
917            let pref = elem_count_symbol_prefix(sym);
918            let mut iter = self.idx_elem_count.prefix_iter(&rtxn, &pref)?;
919            while let Some(Ok((k, _))) = iter.next() {
920                if let Some((c, fk)) = parse_elem_count_key(k, sym) {
921                    if c == *exact_c && sel.traj_id.is_none_or(|t| t == fk.traj_id) {
922                        s.insert(fk);
923                    }
924                }
925            }
926            sets.push(s);
927        }
928
929        for (sym, min_c) in &sel.element_count_min {
930            let mut s = BTreeSet::new();
931            let pref = elem_count_symbol_prefix(sym);
932            let mut iter = self.idx_elem_count.prefix_iter(&rtxn, &pref)?;
933            while let Some(Ok((k, _))) = iter.next() {
934                if let Some((c, fk)) = parse_elem_count_key(k, sym) {
935                    if c >= *min_c && sel.traj_id.is_none_or(|t| t == fk.traj_id) {
936                        s.insert(fk);
937                    }
938                }
939            }
940            sets.push(s);
941        }
942
943        if sel.energy_min.is_some() || sel.energy_max.is_some() {
944            let lo_e = sel.energy_min.unwrap_or(f64::NEG_INFINITY);
945            let hi_e = sel.energy_max.unwrap_or(f64::INFINITY);
946            let lo_bits = ordered_f64_bits(lo_e).unwrap_or(0);
947            let hi_bits = ordered_f64_bits(hi_e).unwrap_or(u64::MAX);
948            let mut s = BTreeSet::new();
949            let mut iter = self.idx_energy.iter(&rtxn)?;
950            while let Some(Ok((k, _))) = iter.next() {
951                if k.len() < 20 {
952                    continue;
953                }
954                let mut eb = [0u8; 8];
955                eb.copy_from_slice(&k[..8]);
956                let bits = u64::from_be_bytes(eb);
957                if bits > hi_bits {
958                    break;
959                }
960                if bits >= lo_bits {
961                    if let Some(fk) = FrameKey::from_bytes(&k[8..20]) {
962                        if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
963                            s.insert(fk);
964                        }
965                    }
966                }
967            }
968            sets.push(s);
969        }
970
971        if sel.fmax_min.is_some() || sel.fmax_max.is_some() {
972            let lo_e = sel.fmax_min.unwrap_or(0.0);
973            let hi_e = sel.fmax_max.unwrap_or(f64::INFINITY);
974            let lo_bits = ordered_f64_bits(lo_e).unwrap_or(0);
975            let hi_bits = ordered_f64_bits(hi_e).unwrap_or(u64::MAX);
976            let mut s = BTreeSet::new();
977            let mut iter = self.idx_fmax.iter(&rtxn)?;
978            while let Some(Ok((k, _))) = iter.next() {
979                if k.len() < 20 {
980                    continue;
981                }
982                let mut eb = [0u8; 8];
983                eb.copy_from_slice(&k[..8]);
984                let bits = u64::from_be_bytes(eb);
985                if bits > hi_bits {
986                    break;
987                }
988                if bits >= lo_bits {
989                    if let Some(fk) = FrameKey::from_bytes(&k[8..20]) {
990                        if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
991                            s.insert(fk);
992                        }
993                    }
994                }
995            }
996            sets.push(s);
997        }
998
999        let scan_ord = |db: Database<Bytes, Unit>, lo: Option<f64>, hi: Option<f64>| -> Result<BTreeSet<FrameKey>> {
1000            let lo_e = lo.unwrap_or(f64::NEG_INFINITY);
1001            let hi_e = hi.unwrap_or(f64::INFINITY);
1002            let lo_bits = ordered_f64_bits(lo_e).unwrap_or(0);
1003            let hi_bits = ordered_f64_bits(hi_e).unwrap_or(u64::MAX);
1004            let mut s = BTreeSet::new();
1005            let mut iter = db.iter(&rtxn)?;
1006            while let Some(Ok((k, _))) = iter.next() {
1007                if k.len() < 20 {
1008                    continue;
1009                }
1010                let mut eb = [0u8; 8];
1011                eb.copy_from_slice(&k[..8]);
1012                let bits = u64::from_be_bytes(eb);
1013                if bits > hi_bits {
1014                    break;
1015                }
1016                if bits >= lo_bits {
1017                    if let Some(fk) = FrameKey::from_bytes(&k[8..20]) {
1018                        if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
1019                            s.insert(fk);
1020                        }
1021                    }
1022                }
1023            }
1024            Ok(s)
1025        };
1026
1027        if sel.mass_min.is_some() || sel.mass_max.is_some() {
1028            sets.push(scan_ord(self.idx_mass, sel.mass_min, sel.mass_max)?);
1029        }
1030        if sel.volume_min.is_some() || sel.volume_max.is_some() {
1031            sets.push(scan_ord(self.idx_volume, sel.volume_min, sel.volume_max)?);
1032        }
1033
1034        if let Some(pbc) = sel.pbc_exact {
1035            let mask = pbc_mask_from_bools(pbc);
1036            let mut s = BTreeSet::new();
1037            let pref = [mask];
1038            let mut iter = self.idx_pbc.prefix_iter(&rtxn, &pref)?;
1039            while let Some(Ok((k, _))) = iter.next() {
1040                if k.len() < 13 {
1041                    continue;
1042                }
1043                if let Some(fk) = FrameKey::from_bytes(&k[1..13]) {
1044                    if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
1045                        s.insert(fk);
1046                    }
1047                }
1048            }
1049            sets.push(s);
1050        }
1051
1052        let scan_meta = |ch: u8, lo: Option<f64>, hi: Option<f64>| -> Result<BTreeSet<FrameKey>> {
1053            let lo_e = lo.unwrap_or(f64::NEG_INFINITY);
1054            let hi_e = hi.unwrap_or(f64::INFINITY);
1055            let lo_bits = ordered_f64_bits(lo_e).unwrap_or(0);
1056            let hi_bits = ordered_f64_bits(hi_e).unwrap_or(u64::MAX);
1057            let mut s = BTreeSet::new();
1058            let pref = [ch];
1059            let mut iter = self.idx_meta.prefix_iter(&rtxn, &pref)?;
1060            while let Some(Ok((k, _))) = iter.next() {
1061                if k.len() < 21 {
1062                    continue;
1063                }
1064                let mut eb = [0u8; 8];
1065                eb.copy_from_slice(&k[1..9]);
1066                let bits = u64::from_be_bytes(eb);
1067                if bits > hi_bits {
1068                    continue; // prefix iter not fully ordered across all, but within channel it is
1069                }
1070                if bits >= lo_bits {
1071                    if let Some(fk) = FrameKey::from_bytes(&k[9..21]) {
1072                        if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
1073                            s.insert(fk);
1074                        }
1075                    }
1076                }
1077            }
1078            Ok(s)
1079        };
1080
1081        if sel.time_min.is_some() || sel.time_max.is_some() {
1082            sets.push(scan_meta(META_TIME, sel.time_min, sel.time_max)?);
1083        }
1084        if sel.timestep_min.is_some() || sel.timestep_max.is_some() {
1085            sets.push(scan_meta(META_TIMESTEP, sel.timestep_min, sel.timestep_max)?);
1086        }
1087        if sel.frame_index_min.is_some() || sel.frame_index_max.is_some() {
1088            sets.push(scan_meta(META_FRAME_INDEX, sel.frame_index_min, sel.frame_index_max)?);
1089        }
1090        if sel.neb_bead_min.is_some() || sel.neb_bead_max.is_some() {
1091            sets.push(scan_meta(META_NEB_BEAD, sel.neb_bead_min, sel.neb_bead_max)?);
1092        }
1093        if sel.neb_band_min.is_some() || sel.neb_band_max.is_some() {
1094            sets.push(scan_meta(META_NEB_BAND, sel.neb_band_min, sel.neb_band_max)?);
1095        }
1096        if sel.charge_min.is_some() || sel.charge_max.is_some() {
1097            sets.push(scan_meta(META_CHARGE, sel.charge_min, sel.charge_max)?);
1098        }
1099        if sel.magmom_min.is_some() || sel.magmom_max.is_some() {
1100            sets.push(scan_meta(META_MAGMOM, sel.magmom_min, sel.magmom_max)?);
1101        }
1102
1103        let mut push_flag = |flag: u8| -> Result<()> {
1104            let mut s = BTreeSet::new();
1105            let pref = [flag];
1106            let mut iter = self.idx_flags.prefix_iter(&rtxn, &pref)?;
1107            while let Some(Ok((k, _))) = iter.next() {
1108                if k.len() < 13 {
1109                    continue;
1110                }
1111                if let Some(fk) = FrameKey::from_bytes(&k[1..13]) {
1112                    if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
1113                        s.insert(fk);
1114                    }
1115                }
1116            }
1117            sets.push(s);
1118            Ok(())
1119        };
1120        if sel.require_forces {
1121            push_flag(FLAG_HAS_FORCES)?;
1122        }
1123        if sel.require_velocities {
1124            push_flag(FLAG_HAS_VELOCITIES)?;
1125        }
1126        if sel.require_energy {
1127            push_flag(FLAG_HAS_ENERGY)?;
1128        }
1129
1130        if sets.is_empty() {
1131            let mut s = BTreeSet::new();
1132            let mut iter = self.frames.iter(&rtxn)?;
1133            while let Some(Ok((k, _))) = iter.next() {
1134                if let Some(fk) = FrameKey::from_bytes(k) {
1135                    if sel.traj_id.is_none_or(|t| t == fk.traj_id) {
1136                        s.insert(fk);
1137                    }
1138                }
1139            }
1140            sets.push(s);
1141        } else if let Some(tid) = sel.traj_id {
1142            for set in &mut sets {
1143                set.retain(|fk| fk.traj_id == tid);
1144            }
1145        }
1146
1147        // Intersect smallest-first to minimize temporary allocations.
1148        sets.sort_by_key(|s| s.len());
1149        let mut acc = sets.remove(0);
1150        for s in sets {
1151            if acc.is_empty() {
1152                break;
1153            }
1154            if s.len() < acc.len() {
1155                acc = s.intersection(&acc).copied().collect();
1156            } else {
1157                acc = acc.intersection(&s).copied().collect();
1158            }
1159        }
1160
1161        let mut out: Vec<FrameKey> = acc.into_iter().collect();
1162        out.sort_unstable();
1163        if let Some(lim) = sel.limit {
1164            out.truncate(lim);
1165        }
1166        Ok(out)
1167    }
1168
1169    pub fn export_extxyz(
1170        &self,
1171        keys: &[FrameKey],
1172        path: impl AsRef<Path>,
1173        energy_key: &str,
1174    ) -> Result<usize> {
1175        use std::fs::File;
1176        use std::io::BufWriter;
1177        let mut w = BufWriter::new(File::create(path)?);
1178        let mut n = 0usize;
1179        for k in keys {
1180            let fr = self.get_frame(*k)?;
1181            write_frame_extxyz(&mut w, &fr, energy_key)?;
1182            n += 1;
1183        }
1184        Ok(n)
1185    }
1186
1187    pub fn ingest_directory(
1188        &self,
1189        dir: impl AsRef<Path>,
1190        start_traj_id: TrajId,
1191    ) -> Result<Vec<(TrajId, u32, String)>> {
1192        let mut out = Vec::new();
1193        let mut tid = start_traj_id;
1194        let mut paths: Vec<_> = fs::read_dir(dir)?
1195            .filter_map(|e| e.ok())
1196            .map(|e| e.path())
1197            .filter(|p| {
1198                let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
1199                name.ends_with(".con") || name.ends_with(".convel")
1200            })
1201            .collect();
1202        paths.sort();
1203        for p in paths {
1204            let n = self.append_trajectory_path(tid, &p)?;
1205            out.push((tid, n, p.display().to_string()));
1206            tid += 1;
1207        }
1208        Ok(out)
1209    }
1210
1211    pub fn unique_frame_keys(&self, sel: &Select) -> Result<Vec<FrameKey>> {
1212        let keys = self.select(sel)?;
1213        let mut uniq = Vec::new();
1214        for k in keys {
1215            let h = self.frame_hash(k)?;
1216            if self.find_by_hash(h)? == Some(k) {
1217                uniq.push(k);
1218            }
1219        }
1220        Ok(uniq)
1221    }
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226    use super::*;
1227    use std::path::PathBuf;
1228
1229    fn fixture(name: &str) -> PathBuf {
1230        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1231            .join("../readcon-core/resources/test")
1232            .join(name)
1233    }
1234
1235    fn fixtures_dir() -> PathBuf {
1236        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../readcon-core/resources/test")
1237    }
1238
1239    #[test]
1240    fn ingest_hash_dedup_and_select() {
1241        let dir = tempfile::tempdir().unwrap();
1242        let db = ConCorpus::open(dir.path()).unwrap();
1243        let n = db
1244            .append_trajectory_path(1, fixture("tiny_cuh2.con"))
1245            .unwrap();
1246        assert!(n >= 1);
1247        let k0 = FrameKey {
1248            traj_id: 1,
1249            frame_idx: 0,
1250        };
1251        let h = db.frame_hash(k0).unwrap();
1252        assert_eq!(db.find_by_hash(h).unwrap(), Some(k0));
1253
1254        let by_hash = db
1255            .select(&Select::new().exact_hash(h.to_bytes()))
1256            .unwrap();
1257        assert_eq!(by_hash, vec![k0]);
1258
1259        let cu = db
1260            .select(&Select::new().require_symbol("Cu").trajectory(1))
1261            .unwrap();
1262        assert!(!cu.is_empty());
1263
1264        let n2 = db
1265            .append_trajectory_path(2, fixture("tiny_multi_cuh2.con"))
1266            .unwrap();
1267        assert!(n2 >= 2);
1268    }
1269
1270    #[test]
1271    fn workflow_metatrain_extxyz_export() {
1272        let dir = tempfile::tempdir().unwrap();
1273        let db = ConCorpus::open(dir.path().join("corpus")).unwrap();
1274        let ingested = db.ingest_directory(fixtures_dir(), 1).unwrap();
1275        assert!(ingested.len() >= 3);
1276
1277        let keys = db
1278            .select(
1279                &Select::new()
1280                    .require_symbol("Cu")
1281                    .natoms_range(1, 10_000)
1282                    .limit(50),
1283            )
1284            .unwrap();
1285        assert!(!keys.is_empty());
1286
1287        let uniq = db
1288            .unique_frame_keys(&Select::new().require_symbol("Cu"))
1289            .unwrap();
1290        assert!(!uniq.is_empty());
1291
1292        let xyz = dir.path().join("train_subset.xyz");
1293        let n = db.export_extxyz(&uniq, &xyz, "energy").unwrap();
1294        assert_eq!(n, uniq.len());
1295        let text = std::fs::read_to_string(&xyz).unwrap();
1296        assert!(text.contains("Lattice="));
1297        assert!(text.contains("Properties="));
1298        assert!(text.lines().any(|l| l.trim_start().starts_with("Cu ")));
1299    }
1300
1301    #[test]
1302    fn workflow_dedup_identical_ingest() {
1303        let dir = tempfile::tempdir().unwrap();
1304        let db = ConCorpus::open(dir.path()).unwrap();
1305        let f = fixture("tiny_cuh2.con");
1306        db.append_trajectory_path(1, &f).unwrap();
1307        db.append_trajectory_path(2, &f).unwrap();
1308        let k1 = FrameKey {
1309            traj_id: 1,
1310            frame_idx: 0,
1311        };
1312        let k2 = FrameKey {
1313            traj_id: 2,
1314            frame_idx: 0,
1315        };
1316        let h1 = db.frame_hash(k1).unwrap();
1317        let h2 = db.frame_hash(k2).unwrap();
1318        assert_eq!(h1, h2);
1319        assert_eq!(db.find_by_hash(h1).unwrap(), Some(k1));
1320        let uniq = db.unique_frame_keys(&Select::new()).unwrap();
1321        assert!(uniq.contains(&k1));
1322        assert!(!uniq.contains(&k2));
1323    }
1324
1325    #[test]
1326    fn metadata_indexes_forces_velocities_energy() {
1327        let dir = tempfile::tempdir().unwrap();
1328        let db = ConCorpus::open(dir.path()).unwrap();
1329        db.append_trajectory_path(1, fixture("tiny_cuh2.con"))
1330            .unwrap();
1331        db.append_trajectory_path(2, fixture("tiny_cuh2_forces.con"))
1332            .unwrap();
1333        db.append_trajectory_path(3, fixture("tiny_cuh2.convel"))
1334            .unwrap();
1335        db.append_trajectory_path(4, fixture("tiny_cuh2_vel_forces.con"))
1336            .unwrap();
1337
1338        let forces = db.select(&Select::new().require_forces()).unwrap();
1339        assert!(forces.iter().any(|k| k.traj_id == 2));
1340        assert!(forces.iter().any(|k| k.traj_id == 4));
1341        assert!(!forces.iter().any(|k| k.traj_id == 1));
1342
1343        let vels = db.select(&Select::new().require_velocities()).unwrap();
1344        assert!(vels.iter().any(|k| k.traj_id == 3));
1345        assert!(vels.iter().any(|k| k.traj_id == 4));
1346
1347        let both = db
1348            .select(&Select::new().require_forces().require_velocities())
1349            .unwrap();
1350        assert!(both.iter().any(|k| k.traj_id == 4));
1351        assert!(!both.iter().any(|k| k.traj_id == 2));
1352
1353        let with_e = db.select(&Select::new().require_energy()).unwrap();
1354        assert!(with_e.iter().any(|k| k.traj_id == 2));
1355        assert!(with_e.iter().any(|k| k.traj_id == 4));
1356        assert!(!with_e.iter().any(|k| k.traj_id == 1));
1357
1358        let in_range = db
1359            .select(&Select::new().energy_range(-43.0, -42.0).require_forces())
1360            .unwrap();
1361        assert!(in_range.iter().any(|k| k.traj_id == 2));
1362        let miss = db.select(&Select::new().energy_range(0.0, 1.0)).unwrap();
1363        assert!(miss.is_empty());
1364    }
1365
1366    #[test]
1367    fn composition_and_fmax_indexes() {
1368        let dir = tempfile::tempdir().unwrap();
1369        let db = ConCorpus::open(dir.path()).unwrap();
1370        db.append_trajectory_path(1, fixture("tiny_cuh2.con"))
1371            .unwrap();
1372        db.append_trajectory_path(2, fixture("tiny_cuh2_forces.con"))
1373            .unwrap();
1374        db.append_trajectory_path(3, fixture("sulfolene.con"))
1375            .unwrap();
1376
1377        let formula = db
1378            .frame_formula(FrameKey {
1379                traj_id: 1,
1380                frame_idx: 0,
1381            })
1382            .unwrap();
1383        assert_eq!(formula, "Cu:2|H:2");
1384
1385        let exact = db
1386            .select(&Select::new().exact_composition("Cu:2|H:2"))
1387            .unwrap();
1388        assert!(exact.iter().any(|k| k.traj_id == 1));
1389        assert!(exact.iter().any(|k| k.traj_id == 2));
1390        assert!(!exact.iter().any(|k| k.traj_id == 3));
1391
1392        let cu2 = db
1393            .select(&Select::new().element_exact("Cu", 2).element_exact("H", 2))
1394            .unwrap();
1395        assert_eq!(cu2.len(), exact.len());
1396
1397        let cu_min2 = db.select(&Select::new().element_min("Cu", 2)).unwrap();
1398        assert!(cu_min2.iter().any(|k| k.traj_id == 1));
1399
1400        let wrong = db
1401            .select(&Select::new().exact_composition("Fe:1"))
1402            .unwrap();
1403        assert!(wrong.is_empty());
1404
1405        let fr_f = db
1406            .get_frame(FrameKey {
1407                traj_id: 2,
1408                frame_idx: 0,
1409            })
1410            .unwrap();
1411        let expected_fmax = frame_fmax(&fr_f).expect("forces fixture has fmax");
1412        let in_fmax = db
1413            .select(
1414                &Select::new()
1415                    .require_forces()
1416                    .fmax_range(0.0, expected_fmax + 1e-6),
1417            )
1418            .unwrap();
1419        assert!(in_fmax.iter().any(|k| k.traj_id == 2));
1420        assert!(!in_fmax.iter().any(|k| k.traj_id == 1));
1421
1422        let too_small = db
1423            .select(&Select::new().fmax_range(0.0, expected_fmax * 0.0 - 1.0))
1424            .unwrap();
1425        // negative max excludes all positive fmax postings that are > hi; lo>hi may be empty
1426        let impossible = db
1427            .select(&Select::new().fmax_range(1e9, 1e9 + 1.0))
1428            .unwrap();
1429        assert!(impossible.is_empty());
1430        let _ = too_small;
1431    }
1432
1433    #[test]
1434    fn reindex_and_append_frames() {
1435        let dir = tempfile::tempdir().unwrap();
1436        let db = ConCorpus::open(dir.path()).unwrap();
1437        db.append_trajectory_path(1, fixture("tiny_cuh2.con"))
1438            .unwrap();
1439        db.append_trajectory_path(2, fixture("tiny_cuh2_forces.con"))
1440            .unwrap();
1441
1442        let before = db
1443            .select(&Select::new().exact_composition("Cu:2|H:2"))
1444            .unwrap();
1445        assert!(!before.is_empty());
1446
1447        let n = db.reindex().unwrap();
1448        assert!(n >= 2);
1449        let after = db
1450            .select(&Select::new().exact_composition("Cu:2|H:2"))
1451            .unwrap();
1452        assert_eq!(before, after);
1453
1454        let fr = db
1455            .get_frame(FrameKey {
1456                traj_id: 1,
1457                frame_idx: 0,
1458            })
1459            .unwrap();
1460        let n2 = db
1461            .append_trajectory_frames(10, &[fr.clone()], "mem")
1462            .unwrap();
1463        assert_eq!(n2, 1);
1464        let mem = db
1465            .select(
1466                &Select::new()
1467                    .trajectory(10)
1468                    .exact_composition("Cu:2|H:2"),
1469            )
1470            .unwrap();
1471        assert_eq!(mem.len(), 1);
1472
1473        let n3 = db.extend_trajectory_frames(10, &[fr], "mem-ext").unwrap();
1474        assert_eq!(n3, 2);
1475    }
1476
1477    #[test]
1478    fn reindex_twice_stable() {
1479        let dir = tempfile::tempdir().unwrap();
1480        let db = ConCorpus::open(dir.path()).unwrap();
1481        db.append_trajectory_path(1, fixture("tiny_cuh2_forces.con"))
1482            .unwrap();
1483        let a = db.reindex().unwrap();
1484        let keys1 = db.select(&Select::new().require_forces()).unwrap();
1485        let b = db.reindex().unwrap();
1486        let keys2 = db.select(&Select::new().require_forces()).unwrap();
1487        assert_eq!(a, b);
1488        assert_eq!(keys1, keys2);
1489    }
1490
1491    #[test]
1492    fn batch_touch_and_texts() {
1493        let dir = tempfile::tempdir().unwrap();
1494        let db = ConCorpus::open(dir.path()).unwrap();
1495        let n = db
1496            .append_trajectory_path(1, fixture("tiny_cuh2.con"))
1497            .unwrap();
1498        assert!(n >= 1);
1499        let (total, checksum) = db.touch_trajectory_blobs(1, n).unwrap();
1500        assert!(total > 0);
1501        assert!(checksum > 0);
1502        let texts = db
1503            .get_frame_texts(&[FrameKey {
1504                traj_id: 1,
1505                frame_idx: 0,
1506            }])
1507            .unwrap();
1508        assert_eq!(texts.len(), 1);
1509        assert_eq!(texts[0].len() as u64, total);
1510        // Checksum must depend on payload bytes (not only length).
1511        let mut expect = 0u64;
1512        for (i, b) in texts[0].as_bytes().iter().enumerate() {
1513            expect = expect.wrapping_add((*b as u64).wrapping_mul((i as u64).wrapping_add(1)));
1514        }
1515        assert_eq!(checksum, expect);
1516    }
1517
1518    /// Concurrent threads append distinct traj IDs; prepare runs in parallel, LMDB commits serialize only.
1519    #[test]
1520    fn concurrent_writers_distinct_traj() {
1521        use std::sync::Arc;
1522        use std::thread;
1523        let dir = tempfile::tempdir().unwrap();
1524        let db = Arc::new(ConCorpus::open(dir.path()).unwrap());
1525        let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
1526        let mut handles = Vec::new();
1527        for tid in 1u64..=8 {
1528            let db = Arc::clone(&db);
1529            let text = text.clone();
1530            handles.push(thread::spawn(move || {
1531                db.append_trajectory_str(tid, &text, format!("t{tid}")).unwrap()
1532            }));
1533        }
1534        let mut counts = Vec::new();
1535        for h in handles {
1536            counts.push(h.join().unwrap());
1537        }
1538        assert!(counts.iter().all(|&c| c >= 1));
1539        for tid in 1u64..=8 {
1540            let meta = db.traj_meta(tid).unwrap().expect("meta");
1541            assert_eq!(meta.n_frames, counts[(tid - 1) as usize]);
1542            let _ = db
1543                .get_frame_text(FrameKey {
1544                    traj_id: tid,
1545                    frame_idx: 0,
1546                })
1547                .unwrap();
1548        }
1549        let cu = db.select(&Select::new().require_symbol("Cu")).unwrap();
1550        assert_eq!(cu.len(), 8);
1551    }
1552
1553    /// Multi-process: N OS processes each open the same env via CLI and select.
1554    #[test]
1555    fn multiproc_cli_concurrent_select() {
1556        use std::process::Command;
1557        let dir = tempfile::tempdir().unwrap();
1558        let corpus = dir.path().join("mp_corpus");
1559        {
1560            let db = ConCorpus::open(&corpus).unwrap();
1561            db.append_trajectory_path(1, fixture("tiny_cuh2.con"))
1562                .unwrap();
1563        }
1564        let Some(bin) = option_env!("CARGO_BIN_EXE_readcon-db") else {
1565            return;
1566        };
1567        let corpus_s = corpus.to_str().unwrap();
1568        let mut kids = Vec::new();
1569        for _ in 0..4 {
1570            kids.push(
1571                Command::new(bin)
1572                    .args(["select", corpus_s, "--symbol", "Cu"])
1573                    .stdout(std::process::Stdio::null())
1574                    .spawn()
1575                    .expect("spawn"),
1576            );
1577        }
1578        for mut c in kids {
1579            assert!(c.wait().unwrap().success());
1580        }
1581    }
1582
1583    #[test]
1584    fn ase_competitive_mass_volume_pbc_meta_charge() {
1585        use readcon_core::types::ConFrameBuilder;
1586        let dir = tempfile::tempdir().unwrap();
1587        let db = ConCorpus::open(dir.path()).unwrap();
1588
1589        // Frame A: Cu2H2 default cell from fixture path
1590        db.append_trajectory_path(1, fixture("tiny_cuh2.con")).unwrap();
1591        let fr1 = db
1592            .get_frame(FrameKey {
1593                traj_id: 1,
1594                frame_idx: 0,
1595            })
1596            .unwrap();
1597        let mass1 = frame_total_mass(&fr1).expect("mass");
1598        let vol1 = frame_cell_volume(&fr1).expect("vol");
1599
1600        // Frame B: inject metadata via rewrite
1601        let mut fr2 = fr1.clone();
1602        fr2.header.set_frame_index(7);
1603        fr2.header.set_time(1.5);
1604        fr2.header.set_timestep(0.25);
1605        fr2.header.set_neb_bead(2);
1606        fr2.header.set_neb_band(1);
1607        fr2.header.set_pbc([true, true, false]);
1608        fr2.header
1609            .metadata
1610            .insert("charge".into(), serde_json::json!(-1.0));
1611        fr2.header
1612            .metadata
1613            .insert("magmom".into(), serde_json::json!(2.0));
1614        // larger cell → larger volume
1615        fr2.header.boxl = [20.0, 20.0, 20.0];
1616        db.append_trajectory_frames(2, &[fr2], "meta").unwrap();
1617
1618        let by_mass = db
1619            .select(&Select::new().mass_range(mass1 - 1e-6, mass1 + 1e-6))
1620            .unwrap();
1621        assert!(by_mass.iter().any(|k| k.traj_id == 1));
1622
1623        let by_vol_small = db
1624            .select(&Select::new().volume_range(vol1 - 1.0, vol1 + 1.0))
1625            .unwrap();
1626        assert!(by_vol_small.iter().any(|k| k.traj_id == 1));
1627        assert!(!by_vol_small.iter().any(|k| k.traj_id == 2));
1628
1629        let pbc_match = db
1630            .select(&Select::new().pbc([true, true, false]))
1631            .unwrap();
1632        assert_eq!(pbc_match.len(), 1);
1633        assert_eq!(pbc_match[0].traj_id, 2);
1634        let pbc_miss = db.select(&Select::new().pbc([true, true, true])).unwrap();
1635        assert!(pbc_miss.is_empty());
1636
1637        let fi = db
1638            .select(&Select::new().frame_index_range(7.0, 7.0))
1639            .unwrap();
1640        assert_eq!(fi, vec![FrameKey { traj_id: 2, frame_idx: 0 }]);
1641        let fi_miss = db
1642            .select(&Select::new().frame_index_range(99.0, 100.0))
1643            .unwrap();
1644        assert!(fi_miss.is_empty());
1645
1646        let bead = db.select(&Select::new().neb_bead_range(2.0, 2.0)).unwrap();
1647        assert_eq!(bead.len(), 1);
1648        let t = db.select(&Select::new().time_range(1.4, 1.6)).unwrap();
1649        assert_eq!(t.len(), 1);
1650
1651        let dt = db
1652            .select(&Select::new().timestep_range(0.25, 0.25))
1653            .unwrap();
1654        assert_eq!(
1655            dt,
1656            vec![FrameKey {
1657                traj_id: 2,
1658                frame_idx: 0
1659            }]
1660        );
1661        let dt_miss = db
1662            .select(&Select::new().timestep_range(9.0, 10.0))
1663            .unwrap();
1664        assert!(dt_miss.is_empty());
1665        // traj 1 has no timestep metadata → must not satisfy a finite timestep window
1666        assert!(!dt.iter().any(|k| k.traj_id == 1));
1667
1668        let band = db.select(&Select::new().neb_band_range(1.0, 1.0)).unwrap();
1669        assert_eq!(
1670            band,
1671            vec![FrameKey {
1672                traj_id: 2,
1673                frame_idx: 0
1674            }]
1675        );
1676        let band_miss = db
1677            .select(&Select::new().neb_band_range(99.0, 100.0))
1678            .unwrap();
1679        assert!(band_miss.is_empty());
1680
1681        let ch = db.select(&Select::new().charge_range(-1.0, -1.0)).unwrap();
1682        assert_eq!(ch.len(), 1);
1683        let mm = db.select(&Select::new().magmom_range(2.0, 2.0)).unwrap();
1684        assert_eq!(mm.len(), 1);
1685        let ch_miss = db.select(&Select::new().charge_range(0.0, 0.0)).unwrap();
1686        assert!(ch_miss.is_empty());
1687
1688        let n = db.reindex().unwrap();
1689        assert!(n >= 2);
1690        let after = db
1691            .select(&Select::new().frame_index_range(7.0, 7.0))
1692            .unwrap();
1693        assert_eq!(after.len(), 1);
1694        let after_dt = db
1695            .select(&Select::new().timestep_range(0.25, 0.25))
1696            .unwrap();
1697        assert_eq!(after_dt.len(), 1);
1698        let after_band = db.select(&Select::new().neb_band_range(1.0, 1.0)).unwrap();
1699        assert_eq!(after_band.len(), 1);
1700
1701        // still composition
1702        let form = db
1703            .select(&Select::new().exact_composition("Cu:2|H:2"))
1704            .unwrap();
1705        assert!(form.len() >= 2);
1706    }
1707
1708    #[test]
1709    fn cooked_soa_roundtrip_and_authority() {
1710        let dir = tempfile::tempdir().unwrap();
1711        let db = ConCorpus::open(dir.path()).unwrap();
1712        let key = FrameKey {
1713            traj_id: 1,
1714            frame_idx: 0,
1715        };
1716        db.append_trajectory_path(1, fixture("tiny_cuh2.con")).unwrap();
1717        // default: no cooked
1718        assert!(db.get_cooked_soa_bytes(key).unwrap().is_none());
1719        let pos_via_parse = db.get_positions(key).unwrap();
1720        let fr = db.get_frame(key).unwrap();
1721        assert_eq!(pos_via_parse.len(), fr.atom_data.len());
1722        for (i, a) in fr.atom_data.iter().enumerate() {
1723            assert_eq!(pos_via_parse[i], [a.x, a.y, a.z]);
1724        }
1725
1726        let text_before = db.get_frame_text(key).unwrap();
1727        let h_before = db.frame_hash(key).unwrap();
1728        let nbytes = db.cook_frame(key).unwrap();
1729        assert!(nbytes > 0);
1730        let cooked = db.get_cooked_soa(key).unwrap().expect("cooked present");
1731        assert_eq!(cooked.natoms as usize, fr.atom_data.len());
1732        for (i, a) in fr.atom_data.iter().enumerate() {
1733            assert_eq!(cooked.positions[i], [a.x, a.y, a.z]);
1734        }
1735        // CON authority unchanged
1736        assert_eq!(db.get_frame_text(key).unwrap(), text_before);
1737        assert_eq!(db.frame_hash(key).unwrap().to_bytes(), h_before.to_bytes());
1738        assert_eq!(
1739            db.find_by_hash(h_before).unwrap(),
1740            Some(key)
1741        );
1742        let pos_cooked = db.get_positions(key).unwrap();
1743        assert_eq!(pos_cooked, cooked.positions);
1744
1745        // select still works
1746        let keys = db.select(&Select::new().require_symbol("Cu")).unwrap();
1747        assert!(keys.contains(&key));
1748
1749        // reindex ignores cooked; text still selects
1750        db.reindex().unwrap();
1751        assert!(db.get_cooked_soa(key).unwrap().is_some());
1752        assert!(db
1753            .select(&Select::new().require_symbol("Cu"))
1754            .unwrap()
1755            .contains(&key));
1756
1757        // delete cooked → fallback parse
1758        db.delete_cooked_soa(key).unwrap();
1759        assert!(db.get_cooked_soa_bytes(key).unwrap().is_none());
1760        let pos_again = db.get_positions(key).unwrap();
1761        assert_eq!(pos_again, pos_via_parse);
1762        assert_eq!(db.get_frame_text(key).unwrap(), text_before);
1763    }
1764
1765    #[test]
1766    fn cooked_soa_forces_and_cook_on_ingest() {
1767        let dir = tempfile::tempdir().unwrap();
1768        let db = ConCorpus::open(dir.path()).unwrap();
1769        db.append_trajectory_path_cook(3, fixture("tiny_cuh2_forces.con"), true)
1770            .unwrap();
1771        let key = FrameKey {
1772            traj_id: 3,
1773            frame_idx: 0,
1774        };
1775        assert!(db.get_cooked_soa_bytes(key).unwrap().is_some());
1776        let fr = db.get_frame(key).unwrap();
1777        let forces = db.get_forces(key).unwrap().expect("forces");
1778        for (i, a) in fr.atom_data.iter().enumerate() {
1779            if let Some(f) = a.force {
1780                assert_eq!(forces[i], f);
1781            }
1782        }
1783        // corrupt cooked → fallback
1784        let mut wtxn = db.env.write_txn().unwrap();
1785        let fk_b = key.to_bytes();
1786        db.frames_soa
1787            .put(&mut wtxn, &fk_b[..], b"XXXX_not_valid")
1788            .unwrap();
1789        wtxn.commit().unwrap();
1790        assert!(db.get_cooked_soa(key).unwrap().is_none());
1791        let pos = db.get_positions(key).unwrap();
1792        assert_eq!(pos.len(), fr.atom_data.len());
1793        assert_eq!(
1794            pos[0],
1795            [fr.atom_data[0].x, fr.atom_data[0].y, fr.atom_data[0].z]
1796        );
1797    }
1798
1799    #[test]
1800    fn cooked_soa_recook_all() {
1801        let dir = tempfile::tempdir().unwrap();
1802        let db = ConCorpus::open(dir.path()).unwrap();
1803        db.append_trajectory_path(1, fixture("tiny_cuh2.con")).unwrap();
1804        db.append_trajectory_path(2, fixture("tiny_cuh2_forces.con"))
1805            .unwrap();
1806        let n = db.recook_all().unwrap();
1807        assert_eq!(n, 2);
1808        assert!(db
1809            .get_cooked_soa(FrameKey {
1810                traj_id: 1,
1811                frame_idx: 0
1812            })
1813            .unwrap()
1814            .is_some());
1815        assert!(db
1816            .get_cooked_soa(FrameKey {
1817                traj_id: 2,
1818                frame_idx: 0
1819            })
1820            .unwrap()
1821            .is_some());
1822    }
1823
1824    /// RCSO cannot stand in for missing CON text (not fully equivalent).
1825    #[test]
1826    fn cooked_soa_cannot_replace_missing_frames_text() {
1827        let dir = tempfile::tempdir().unwrap();
1828        let db = ConCorpus::open(dir.path()).unwrap();
1829        let key = FrameKey {
1830            traj_id: 9,
1831            frame_idx: 0,
1832        };
1833        db.append_trajectory_path_cook(9, fixture("tiny_cuh2.con"), true)
1834            .unwrap();
1835        assert!(db.has_valid_cooked_soa(key).unwrap());
1836        let pos_ok = db.get_positions(key).unwrap();
1837        assert!(!pos_ok.is_empty());
1838        // Delete authoritative CON blob but leave RCSO (unsupported storage shape).
1839        {
1840            let mut wtxn = db.env.write_txn().unwrap();
1841            let fk_b = key.to_bytes();
1842            db.frames.delete(&mut wtxn, &fk_b[..]).unwrap();
1843            wtxn.commit().unwrap();
1844        }
1845        // Numeric tier may still decode RCSO...
1846        assert!(db.get_cooked_soa(key).unwrap().is_some());
1847        let pos_cooked_only = db.get_positions(key).unwrap();
1848        assert_eq!(pos_cooked_only, pos_ok);
1849        // ...but authority surfaces fail without CON text.
1850        assert!(db.get_frame_text(key).is_err());
1851        assert!(db.get_frame(key).is_err());
1852        // Hash reverse lookup still keyed off CON-era hash_by_frame may exist, but
1853        // reindex cannot rebuild without frames blob.
1854        assert!(db.frame_formula(key).is_err());
1855    }
1856
1857    #[test]
1858    fn numeric_fast_path_matches_con_twice() {
1859        let dir = tempfile::tempdir().unwrap();
1860        let db = ConCorpus::open(dir.path()).unwrap();
1861        let key = FrameKey {
1862            traj_id: 1,
1863            frame_idx: 0,
1864        };
1865        for _ in 0..2 {
1866            // fresh corpus each half would be overkill; exercise cook + dual path twice
1867        }
1868        db.append_trajectory_path(1, fixture("tiny_cuh2_forces.con"))
1869            .unwrap();
1870        let fr = db.get_frame(key).unwrap();
1871        let expect_pos: Vec<_> = fr.atom_data.iter().map(|a| [a.x, a.y, a.z]).collect();
1872        let expect_f: Vec<_> = fr
1873            .atom_data
1874            .iter()
1875            .map(|a| a.force.unwrap_or([0.0; 3]))
1876            .collect();
1877        // Without cook: parse path
1878        assert_eq!(db.get_positions(key).unwrap(), expect_pos);
1879        assert_eq!(db.get_forces(key).unwrap().unwrap(), expect_f);
1880        db.cook_frame(key).unwrap();
1881        assert!(db.has_valid_cooked_soa(key).unwrap());
1882        // With cook: fast path must match CON-derived atoms (shipped API, not re-impl)
1883        assert_eq!(db.get_positions(key).unwrap(), expect_pos);
1884        assert_eq!(db.get_forces(key).unwrap().unwrap(), expect_f);
1885        let text = db.get_frame_text(key).unwrap();
1886        let h = db.frame_hash(key).unwrap();
1887        db.delete_cooked_soa(key).unwrap();
1888        assert_eq!(db.get_frame_text(key).unwrap(), text);
1889        assert_eq!(db.frame_hash(key).unwrap().to_bytes(), h.to_bytes());
1890        assert_eq!(db.get_positions(key).unwrap(), expect_pos);
1891    }
1892}