Skip to main content

readcon_core/
index_proj.rs

1//! Campaign-store **index projection**: screening scalars and contracts derived from
2//! a parsed [`ConFrame`](crate::types::ConFrame) so corpora (e.g. `readcon-db`) do not
3//! fork CON semantics.
4//!
5//! # Finite scalars
6//! Energy, mass, volume, \(f_{\max}\), and metadata channels used for ordered indexes
7//! are included **only when finite** (`f64::is_finite`). NaN/Inf are omitted so B-tree
8//! range scans never see non-orderable keys.
9//!
10//! # Sections mask
11//! [`sections_present_mask`] / [`SECTIONS_MASK_*`] summarize forces / velocities /
12//! energies presence for flag indexes without scanning blobs twice.
13//!
14//! # Spans
15//! Multi-frame ingest should use [`crate::iterators::ConFrameIterator::next_with_raw_span`]
16//! so stored blobs are exact substrings; [`frame_byte_spans`] is a structural pre-pass
17//! (offsets only) for planning.
18
19use crate::types::{ConFrame, SECTION_ENERGIES, SECTION_FORCES, SECTION_VELOCITIES};
20use std::collections::BTreeMap;
21
22/// Bit 0: forces section or per-atom forces present.
23pub const SECTIONS_MASK_FORCES: u8 = 1 << 0;
24/// Bit 1: velocities section or per-atom velocities present.
25pub const SECTIONS_MASK_VELOCITIES: u8 = 1 << 1;
26/// Bit 2: energies section or finite frame energy present.
27pub const SECTIONS_MASK_ENERGIES: u8 = 1 << 2;
28
29/// Canonical multiset formula: sorted non-empty `Sym:count` joined by `|`.
30/// Example: Cu₂H₂ → `Cu:2|H:2`. Shared with campaign `idx_formula` keys.
31pub fn composition_formula(counts: &[(String, u32)]) -> String {
32    let mut parts: Vec<(String, u32)> = counts
33        .iter()
34        .filter(|(s, c)| !s.is_empty() && *c > 0)
35        .cloned()
36        .collect();
37    parts.sort_by(|a, b| a.0.cmp(&b.0));
38    parts
39        .into_iter()
40        .map(|(s, c)| format!("{s}:{c}"))
41        .collect::<Vec<_>>()
42        .join("|")
43}
44
45/// Species multiset from atom symbols (non-empty only), sorted by symbol.
46pub fn species_counts_from_symbols(
47    symbols: impl Iterator<Item = impl AsRef<str>>,
48) -> Vec<(String, u32)> {
49    let mut m = BTreeMap::new();
50    for s in symbols {
51        let s = s.as_ref();
52        if s.is_empty() {
53            continue;
54        }
55        *m.entry(s.to_string()).or_insert(0u32) += 1;
56    }
57    m.into_iter().collect()
58}
59
60/// Species multiset from a frame's `atom_data` symbols.
61pub fn frame_species_counts(frame: &ConFrame) -> Vec<(String, u32)> {
62    species_counts_from_symbols(frame.atom_data.iter().map(|a| a.symbol.as_ref()))
63}
64
65/// Canonical formula string for `frame` (empty string if no non-empty symbols).
66pub fn frame_composition_formula(frame: &ConFrame) -> String {
67    composition_formula(&frame_species_counts(frame))
68}
69
70/// Finite frame energy from header helper or metadata `"energy"`; **None** if missing or non-finite.
71pub fn finite_energy(frame: &ConFrame) -> Option<f64> {
72    frame.header.energy().filter(|e| e.is_finite()).or_else(|| {
73        frame
74            .header
75            .metadata
76            .get("energy")
77            .and_then(|v| v.as_f64())
78            .filter(|e| e.is_finite())
79    })
80}
81
82/// Max Euclidean \(\|F_i\|\) over atoms with force data; None if no finite forces.
83pub fn frame_fmax(frame: &ConFrame) -> Option<f64> {
84    let mut m = None;
85    for a in &frame.atom_data {
86        if let Some(f) = a.force {
87            let mag = (f[0] * f[0] + f[1] * f[1] + f[2] * f[2]).sqrt();
88            if mag.is_finite() {
89                m = Some(m.map_or(mag, |cur: f64| cur.max(mag)));
90            }
91        }
92    }
93    m
94}
95
96/// Total mass = Σ masses_per_type[i] * natms_per_type[i] (all finite).
97pub fn frame_total_mass(frame: &ConFrame) -> Option<f64> {
98    let h = &frame.header;
99    if h.masses_per_type.is_empty() || h.natms_per_type.is_empty() {
100        return None;
101    }
102    let n = h.masses_per_type.len().min(h.natms_per_type.len());
103    let mut m = 0.0f64;
104    for i in 0..n {
105        let mi = h.masses_per_type[i];
106        let ni = h.natms_per_type[i] as f64;
107        if !mi.is_finite() || !ni.is_finite() {
108            return None;
109        }
110        m += mi * ni;
111    }
112    m.is_finite().then_some(m)
113}
114
115fn scalar_triple(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
116    a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0])
117        + a[2] * (b[0] * c[1] - b[1] * c[0])
118}
119
120/// Cell volume: prefer lattice determinant; else triclinic from `boxl` + `angles` (degrees).
121pub fn frame_cell_volume(frame: &ConFrame) -> Option<f64> {
122    if let Some(lv) = frame.header.lattice_vectors() {
123        let det = scalar_triple(lv[0], lv[1], lv[2]).abs();
124        return det.is_finite().then_some(det);
125    }
126    let [a, b, c] = frame.header.boxl;
127    let [alpha, beta, gamma] = frame.header.angles;
128    if ![a, b, c, alpha, beta, gamma]
129        .iter()
130        .all(|x| x.is_finite() && *x > 0.0)
131    {
132        return None;
133    }
134    let ar = alpha.to_radians();
135    let br = beta.to_radians();
136    let gr = gamma.to_radians();
137    let ca = ar.cos();
138    let cb = br.cos();
139    let cg = gr.cos();
140    let sg = gr.sin();
141    if sg.abs() < 1e-15 {
142        return None;
143    }
144    let t = 1.0 - ca * ca - cb * cb - cg * cg + 2.0 * ca * cb * cg;
145    if t <= 0.0 {
146        return None;
147    }
148    let v = a * b * c * t.sqrt();
149    v.is_finite().then_some(v)
150}
151
152/// True if forces are declared or any atom carries force data.
153pub fn frame_has_forces(frame: &ConFrame) -> bool {
154    frame
155        .header
156        .sections
157        .iter()
158        .any(|s| s.eq_ignore_ascii_case(SECTION_FORCES))
159        || frame.atom_data.iter().any(|a| a.force.is_some())
160        || frame.has_forces()
161}
162
163/// True if velocities are declared or any atom carries velocity data.
164pub fn frame_has_velocities(frame: &ConFrame) -> bool {
165    frame
166        .header
167        .sections
168        .iter()
169        .any(|s| s.eq_ignore_ascii_case(SECTION_VELOCITIES))
170        || frame.atom_data.iter().any(|a| a.velocity.is_some())
171        || frame.has_velocities()
172}
173
174/// True if energies section is declared or a finite frame energy exists.
175pub fn frame_has_energies(frame: &ConFrame) -> bool {
176    frame
177        .header
178        .sections
179        .iter()
180        .any(|s| s.eq_ignore_ascii_case(SECTION_ENERGIES))
181        || frame.has_energies()
182        || finite_energy(frame).is_some()
183}
184
185/// Bitmask of present sections (forces / velocities / energies) for flag indexes.
186pub fn sections_present_mask(frame: &ConFrame) -> u8 {
187    let mut m = 0u8;
188    if frame_has_forces(frame) {
189        m |= SECTIONS_MASK_FORCES;
190    }
191    if frame_has_velocities(frame) {
192        m |= SECTIONS_MASK_VELOCITIES;
193    }
194    if frame_has_energies(frame) {
195        m |= SECTIONS_MASK_ENERGIES;
196    }
197    m
198}
199
200fn meta_f64(frame: &ConFrame, key: &str) -> Option<f64> {
201    let v = frame.header.metadata.get(key)?;
202    if let Some(f) = v.as_f64() {
203        return f.is_finite().then_some(f);
204    }
205    if let Some(i) = v.as_i64() {
206        let f = i as f64;
207        return f.is_finite().then_some(f);
208    }
209    if let Some(u) = v.as_u64() {
210        let f = u as f64;
211        return f.is_finite().then_some(f);
212    }
213    None
214}
215
216/// ASE.db-competitive / CON-derivable screening projection for one frame.
217#[derive(Clone, Debug, PartialEq)]
218pub struct FrameIndexProjection {
219    pub n_atoms: u32,
220    /// Distinct non-empty symbols (sorted via BTree insertion order of counts map).
221    pub symbols: Vec<String>,
222    /// Per-element counts aligned with campaign `idx_elem_count`.
223    pub species_counts: Vec<(String, u32)>,
224    /// Canonical multiset formula (`Cu:2|H:2`) or empty.
225    pub formula: String,
226    /// Finite energy only.
227    pub energy: Option<f64>,
228    /// Max force magnitude when forces present.
229    pub fmax: Option<f64>,
230    pub total_mass: Option<f64>,
231    pub cell_volume: Option<f64>,
232    /// Explicit PBC triple from metadata, if present.
233    pub pbc: Option<[bool; 3]>,
234    pub sections_mask: u8,
235    pub has_forces: bool,
236    pub has_velocities: bool,
237    pub has_energy: bool,
238    pub time: Option<f64>,
239    pub timestep: Option<f64>,
240    pub frame_index: Option<f64>,
241    pub neb_bead: Option<f64>,
242    pub neb_band: Option<f64>,
243    pub charge: Option<f64>,
244    pub magmom: Option<f64>,
245}
246
247impl FrameIndexProjection {
248    /// Build projection from a fully parsed frame (single source of truth for indexes).
249    pub fn from_frame(frame: &ConFrame) -> Self {
250        let species_counts = frame_species_counts(frame);
251        let symbols: Vec<String> = species_counts.iter().map(|(s, _)| s.clone()).collect();
252        let formula = composition_formula(&species_counts);
253        let energy = finite_energy(frame);
254        let has_forces = frame_has_forces(frame);
255        let has_velocities = frame_has_velocities(frame);
256        let has_energy = frame_has_energies(frame);
257        let sections_mask = {
258            let mut m = 0u8;
259            if has_forces {
260                m |= SECTIONS_MASK_FORCES;
261            }
262            if has_velocities {
263                m |= SECTIONS_MASK_VELOCITIES;
264            }
265            if has_energy {
266                m |= SECTIONS_MASK_ENERGIES;
267            }
268            m
269        };
270        let time = frame
271            .header
272            .time()
273            .filter(|t| t.is_finite())
274            .or_else(|| meta_f64(frame, "time"));
275        let timestep = frame
276            .header
277            .timestep()
278            .filter(|t| t.is_finite())
279            .or_else(|| meta_f64(frame, "timestep"));
280        let frame_index = frame
281            .header
282            .frame_index()
283            .map(|i| i as f64)
284            .filter(|t| t.is_finite())
285            .or_else(|| meta_f64(frame, "frame_index"));
286        let neb_bead = frame
287            .header
288            .neb_bead()
289            .map(|i| i as f64)
290            .filter(|t| t.is_finite())
291            .or_else(|| meta_f64(frame, "neb_bead"));
292        let neb_band = meta_f64(frame, "neb_band").or_else(|| {
293            frame
294                .header
295                .metadata
296                .get("neb_band")
297                .and_then(|v| v.as_u64())
298                .map(|u| u as f64)
299                .filter(|t| t.is_finite())
300        });
301        Self {
302            n_atoms: frame.atom_data.len() as u32,
303            symbols,
304            species_counts,
305            formula,
306            energy,
307            fmax: frame_fmax(frame),
308            total_mass: frame_total_mass(frame),
309            cell_volume: frame_cell_volume(frame),
310            pbc: frame.header.pbc(),
311            sections_mask,
312            has_forces,
313            has_velocities,
314            has_energy,
315            time,
316            timestep,
317            frame_index,
318            neb_bead,
319            neb_band,
320            charge: meta_f64(frame, "charge"),
321            magmom: meta_f64(frame, "magmom"),
322        }
323    }
324}
325
326/// Byte span of one frame within a multi-frame CON buffer (`start` inclusive, `end` exclusive).
327#[derive(Clone, Copy, Debug, PartialEq, Eq)]
328pub struct FrameByteSpan {
329    pub start: usize,
330    pub end: usize,
331}
332
333impl FrameByteSpan {
334    pub fn len(self) -> usize {
335        self.end.saturating_sub(self.start)
336    }
337
338    #[must_use]
339    pub fn is_empty(self) -> bool {
340        self.len() == 0
341    }
342
343    pub fn slice(self, file_contents: &str) -> Option<&str> {
344        file_contents.get(self.start..self.end)
345    }
346}
347
348/// Structural pre-pass: frame byte ranges via span-preserving iteration (parses each frame).
349/// Concatenating spans in order reproduces `file_contents` for well-formed multi-frame CON
350/// with no leading/trailing junk (trailing newline at EOF may be absorbed into the last span).
351/// Frame ranges from a skip walk ([`crate::iterators::frame_start_offsets`]).
352/// Does not parse atom payloads. Prefer this over [`frame_byte_spans`] when
353/// you only need to seek.
354pub fn frame_byte_spans_skip(
355    file_contents: &str,
356) -> Result<Vec<FrameByteSpan>, crate::error::ParseError> {
357    let starts = crate::iterators::frame_start_offsets(file_contents);
358    let n = file_contents.len();
359    Ok(starts
360        .iter()
361        .enumerate()
362        .map(|(i, &start)| FrameByteSpan {
363            start,
364            end: starts.get(i + 1).copied().unwrap_or(n),
365        })
366        .collect())
367}
368
369/// Parse frame `index` from `file_contents` using a skip-built span table.
370pub fn parse_frame_at(
371    file_contents: &str,
372    index: usize,
373) -> Result<crate::types::ConFrame, crate::error::ParseError> {
374    let spans = frame_byte_spans_skip(file_contents)?;
375    let span = spans
376        .get(index)
377        .ok_or(crate::error::ParseError::IndexOutOfBounds {
378            index,
379            len: spans.len(),
380        })?;
381    let slice = span
382        .slice(file_contents)
383        .ok_or(crate::error::ParseError::IncompleteFrame)?;
384    match crate::iterators::ConFrameIterator::new(slice).next() {
385        Some(r) => r,
386        None => Err(crate::error::ParseError::IncompleteFrame),
387    }
388}
389
390pub fn frame_byte_spans(
391    file_contents: &str,
392) -> Result<Vec<FrameByteSpan>, crate::error::ParseError> {
393    let mut it = crate::iterators::ConFrameIterator::new(file_contents);
394    let mut out = Vec::new();
395    while let Some(item) = it.next_with_raw_span(file_contents) {
396        let (_frame, span) = item?;
397        let start = span.as_ptr() as usize - file_contents.as_ptr() as usize;
398        let end = start + span.len();
399        out.push(FrameByteSpan { start, end });
400    }
401    Ok(out)
402}
403
404/// Symbol multiset histogram without retaining frames (ingest planning / cheap stats).
405pub fn symbol_histogram(
406    file_contents: &str,
407) -> Result<BTreeMap<String, u32>, crate::error::ParseError> {
408    let mut hist = BTreeMap::new();
409    for item in crate::iterators::ConFrameIterator::new(file_contents) {
410        let frame = item?;
411        for a in &frame.atom_data {
412            if a.symbol.is_empty() {
413                continue;
414            }
415            *hist.entry(a.symbol.to_string()).or_insert(0u32) += 1;
416        }
417    }
418    Ok(hist)
419}
420
421/// Ingest contract: successive `next_with_raw_span` slices concatenate to the full buffer
422/// when the file is only frames (no prefix garbage). Used by tests and corpus docs.
423pub fn spans_cover_buffer(file_contents: &str) -> Result<bool, crate::error::ParseError> {
424    let spans = frame_byte_spans(file_contents)?;
425    if spans.is_empty() {
426        return Ok(file_contents.is_empty());
427    }
428    if spans[0].start != 0 {
429        return Ok(false);
430    }
431    for w in spans.windows(2) {
432        if w[0].end != w[1].start {
433            return Ok(false);
434        }
435    }
436    Ok(spans.last().map(|s| s.end) == Some(file_contents.len())
437        || spans.last().map(|s| s.end) == Some(file_contents.trim_end().len()))
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::iterators::ConFrameIterator;
444    use crate::writer::ConFrameWriter;
445    use std::io::Cursor;
446
447    fn fixture_text() -> String {
448        let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
449            .join("resources/test/tiny_cuh2.con");
450        std::fs::read_to_string(p).unwrap()
451    }
452
453    #[test]
454    fn formula_canonical_order() {
455        let f1 = composition_formula(&[("H".into(), 2), ("Cu".into(), 2)]);
456        let f2 = composition_formula(&[("Cu".into(), 2), ("H".into(), 2)]);
457        assert_eq!(f1, "Cu:2|H:2");
458        assert_eq!(f1, f2);
459    }
460
461    #[test]
462    fn projection_from_fixture() {
463        let text = fixture_text();
464        let fr = ConFrameIterator::new(&text).next().unwrap().unwrap();
465        let p = FrameIndexProjection::from_frame(&fr);
466        assert!(p.n_atoms >= 1);
467        assert!(p.formula.contains(':'));
468        assert!(p.total_mass.is_some_and(|m| m > 0.0));
469        assert!(p.cell_volume.is_some_and(|v| v > 0.0));
470        assert_eq!(
471            p.sections_mask & SECTIONS_MASK_FORCES == SECTIONS_MASK_FORCES,
472            p.has_forces
473        );
474    }
475
476    #[test]
477    fn finite_energy_rejects_nan() {
478        let text = fixture_text();
479        let mut fr = ConFrameIterator::new(&text).next().unwrap().unwrap();
480        fr.header
481            .metadata
482            .insert("energy".into(), serde_json::json!(f64::NAN));
483        // header.energy() may still win if set; force only metadata path by clearing if needed
484        assert!(finite_energy(&fr).is_none() || finite_energy(&fr).unwrap().is_finite());
485        // explicit non-finite via projection energy field policy
486        let mut fr2 = fr.clone();
487        if let Some(e) = fr2.header.energy() {
488            let _ = e;
489        }
490        // inject only non-finite in metadata and ensure filter works when energy() absent
491        fr2.header
492            .metadata
493            .insert("energy".into(), serde_json::json!(f64::INFINITY));
494        let e = fr2
495            .header
496            .energy()
497            .or_else(|| fr2.header.metadata.get("energy").and_then(|v| v.as_f64()));
498        if let Some(x) = e {
499            if !x.is_finite() {
500                assert!(finite_energy(&fr2).is_none());
501            }
502        }
503    }
504
505    #[test]
506    fn skip_spans_seek_second_frame() {
507        let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
508            .join("resources/test/tiny_multi_cuh2.con");
509        let text = std::fs::read_to_string(p).unwrap();
510        let skip = frame_byte_spans_skip(&text).unwrap();
511        assert_eq!(skip.len(), 2);
512        let a = parse_frame_at(&text, 0).unwrap();
513        let b = parse_frame_at(&text, 1).unwrap();
514        let mut it = ConFrameIterator::new(&text);
515        let fa = it.next().unwrap().unwrap();
516        let fb = it.next().unwrap().unwrap();
517        assert_eq!(a.atom_data[0].x, fa.atom_data[0].x);
518        assert_eq!(b.atom_data[0].x, fb.atom_data[0].x);
519        assert!(parse_frame_at(&text, 9).is_err());
520    }
521
522    #[test]
523    fn span_preserving_concat() {
524        let text = fixture_text();
525        let spans = frame_byte_spans(&text).unwrap();
526        assert!(!spans.is_empty());
527        let mut acc = String::new();
528        for s in &spans {
529            acc.push_str(s.slice(&text).unwrap());
530        }
531        // re-parse acc must yield same frame count
532        let n_orig = ConFrameIterator::new(&text).count();
533        let n_acc = ConFrameIterator::new(&acc).count();
534        assert_eq!(n_orig, n_acc);
535        assert!(spans_cover_buffer(&text).unwrap());
536    }
537
538    #[test]
539    fn symbol_histogram_nonempty() {
540        let h = symbol_histogram(&fixture_text()).unwrap();
541        assert!(!h.is_empty());
542    }
543
544    /// Projection contract vs shipped frame parse (regression if index_proj drifts from CON).
545    #[test]
546    fn projection_matches_iterator_frame_fields() {
547        let text = fixture_text();
548        let fr = ConFrameIterator::new(&text).next().unwrap().unwrap();
549        let p = FrameIndexProjection::from_frame(&fr);
550        assert_eq!(p.n_atoms as usize, fr.atom_data.len());
551        assert_eq!(p.n_atoms, fr.positions.nrows() as u32);
552        let expect_formula = frame_composition_formula(&fr);
553        assert_eq!(p.formula, expect_formula);
554        assert!(!p.formula.is_empty());
555        assert_eq!(p.species_counts, frame_species_counts(&fr));
556        assert!(p.total_mass.is_some_and(|m| m > 0.0));
557        assert!(p.cell_volume.is_some_and(|v| v > 0.0));
558        let expect_forces = frame_has_forces(&fr);
559        let expect_vel = frame_has_velocities(&fr);
560        assert_eq!(p.has_forces, expect_forces);
561        assert_eq!(p.has_velocities, expect_vel);
562        let mut expect_mask = 0u8;
563        if expect_forces {
564            expect_mask |= SECTIONS_MASK_FORCES;
565        }
566        if expect_vel {
567            expect_mask |= SECTIONS_MASK_VELOCITIES;
568        }
569        if frame_has_energies(&fr) {
570            expect_mask |= SECTIONS_MASK_ENERGIES;
571        }
572        assert_eq!(p.sections_mask, expect_mask);
573        assert_eq!(p.sections_mask, sections_present_mask(&fr));
574    }
575
576    #[test]
577    fn canonical_writer_deterministic() {
578        let text = fixture_text();
579        let fr = ConFrameIterator::new(&text).next().unwrap().unwrap();
580        let mut a = Cursor::new(Vec::new());
581        let mut b = Cursor::new(Vec::new());
582        {
583            let mut wa = ConFrameWriter::new(&mut a).canonical(true);
584            let mut wb = ConFrameWriter::new(&mut b).canonical(true);
585            wa.write_frame(&fr).unwrap();
586            wb.write_frame(&fr).unwrap();
587        }
588        assert_eq!(a.into_inner(), b.into_inner());
589    }
590}