Skip to main content

readcon_core/
iterators.rs

1//=============================================================================
2// The Public API - A clean iterator for users of our library
3//=============================================================================
4
5use crate::parser::{parse_declared_sections, parse_single_frame, LineStream};
6use crate::{error, types};
7use std::path::Path;
8
9/// memchr-backed line cursor for the full parse path (not only frame skip).
10///
11/// Profile note: `str::Lines` + `Peekable` showed up under
12/// `ConFrameIterator::next` on multi-atom multi-frame workloads. One cursor
13/// serves `next` / `peek` / `forward_fast` so skip and full parse share the
14/// same O(1) newline scan rather than two desynchronized views of the buffer.
15pub struct MemchrLines<'a> {
16    bytes: &'a [u8],
17    pos: usize,
18    peeked: Option<&'a str>,
19}
20
21impl<'a> MemchrLines<'a> {
22    pub fn new(text: &'a str) -> Self {
23        Self {
24            bytes: text.as_bytes(),
25            pos: 0,
26            peeked: None,
27        }
28    }
29
30    #[inline]
31    fn read_one(&mut self) -> Option<&'a str> {
32        if self.pos >= self.bytes.len() {
33            return None;
34        }
35        let rest = &self.bytes[self.pos..];
36        let (line_bytes, advance) = match memchr::memchr(b'\n', rest) {
37            Some(i) => (&rest[..i], i + 1),
38            None => (rest, rest.len()),
39        };
40        self.pos += advance;
41        let trimmed = if line_bytes.last() == Some(&b'\r') {
42            &line_bytes[..line_bytes.len() - 1]
43        } else {
44            line_bytes
45        };
46        // SAFETY: source was `&str`; line is a UTF-8 prefix cut on ASCII `\n`/`\r`.
47        Some(unsafe { std::str::from_utf8_unchecked(trimmed) })
48    }
49
50    #[inline]
51    pub fn next_line(&mut self) -> Option<&'a str> {
52        if let Some(p) = self.peeked.take() {
53            return Some(p);
54        }
55        self.read_one()
56    }
57
58    #[inline]
59    pub fn peek_line(&mut self) -> Option<&'a str> {
60        if self.peeked.is_none() {
61            self.peeked = self.read_one();
62        }
63        self.peeked
64    }
65
66    /// Drop any peek buffer (required before bulk cursor advances).
67    fn clear_peek(&mut self) {
68        if let Some(p) = self.peeked.take() {
69            // Rewind pos to the start of the peeked line.
70            let start = p.as_ptr() as usize - self.bytes.as_ptr() as usize;
71            self.pos = start;
72        }
73    }
74}
75
76impl<'a> Iterator for MemchrLines<'a> {
77    type Item = &'a str;
78    fn next(&mut self) -> Option<&'a str> {
79        self.next_line()
80    }
81}
82
83impl<'a> LineStream<'a> for MemchrLines<'a> {
84    #[inline]
85    fn next_line(&mut self) -> Option<&'a str> {
86        MemchrLines::next_line(self)
87    }
88    #[inline]
89    fn peek_line(&mut self) -> Option<&'a str> {
90        MemchrLines::peek_line(self)
91    }
92}
93
94/// An iterator that lazily parses simulation frames from a `.con` or `.convel`
95/// file's contents.
96///
97/// This struct wraps a memchr line cursor over the file buffer and, upon each
98/// iteration, attempts to parse a complete `ConFrame`. Velocity sections are
99/// detected automatically: if a blank line follows the coordinate blocks, the
100/// velocity data is parsed into the atoms.
101///
102/// The iterator yields items of type `Result<ConFrame, ParseError>`, allowing for
103/// robust error handling for each frame.
104pub struct ConFrameIterator<'a> {
105    pub(crate) lines: MemchrLines<'a>,
106}
107
108impl<'a> ConFrameIterator<'a> {
109    /// Creates a new `ConFrameIterator` from a string slice of the entire file.
110    ///
111    /// # Arguments
112    ///
113    /// * `file_contents` - A string slice containing the text of one or more `.con` frames.
114    pub fn new(file_contents: &'a str) -> Self {
115        ConFrameIterator {
116            lines: MemchrLines::new(file_contents),
117        }
118    }
119
120    /// Bulk-skips `n` lines from the shared memchr cursor.
121    fn advance_lines(&mut self, n: usize) -> Result<(), error::ParseError> {
122        self.lines.clear_peek();
123        for _ in 0..n {
124            let rest = &self.lines.bytes[self.lines.pos..];
125            match memchr::memchr(b'\n', rest) {
126                Some(pos) => self.lines.pos += pos + 1,
127                None => {
128                    if rest.is_empty() {
129                        return Err(error::ParseError::IncompleteFrame);
130                    }
131                    self.lines.pos = self.lines.bytes.len();
132                    return Err(error::ParseError::IncompleteFrame);
133                }
134            }
135        }
136        Ok(())
137    }
138
139    /// One line from the shared cursor (same as full-parse path).
140    fn read_line_str(&mut self) -> Option<&'a str> {
141        self.lines.clear_peek();
142        self.lines.next_line()
143    }
144
145    /// memchr-backed equivalent of [`Self::forward`]. Skips the next
146    /// frame without fully parsing its atom data. Shares the same line
147    /// cursor as [`Iterator::next`], so skip and full parse interleave safely.
148    pub fn forward_fast(&mut self) -> Option<Result<(), error::ParseError>> {
149        self.lines.clear_peek();
150        if self.lines.pos >= self.lines.bytes.len() {
151            return None;
152        }
153        // Lines 1..=6 of the header are skipped wholesale.
154        if let Err(e) = self.advance_lines(6) {
155            return Some(Err(e));
156        }
157        // Line 7: natm_types.
158        let natm_types: usize = match self.read_line_str() {
159            Some(line) => match crate::parser::parse_line_of_n::<usize>(line, 1) {
160                Ok(v) => v[0],
161                Err(e) => return Some(Err(e)),
162            },
163            None => return Some(Err(error::ParseError::IncompleteHeader)),
164        };
165        // Line 8: natms_per_type.
166        let natms_per_type: Vec<usize> = match self.read_line_str() {
167            Some(line) => match crate::parser::parse_line_of_n(line, natm_types) {
168                Ok(v) => v,
169                Err(e) => return Some(Err(e)),
170            },
171            None => return Some(Err(error::ParseError::IncompleteHeader)),
172        };
173        // Line 9: masses_per_type, consumed.
174        if let Err(e) = self.advance_lines(1) {
175            return Some(Err(e));
176        }
177        let total_atoms: usize = natms_per_type.iter().sum();
178        let coord_block_lines = total_atoms + natm_types * 2;
179        if let Err(e) = self.advance_lines(coord_block_lines) {
180            return Some(Err(e));
181        }
182        // Optional sections: blank line + same-shape block, repeated.
183        self.lines.clear_peek();
184        loop {
185            let rest = &self.lines.bytes[self.lines.pos..];
186            if rest.is_empty() {
187                break;
188            }
189            let next_eol = memchr::memchr(b'\n', rest);
190            let line = match next_eol {
191                Some(pos) => &rest[..pos],
192                None => rest,
193            };
194            let is_blank = line.iter().all(|b| matches!(b, b' ' | b'\t' | b'\r'));
195            if !is_blank {
196                break;
197            }
198            // Consume the blank separator and the section block.
199            self.lines.pos += next_eol.map(|p| p + 1).unwrap_or(rest.len());
200            if let Err(e) = self.advance_lines(coord_block_lines) {
201                return Some(Err(e));
202            }
203        }
204        Some(Ok(()))
205    }
206
207    /// Skips the next frame without fully parsing its atomic data.
208    ///
209    /// This is more efficient than `next()` if you only need to advance the
210    /// iterator. It reads the frame's header to determine how many lines to skip,
211    /// including any velocity section if present.
212    ///
213    /// # Returns
214    ///
215    /// * `Some(Ok(()))` on a successful skip.
216    /// * `Some(Err(ParseError::...))` if there's an error parsing the header.
217    /// * `None` if the iterator is already at the end.
218    pub fn forward(&mut self) -> Option<Result<(), error::ParseError>> {
219        // Prefer the shared memchr skip path (same cursor as full parse).
220        self.forward_fast()
221    }
222
223    /// Next frame plus the exact substring of the buffer passed to [`Self::new`].
224    ///
225    /// **Corpus ingest contract:** successive successful spans from the same
226    /// `file_contents` are contiguous (`end` of frame *i* equals `start` of frame
227    /// *i+1*) and, for a buffer that is only multi-frame CON (no prefix garbage),
228    /// concatenating all spans reproduces the trajectory text. Campaign stores
229    /// (`readcon-db`) must persist these spans as authoritative blobs—do not
230    /// re-serialize on the hot ingest path unless the caller supplied in-memory
231    /// [`types::ConFrame`] values without source text.
232    ///
233    /// See also [`crate::index_proj::frame_byte_spans`] and
234    /// [`crate::index_proj::spans_cover_buffer`].
235    pub fn next_with_raw_span(
236        &mut self,
237        file_contents: &'a str,
238    ) -> Option<Result<(types::ConFrame, &'a str), error::ParseError>> {
239        let base = file_contents.as_ptr() as usize;
240        let start = {
241            let line = self.lines.peek_line()?;
242            line.as_ptr() as usize - base
243        };
244        let frame = match self.next()? {
245            Ok(f) => f,
246            Err(e) => return Some(Err(e)),
247        };
248        let end = match self.lines.peek_line() {
249            Some(line) => line.as_ptr() as usize - base,
250            None => file_contents.len(),
251        };
252        debug_assert!(end >= start && end <= file_contents.len());
253        Some(Ok((frame, &file_contents[start..end])))
254    }
255}
256
257impl<'a> Iterator for ConFrameIterator<'a> {
258    /// The type of item yielded by the iterator.
259    ///
260    /// Each item is a `Result` that contains a successfully parsed `ConFrame` or a
261    /// `ParseError` if the frame's data is malformed.
262    type Item = Result<types::ConFrame, error::ParseError>;
263
264    /// Advances the iterator and attempts to parse the next frame.
265    ///
266    /// This method will return `None` only when there are no more lines to consume.
267    /// If there are lines but they do not form a complete frame, it will return
268    /// `Some(Err(ParseError::...))`.
269    fn next(&mut self) -> Option<Self::Item> {
270        // If there are no more lines at all, the iterator is exhausted.
271        self.lines.peek_line()?;
272        // Otherwise, attempt to parse the next frame from the available lines.
273        let mut frame = match parse_single_frame(&mut self.lines) {
274            Ok(f) => f,
275            Err(e) => return Some(Err(e)),
276        };
277        // Optional sections mutate AoS; only re-sync section SoA when needed.
278        // Plain .con assembly already filled positions/ids/masses (no O(N)
279        // post-scan when no velocity/force sections were applied).
280        let sections = match parse_declared_sections(
281            &mut self.lines,
282            &mut frame.header,
283            &mut frame.atom_data,
284        ) {
285            Ok(n) => n,
286            Err(e) => return Some(Err(e)),
287        };
288        if sections > 0 {
289            frame.sync_arrays_from_atom_data();
290        }
291        Some(Ok(frame))
292    }
293}
294
295#[cfg(test)]
296mod aos_soa_agreement_tests {
297    use super::*;
298    use std::path::PathBuf;
299
300    #[test]
301    fn iterator_vel_forces_soa_matches_aos() {
302        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
303            .join("resources/test/tiny_cuh2_vel_forces.con");
304        let text = std::fs::read_to_string(&p).expect("fixture");
305        let fr = ConFrameIterator::new(&text)
306            .next()
307            .expect("frame")
308            .expect("parse");
309        let n = fr.atom_data.len();
310        assert!(n > 0);
311        assert_eq!(fr.positions.nrows(), n);
312        let has_vel = fr.atom_data.iter().any(|a| a.velocity.is_some());
313        let has_frc = fr.atom_data.iter().any(|a| a.force.is_some());
314        if has_vel {
315            assert_eq!(
316                fr.velocities.nrows(),
317                n,
318                "SoA velocities must match AoS after section parse"
319            );
320        }
321        if has_frc {
322            assert_eq!(fr.forces.nrows(), n, "SoA forces must match AoS");
323        }
324        for (i, a) in fr.atom_data.iter().enumerate() {
325            let p = fr.positions.as_f64_row(i);
326            assert_eq!([a.x, a.y, a.z], p);
327            if let Some(v) = a.velocity {
328                assert_eq!(v, fr.velocities.as_f64_row(i));
329            }
330            if let Some(f) = a.force {
331                assert_eq!(f, fr.forces.as_f64_row(i));
332            }
333        }
334    }
335
336    /// After SoA-primary parse, section sync must not require rewriting positions
337    /// (nrows already equals N); forces SoA still filled from AoS.
338    #[test]
339    fn sync_skips_pos_when_nrows_matches_keeps_force_soa() {
340        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
341            .join("resources/test/tiny_cuh2_forces.con");
342        let text = std::fs::read_to_string(&p).expect("fixture");
343        let fr = ConFrameIterator::new(&text)
344            .next()
345            .expect("frame")
346            .expect("parse");
347        let n = fr.atom_data.len();
348        assert_eq!(fr.positions.nrows(), n);
349        assert_eq!(fr.forces.nrows(), n);
350        // Snapshot first position SoA row then re-sync; coords must stay bit-identical
351        // (no needless rewrite would change nothing but we still require agreement).
352        let p0 = fr.positions.as_f64_row(0);
353        let mut fr2 = fr.clone();
354        fr2.sync_arrays_from_atom_data();
355        assert_eq!(fr2.positions.as_f64_row(0), p0);
356        assert_eq!(fr2.forces.nrows(), n);
357        assert_eq!(
358            fr2.forces.as_f64_row(0),
359            fr2.atom_data[0].force.expect("force")
360        );
361    }
362}
363
364/// Reads all frames from a file.
365///
366/// For files smaller than 64 KiB, uses a simple `read_to_string` to avoid
367/// the fixed overhead of mmap (VMA creation, page fault, munmap). For larger
368/// trajectory files, uses memory-mapped I/O to let the OS page cache handle
369/// the data.
370/// Byte-size gate for Rayon multi-frame parse. Avoids an extra O(n) frame-count
371/// scan: phase-1 of [`parse_frames_parallel`] already walks boundaries when we
372/// choose parallel. Below this size, sequential parse wins on small multi-frame
373/// files (pool scheduling overhead).
374#[cfg(feature = "parallel")]
375pub(crate) const PARALLEL_BYTES_THRESHOLD: usize = 48 * 1024;
376
377pub fn read_all_frames(path: &Path) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
378    let contents = crate::compression::read_file_contents(path)?;
379    let text = contents.as_str()?;
380    #[cfg(feature = "parallel")]
381    {
382        if text.len() >= PARALLEL_BYTES_THRESHOLD {
383            let parts = parse_frames_parallel(text);
384            let mut frames = Vec::with_capacity(parts.len());
385            for r in parts {
386                frames.push(r?);
387            }
388            return Ok(frames);
389        }
390    }
391    let iter = ConFrameIterator::new(text);
392    let frames: Result<Vec<_>, _> = iter.collect();
393    Ok(frames?)
394}
395
396/// Count frames without building atom payloads (uses [`ConFrameIterator::forward_fast`]
397/// when possible, else [`ConFrameIterator::forward`]).
398///
399/// Prefer this over `read_all_frames(...).len()` when only the frame count is needed.
400pub fn count_frames(path: &Path) -> Result<usize, Box<dyn std::error::Error>> {
401    let contents = crate::compression::read_file_contents(path)?;
402    let text = contents.as_str()?;
403    let mut n = 0usize;
404    let mut iter = ConFrameIterator::new(text);
405    loop {
406        match iter.forward_fast() {
407            Some(Ok(())) => n += 1,
408            Some(Err(e)) => return Err(Box::new(e)),
409            None => break,
410        }
411    }
412    Ok(n)
413}
414
415/// Reads only the first frame from a file.
416///
417/// More efficient than `read_all_frames` for single-frame access because it
418/// stops parsing after the first frame rather than collecting all of them.
419pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
420    let contents = crate::compression::read_file_contents(path)?;
421    let text = contents.as_str()?;
422    let mut iter = ConFrameIterator::new(text);
423    match iter.next() {
424        Some(Ok(frame)) => Ok(frame),
425        Some(Err(e)) => Err(Box::new(e)),
426        None => Err("No frames found in file".into()),
427    }
428}
429
430/// Parses frames in parallel using rayon, splitting on frame boundaries.
431///
432/// Phase 1: sequential O(N) scan via memchr-backed
433/// [`ConFrameIterator::forward_fast`] to find byte offsets of every
434/// frame's start. The previous implementation built a `Vec<&str>` of
435/// every line and called `lines[..i].iter().map(|l| l.len() +
436/// 1).sum()` on every frame, which is O(N^2) in line count and
437/// dominated runtime on multi-frame trajectories.
438///
439/// Phase 2: parallel parse of each frame slice using rayon on the
440/// **global** Rayon pool (see also [`parse_frames_parallel_with_threads`]
441/// for strong-scaling control of the worker count).
442///
443/// Requires the `parallel` feature.
444#[cfg(feature = "parallel")]
445pub fn parse_frames_parallel(
446    file_contents: &str,
447) -> Vec<Result<types::ConFrame, error::ParseError>> {
448    parse_frames_parallel_with_threads(file_contents, None)
449}
450
451/// Like [`parse_frames_parallel`], but runs phase-2 on an explicit Rayon
452/// pool with `num_threads` workers when `Some(n)` (`n` is clamped to at
453/// least 1). `None` uses the global pool (same as [`parse_frames_parallel`]).
454///
455/// Strong-scaling tests pin worker counts without racing the global pool.
456/// Results are ordered by frame index (stable vs sequential iterator order).
457///
458/// Requires the `parallel` feature.
459#[cfg(feature = "parallel")]
460pub fn parse_frames_parallel_with_threads(
461    file_contents: &str,
462    num_threads: Option<usize>,
463) -> Vec<Result<types::ConFrame, error::ParseError>> {
464    use rayon::prelude::*;
465
466    // Phase 1: walk the file once with forward_fast and snapshot the
467    // cursor before each frame.
468    let mut boundaries: Vec<usize> = Vec::new();
469    let mut scanner = ConFrameIterator::new(file_contents);
470    loop {
471        scanner.lines.clear_peek();
472        let start = scanner.lines.pos;
473        if start >= scanner.lines.bytes.len() {
474            break;
475        }
476        boundaries.push(start);
477        match scanner.forward_fast() {
478            Some(Ok(())) => {}
479            Some(Err(_)) | None => break,
480        }
481    }
482
483    let parse_chunks = || {
484        let num_frames = boundaries.len();
485        (0..num_frames)
486            .into_par_iter()
487            .map(|i| {
488                let start = boundaries[i];
489                let end = if i + 1 < num_frames {
490                    boundaries[i + 1]
491                } else {
492                    file_contents.len()
493                };
494                let chunk = &file_contents[start..end];
495                let mut iter = ConFrameIterator::new(chunk);
496                match iter.next() {
497                    Some(result) => result,
498                    None => Err(error::ParseError::IncompleteFrame),
499                }
500            })
501            .collect()
502    };
503
504    match num_threads {
505        None => parse_chunks(),
506        Some(n) => {
507            let n = n.max(1);
508            let pool = rayon::ThreadPoolBuilder::new()
509                .num_threads(n)
510                .build()
511                .expect("rayon pool");
512            pool.install(parse_chunks)
513        }
514    }
515}
516
517#[cfg(all(test, feature = "parallel"))]
518mod parallel_strong_scale_tests {
519    use super::*;
520    use std::path::PathBuf;
521
522    fn multi_frame_fixture() -> String {
523        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
524            .join("resources/test/tiny_cuh2.con");
525        let one = std::fs::read_to_string(p).expect("fixture");
526        // Enough frames for >1 worker to exercise the pool.
527        one.repeat(8)
528    }
529
530    fn sequential_frames(text: &str) -> Vec<types::ConFrame> {
531        ConFrameIterator::new(text)
532            .map(|r| r.expect("seq frame"))
533            .collect()
534    }
535
536    fn frames_payload_key(f: &types::ConFrame) -> (usize, Vec<(String, f64, f64, f64)>) {
537        let atoms: Vec<_> = f
538            .atom_data
539            .iter()
540            .map(|a| (a.symbol.to_string(), a.x, a.y, a.z))
541            .collect();
542        (f.atom_data.len(), atoms)
543    }
544
545    #[test]
546    fn parallel_workers_match_sequential_payloads() {
547        let text = multi_frame_fixture();
548        let seq = sequential_frames(&text);
549        assert!(seq.len() >= 8);
550        let seq_keys: Vec<_> = seq.iter().map(frames_payload_key).collect();
551
552        for workers in [1usize, 2, 4] {
553            let par = parse_frames_parallel_with_threads(&text, Some(workers));
554            assert_eq!(par.len(), seq.len(), "workers={workers}");
555            let par_keys: Vec<_> = par
556                .into_iter()
557                .map(|r| frames_payload_key(&r.expect("par frame")))
558                .collect();
559            assert_eq!(par_keys, seq_keys, "workers={workers} frame payloads");
560        }
561
562        // Global pool path agrees too.
563        let par_default = parse_frames_parallel(&text);
564        let def_keys: Vec<_> = par_default
565            .into_iter()
566            .map(|r| frames_payload_key(&r.expect("par")))
567            .collect();
568        assert_eq!(def_keys, seq_keys);
569    }
570}