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.get(index).ok_or(crate::error::ParseError::IndexOutOfBounds {
376        index,
377        len: spans.len(),
378    })?;
379    let slice = span
380        .slice(file_contents)
381        .ok_or(crate::error::ParseError::IncompleteFrame)?;
382    match crate::iterators::ConFrameIterator::new(slice).next() {
383        Some(r) => r,
384        None => Err(crate::error::ParseError::IncompleteFrame),
385    }
386}
387
388pub fn frame_byte_spans(
389    file_contents: &str,
390) -> Result<Vec<FrameByteSpan>, crate::error::ParseError> {
391    let mut it = crate::iterators::ConFrameIterator::new(file_contents);
392    let mut out = Vec::new();
393    while let Some(item) = it.next_with_raw_span(file_contents) {
394        let (_frame, span) = item?;
395        let start = span.as_ptr() as usize - file_contents.as_ptr() as usize;
396        let end = start + span.len();
397        out.push(FrameByteSpan { start, end });
398    }
399    Ok(out)
400}
401
402/// Symbol multiset histogram without retaining frames (ingest planning / cheap stats).
403pub fn symbol_histogram(
404    file_contents: &str,
405) -> Result<BTreeMap<String, u32>, crate::error::ParseError> {
406    let mut hist = BTreeMap::new();
407    for item in crate::iterators::ConFrameIterator::new(file_contents) {
408        let frame = item?;
409        for a in &frame.atom_data {
410            if a.symbol.is_empty() {
411                continue;
412            }
413            *hist.entry(a.symbol.to_string()).or_insert(0u32) += 1;
414        }
415    }
416    Ok(hist)
417}
418
419/// Ingest contract: successive `next_with_raw_span` slices concatenate to the full buffer
420/// when the file is only frames (no prefix garbage). Used by tests and corpus docs.
421pub fn spans_cover_buffer(file_contents: &str) -> Result<bool, crate::error::ParseError> {
422    let spans = frame_byte_spans(file_contents)?;
423    if spans.is_empty() {
424        return Ok(file_contents.is_empty());
425    }
426    if spans[0].start != 0 {
427        return Ok(false);
428    }
429    for w in spans.windows(2) {
430        if w[0].end != w[1].start {
431            return Ok(false);
432        }
433    }
434    Ok(spans.last().map(|s| s.end) == Some(file_contents.len())
435        || spans.last().map(|s| s.end) == Some(file_contents.trim_end().len()))
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::iterators::ConFrameIterator;
442    use crate::writer::ConFrameWriter;
443    use std::io::Cursor;
444
445    fn fixture_text() -> String {
446        let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
447            .join("resources/test/tiny_cuh2.con");
448        std::fs::read_to_string(p).unwrap()
449    }
450
451    #[test]
452    fn formula_canonical_order() {
453        let f1 = composition_formula(&[("H".into(), 2), ("Cu".into(), 2)]);
454        let f2 = composition_formula(&[("Cu".into(), 2), ("H".into(), 2)]);
455        assert_eq!(f1, "Cu:2|H:2");
456        assert_eq!(f1, f2);
457    }
458
459    #[test]
460    fn projection_from_fixture() {
461        let text = fixture_text();
462        let fr = ConFrameIterator::new(&text).next().unwrap().unwrap();
463        let p = FrameIndexProjection::from_frame(&fr);
464        assert!(p.n_atoms >= 1);
465        assert!(p.formula.contains(':'));
466        assert!(p.total_mass.is_some_and(|m| m > 0.0));
467        assert!(p.cell_volume.is_some_and(|v| v > 0.0));
468        assert_eq!(
469            p.sections_mask & SECTIONS_MASK_FORCES == SECTIONS_MASK_FORCES,
470            p.has_forces
471        );
472    }
473
474    #[test]
475    fn finite_energy_rejects_nan() {
476        let text = fixture_text();
477        let mut fr = ConFrameIterator::new(&text).next().unwrap().unwrap();
478        fr.header
479            .metadata
480            .insert("energy".into(), serde_json::json!(f64::NAN));
481        // header.energy() may still win if set; force only metadata path by clearing if needed
482        assert!(finite_energy(&fr).is_none() || finite_energy(&fr).unwrap().is_finite());
483        // explicit non-finite via projection energy field policy
484        let mut fr2 = fr.clone();
485        if let Some(e) = fr2.header.energy() {
486            let _ = e;
487        }
488        // inject only non-finite in metadata and ensure filter works when energy() absent
489        fr2.header
490            .metadata
491            .insert("energy".into(), serde_json::json!(f64::INFINITY));
492        let e = fr2
493            .header
494            .energy()
495            .or_else(|| fr2.header.metadata.get("energy").and_then(|v| v.as_f64()));
496        if let Some(x) = e {
497            if !x.is_finite() {
498                assert!(finite_energy(&fr2).is_none());
499            }
500        }
501    }
502
503    #[test]
504    fn skip_spans_seek_second_frame() {
505        let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
506            .join("resources/test/tiny_multi_cuh2.con");
507        let text = std::fs::read_to_string(p).unwrap();
508        let skip = frame_byte_spans_skip(&text).unwrap();
509        assert_eq!(skip.len(), 2);
510        let a = parse_frame_at(&text, 0).unwrap();
511        let b = parse_frame_at(&text, 1).unwrap();
512        let mut it = ConFrameIterator::new(&text);
513        let fa = it.next().unwrap().unwrap();
514        let fb = it.next().unwrap().unwrap();
515        assert_eq!(a.atom_data[0].x, fa.atom_data[0].x);
516        assert_eq!(b.atom_data[0].x, fb.atom_data[0].x);
517        assert!(parse_frame_at(&text, 9).is_err());
518    }
519
520    #[test]
521    fn span_preserving_concat() {
522        let text = fixture_text();
523        let spans = frame_byte_spans(&text).unwrap();
524        assert!(!spans.is_empty());
525        let mut acc = String::new();
526        for s in &spans {
527            acc.push_str(s.slice(&text).unwrap());
528        }
529        // re-parse acc must yield same frame count
530        let n_orig = ConFrameIterator::new(&text).count();
531        let n_acc = ConFrameIterator::new(&acc).count();
532        assert_eq!(n_orig, n_acc);
533        assert!(spans_cover_buffer(&text).unwrap());
534    }
535
536    #[test]
537    fn symbol_histogram_nonempty() {
538        let h = symbol_histogram(&fixture_text()).unwrap();
539        assert!(!h.is_empty());
540    }
541
542    /// Projection contract vs shipped frame parse (regression if index_proj drifts from CON).
543    #[test]
544    fn projection_matches_iterator_frame_fields() {
545        let text = fixture_text();
546        let fr = ConFrameIterator::new(&text).next().unwrap().unwrap();
547        let p = FrameIndexProjection::from_frame(&fr);
548        assert_eq!(p.n_atoms as usize, fr.atom_data.len());
549        assert_eq!(p.n_atoms, fr.positions.nrows() as u32);
550        let expect_formula = frame_composition_formula(&fr);
551        assert_eq!(p.formula, expect_formula);
552        assert!(!p.formula.is_empty());
553        assert_eq!(p.species_counts, frame_species_counts(&fr));
554        assert!(p.total_mass.is_some_and(|m| m > 0.0));
555        assert!(p.cell_volume.is_some_and(|v| v > 0.0));
556        let expect_forces = frame_has_forces(&fr);
557        let expect_vel = frame_has_velocities(&fr);
558        assert_eq!(p.has_forces, expect_forces);
559        assert_eq!(p.has_velocities, expect_vel);
560        let mut expect_mask = 0u8;
561        if expect_forces {
562            expect_mask |= SECTIONS_MASK_FORCES;
563        }
564        if expect_vel {
565            expect_mask |= SECTIONS_MASK_VELOCITIES;
566        }
567        if frame_has_energies(&fr) {
568            expect_mask |= SECTIONS_MASK_ENERGIES;
569        }
570        assert_eq!(p.sections_mask, expect_mask);
571        assert_eq!(p.sections_mask, sections_present_mask(&fr));
572    }
573
574    #[test]
575    fn canonical_writer_deterministic() {
576        let text = fixture_text();
577        let fr = ConFrameIterator::new(&text).next().unwrap().unwrap();
578        let mut a = Cursor::new(Vec::new());
579        let mut b = Cursor::new(Vec::new());
580        {
581            let mut wa = ConFrameWriter::new(&mut a).canonical(true);
582            let mut wb = ConFrameWriter::new(&mut b).canonical(true);
583            wa.write_frame(&fr).unwrap();
584            wb.write_frame(&fr).unwrap();
585        }
586        assert_eq!(a.into_inner(), b.into_inner());
587    }
588}