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