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};
6use crate::{error, types};
7use std::iter::Peekable;
8use std::path::Path;
9
10/// An iterator that lazily parses simulation frames from a `.con` or `.convel`
11/// file's contents.
12///
13/// This struct wraps an iterator over the lines of a string and, upon each iteration,
14/// attempts to parse a complete `ConFrame`. Velocity sections are detected
15/// automatically: if a blank line follows the coordinate blocks, the velocity
16/// data is parsed into the atoms.
17///
18/// The iterator yields items of type `Result<ConFrame, ParseError>`, allowing for
19/// robust error handling for each frame.
20pub struct ConFrameIterator<'a> {
21    pub(crate) lines: Peekable<std::str::Lines<'a>>,
22    /// Raw bytes of the source string, alongside a cursor. Used by
23    /// [`Self::forward_fast`] to skip frames via direct memchr-bulk
24    /// `\n` lookup instead of advancing the line iterator one call at
25    /// a time. The cursor is only nudged forward from `forward_fast`;
26    /// callers that mix `next()` / `forward()` with `forward_fast`
27    /// will see the cursor reset to whatever the line iterator's
28    /// current view of the slice is.
29    bytes: &'a [u8],
30    cursor: usize,
31}
32
33impl<'a> ConFrameIterator<'a> {
34    /// Creates a new `ConFrameIterator` from a string slice of the entire file.
35    ///
36    /// # Arguments
37    ///
38    /// * `file_contents` - A string slice containing the text of one or more `.con` frames.
39    pub fn new(file_contents: &'a str) -> Self {
40        ConFrameIterator {
41            lines: file_contents.lines().peekable(),
42            bytes: file_contents.as_bytes(),
43            cursor: 0,
44        }
45    }
46
47    /// Bulk-skips `n` lines starting at `self.cursor` using memchr to
48    /// find each `\n`. Returns `Ok(())` when `n` lines were consumed,
49    /// `Err(IncompleteFrame)` when the byte slice ran out first.
50    ///
51    /// Used by [`Self::forward_fast`] to avoid the per-line iterator
52    /// overhead of `Peekable<Lines>::next()` on workloads that only
53    /// want to skip past the current frame.
54    fn advance_lines(&mut self, n: usize) -> Result<(), error::ParseError> {
55        for _ in 0..n {
56            let rest = &self.bytes[self.cursor..];
57            match memchr::memchr(b'\n', rest) {
58                Some(pos) => self.cursor += pos + 1,
59                None => {
60                    if rest.is_empty() {
61                        return Err(error::ParseError::IncompleteFrame);
62                    }
63                    self.cursor = self.bytes.len();
64                    return Err(error::ParseError::IncompleteFrame);
65                }
66            }
67        }
68        Ok(())
69    }
70
71    /// Returns the bytes of the current line at `self.cursor` (without
72    /// the trailing `\n`) as a `&str`, advancing the cursor past the
73    /// newline.
74    fn read_line_str(&mut self) -> Option<&'a str> {
75        let rest = &self.bytes[self.cursor..];
76        if rest.is_empty() {
77            return None;
78        }
79        let (line_bytes, advance) = match memchr::memchr(b'\n', rest) {
80            Some(pos) => (&rest[..pos], pos + 1),
81            None => (rest, rest.len()),
82        };
83        // Strip optional trailing \r for Windows line endings.
84        let trimmed = if line_bytes.last() == Some(&b'\r') {
85            &line_bytes[..line_bytes.len() - 1]
86        } else {
87            line_bytes
88        };
89        // SAFETY: source was a `&str` so any UTF-8 prefix terminated by
90        // an ASCII byte (`\n`, `\r`) remains valid UTF-8.
91        let line = unsafe { std::str::from_utf8_unchecked(trimmed) };
92        self.cursor += advance;
93        Some(line)
94    }
95
96    /// memchr-backed equivalent of [`Self::forward`]. Skips the next
97    /// frame without fully parsing its atom data, walking the
98    /// underlying byte slice with `memchr` directly instead of the
99    /// peekable line iterator. The two methods are not interleavable:
100    /// once the caller starts using `forward_fast`, switching to
101    /// `next()` or `forward()` resets to the line iterator's view and
102    /// loses any progress the cursor made.
103    pub fn forward_fast(&mut self) -> Option<Result<(), error::ParseError>> {
104        if self.cursor >= self.bytes.len() {
105            return None;
106        }
107        // Lines 1..=6 of the header are skipped wholesale.
108        if let Err(e) = self.advance_lines(6) {
109            return Some(Err(e));
110        }
111        // Line 7: natm_types.
112        let natm_types: usize = match self.read_line_str() {
113            Some(line) => match crate::parser::parse_line_of_n::<usize>(line, 1) {
114                Ok(v) => v[0],
115                Err(e) => return Some(Err(e)),
116            },
117            None => return Some(Err(error::ParseError::IncompleteHeader)),
118        };
119        // Line 8: natms_per_type.
120        let natms_per_type: Vec<usize> = match self.read_line_str() {
121            Some(line) => match crate::parser::parse_line_of_n(line, natm_types) {
122                Ok(v) => v,
123                Err(e) => return Some(Err(e)),
124            },
125            None => return Some(Err(error::ParseError::IncompleteHeader)),
126        };
127        // Line 9: masses_per_type, consumed.
128        if let Err(e) = self.advance_lines(1) {
129            return Some(Err(e));
130        }
131        let total_atoms: usize = natms_per_type.iter().sum();
132        let coord_block_lines = total_atoms + natm_types * 2;
133        if let Err(e) = self.advance_lines(coord_block_lines) {
134            return Some(Err(e));
135        }
136        // Optional sections: blank line + same-shape block, repeated.
137        loop {
138            let rest = &self.bytes[self.cursor..];
139            // Peek the next line without advancing the cursor.
140            let next_eol = memchr::memchr(b'\n', rest);
141            let line = match next_eol {
142                Some(pos) => &rest[..pos],
143                None => rest,
144            };
145            let is_blank = line
146                .iter()
147                .all(|b| matches!(b, b' ' | b'\t' | b'\r'));
148            if !is_blank || line.is_empty() && self.cursor >= self.bytes.len() {
149                break;
150            }
151            if !is_blank {
152                break;
153            }
154            // Consume the blank separator and the section block.
155            self.cursor += next_eol.map(|p| p + 1).unwrap_or(rest.len());
156            if let Err(e) = self.advance_lines(coord_block_lines) {
157                return Some(Err(e));
158            }
159        }
160        Some(Ok(()))
161    }
162
163    /// Skips the next frame without fully parsing its atomic data.
164    ///
165    /// This is more efficient than `next()` if you only need to advance the
166    /// iterator. It reads the frame's header to determine how many lines to skip,
167    /// including any velocity section if present.
168    ///
169    /// # Returns
170    ///
171    /// * `Some(Ok(()))` on a successful skip.
172    /// * `Some(Err(ParseError::...))` if there's an error parsing the header.
173    /// * `None` if the iterator is already at the end.
174    pub fn forward(&mut self) -> Option<Result<(), error::ParseError>> {
175        // Skip frame by parsing only required header fields to avoid full parsing overhead
176        self.lines.peek()?;
177
178        // Manually consume the first 6 lines of the header, which we don't need for skipping.
179        for _ in 0..6 {
180            if self.lines.next().is_none() {
181                return Some(Err(error::ParseError::IncompleteHeader));
182            }
183        }
184
185        // Line 7: natm_types. We need to parse this.
186        let natm_types: usize = match self.lines.next() {
187            Some(line) => match crate::parser::parse_line_of_n::<usize>(line, 1) {
188                Ok(v) => v[0],
189                Err(e) => return Some(Err(e)),
190            },
191            None => return Some(Err(error::ParseError::IncompleteHeader)),
192        };
193
194        // Line 8: natms_per_type. We need this to sum the total number of atoms.
195        let natms_per_type: Vec<usize> = match self.lines.next() {
196            Some(line) => match crate::parser::parse_line_of_n(line, natm_types) {
197                Ok(v) => v,
198                Err(e) => return Some(Err(e)),
199            },
200            None => return Some(Err(error::ParseError::IncompleteHeader)),
201        };
202
203        // Line 9: masses_per_type. We just need to consume this line.
204        if self.lines.next().is_none() {
205            return Some(Err(error::ParseError::IncompleteHeader));
206        }
207
208        // Calculate how many more lines to skip for coordinate blocks.
209        let total_atoms: usize = natms_per_type.iter().sum();
210        // For each atom type, there is a symbol line and a "Coordinates..." line.
211        let non_atom_lines = natm_types * 2;
212        let lines_to_skip = total_atoms + non_atom_lines;
213
214        // Advance the iterator by skipping the coordinate block lines.
215        for _ in 0..lines_to_skip {
216            if self.lines.next().is_none() {
217                // The file ended before the header's promise was fulfilled.
218                return Some(Err(error::ParseError::IncompleteFrame));
219            }
220        }
221
222        // Skip additional sections (velocities, forces).
223        // Each section has: blank separator + same structure as coordinate blocks.
224        // We don't have access to the parsed sections list here (we only parsed
225        // the header minimally), so we detect sections by peeking for blank separators.
226        loop {
227            match self.lines.peek() {
228                Some(line) if line.trim().is_empty() => {
229                    self.lines.next(); // consume blank separator
230                    let section_lines = total_atoms + non_atom_lines;
231                    for _ in 0..section_lines {
232                        if self.lines.next().is_none() {
233                            return Some(Err(error::ParseError::IncompleteFrame));
234                        }
235                    }
236                }
237                _ => break,
238            }
239        }
240
241        Some(Ok(()))
242    }
243
244    /// Next frame plus the exact substring of the buffer passed to [`Self::new`].
245    ///
246    /// **Corpus ingest contract:** successive successful spans from the same
247    /// `file_contents` are contiguous (`end` of frame *i* equals `start` of frame
248    /// *i+1*) and, for a buffer that is only multi-frame CON (no prefix garbage),
249    /// concatenating all spans reproduces the trajectory text. Campaign stores
250    /// (`readcon-db`) must persist these spans as authoritative blobs—do not
251    /// re-serialize on the hot ingest path unless the caller supplied in-memory
252    /// [`types::ConFrame`] values without source text.
253    ///
254    /// See also [`crate::index_proj::frame_byte_spans`] and
255    /// [`crate::index_proj::spans_cover_buffer`].
256    pub fn next_with_raw_span(
257        &mut self,
258        file_contents: &'a str,
259    ) -> Option<Result<(types::ConFrame, &'a str), error::ParseError>> {
260        let base = file_contents.as_ptr() as usize;
261        let start = {
262            let line = self.lines.peek()?;
263            line.as_ptr() as usize - base
264        };
265        let frame = match self.next()? {
266            Ok(f) => f,
267            Err(e) => return Some(Err(e)),
268        };
269        let end = match self.lines.peek() {
270            Some(line) => line.as_ptr() as usize - base,
271            None => file_contents.len(),
272        };
273        debug_assert!(end >= start && end <= file_contents.len());
274        // frame already went through next() → sync_arrays_from_atom_data
275        Some(Ok((frame, &file_contents[start..end])))
276    }
277}
278
279impl<'a> Iterator for ConFrameIterator<'a> {
280    /// The type of item yielded by the iterator.
281    ///
282    /// Each item is a `Result` that contains a successfully parsed `ConFrame` or a
283    /// `ParseError` if the frame's data is malformed.
284    type Item = Result<types::ConFrame, error::ParseError>;
285
286    /// Advances the iterator and attempts to parse the next frame.
287    ///
288    /// This method will return `None` only when there are no more lines to consume.
289    /// If there are lines but they do not form a complete frame, it will return
290    /// `Some(Err(ParseError::...))`.
291    fn next(&mut self) -> Option<Self::Item> {
292        // If there are no more lines at all, the iterator is exhausted.
293        self.lines.peek()?;
294        // Otherwise, attempt to parse the next frame from the available lines.
295        let mut frame = match parse_single_frame(&mut self.lines) {
296            Ok(f) => f,
297            Err(e) => return Some(Err(e)),
298        };
299        // Parse declared sections (velocities, forces) or fall back to legacy velocity detection
300        // (mutates AoS only; SoA sections filled below).
301        match parse_declared_sections(&mut self.lines, &mut frame.header, &mut frame.atom_data) {
302            Ok(_) => {}
303            Err(e) => return Some(Err(e)),
304        }
305        frame.sync_arrays_from_atom_data();
306        Some(Ok(frame))
307    }
308}
309
310#[cfg(test)]
311mod aos_soa_agreement_tests {
312    use super::*;
313    use std::path::PathBuf;
314
315    #[test]
316    fn iterator_vel_forces_soa_matches_aos() {
317        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
318            .join("resources/test/tiny_cuh2_vel_forces.con");
319        let text = std::fs::read_to_string(&p).expect("fixture");
320        let fr = ConFrameIterator::new(&text)
321            .next()
322            .expect("frame")
323            .expect("parse");
324        let n = fr.atom_data.len();
325        assert!(n > 0);
326        assert_eq!(fr.positions.nrows(), n);
327        let has_vel = fr.atom_data.iter().any(|a| a.velocity.is_some());
328        let has_frc = fr.atom_data.iter().any(|a| a.force.is_some());
329        if has_vel {
330            assert_eq!(
331                fr.velocities.nrows(),
332                n,
333                "SoA velocities must match AoS after section parse"
334            );
335        }
336        if has_frc {
337            assert_eq!(fr.forces.nrows(), n, "SoA forces must match AoS");
338        }
339        for (i, a) in fr.atom_data.iter().enumerate() {
340            let p = fr.positions.as_f64_row(i);
341            assert_eq!([a.x, a.y, a.z], p);
342            if let Some(v) = a.velocity {
343                assert_eq!(v, fr.velocities.as_f64_row(i));
344            }
345            if let Some(f) = a.force {
346                assert_eq!(f, fr.forces.as_f64_row(i));
347            }
348        }
349    }
350
351    /// After SoA-primary parse, section sync must not require rewriting positions
352    /// (nrows already equals N); forces SoA still filled from AoS.
353    #[test]
354    fn sync_skips_pos_when_nrows_matches_keeps_force_soa() {
355        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
356            .join("resources/test/tiny_cuh2_forces.con");
357        let text = std::fs::read_to_string(&p).expect("fixture");
358        let fr = ConFrameIterator::new(&text)
359            .next()
360            .expect("frame")
361            .expect("parse");
362        let n = fr.atom_data.len();
363        assert_eq!(fr.positions.nrows(), n);
364        assert_eq!(fr.forces.nrows(), n);
365        // Snapshot first position SoA row then re-sync; coords must stay bit-identical
366        // (no needless rewrite would change nothing but we still require agreement).
367        let p0 = fr.positions.as_f64_row(0);
368        let mut fr2 = fr.clone();
369        fr2.sync_arrays_from_atom_data();
370        assert_eq!(fr2.positions.as_f64_row(0), p0);
371        assert_eq!(fr2.forces.nrows(), n);
372        assert_eq!(
373            fr2.forces.as_f64_row(0),
374            fr2.atom_data[0].force.expect("force")
375        );
376    }
377}
378
379/// Reads all frames from a file.
380///
381/// For files smaller than 64 KiB, uses a simple `read_to_string` to avoid
382/// the fixed overhead of mmap (VMA creation, page fault, munmap). For larger
383/// trajectory files, uses memory-mapped I/O to let the OS page cache handle
384/// the data.
385pub fn read_all_frames(path: &Path) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
386    let contents = crate::compression::read_file_contents(path)?;
387    let text = contents.as_str()?;
388    let iter = ConFrameIterator::new(text);
389    let frames: Result<Vec<_>, _> = iter.collect();
390    Ok(frames?)
391}
392
393/// Reads only the first frame from a file.
394///
395/// More efficient than `read_all_frames` for single-frame access because it
396/// stops parsing after the first frame rather than collecting all of them.
397pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
398    let contents = crate::compression::read_file_contents(path)?;
399    let text = contents.as_str()?;
400    let mut iter = ConFrameIterator::new(text);
401    match iter.next() {
402        Some(Ok(frame)) => Ok(frame),
403        Some(Err(e)) => Err(Box::new(e)),
404        None => Err("No frames found in file".into()),
405    }
406}
407
408/// Parses frames in parallel using rayon, splitting on frame boundaries.
409///
410/// Phase 1: sequential O(N) scan via memchr-backed
411/// [`ConFrameIterator::forward_fast`] to find byte offsets of every
412/// frame's start. The previous implementation built a `Vec<&str>` of
413/// every line and called `lines[..i].iter().map(|l| l.len() +
414/// 1).sum()` on every frame, which is O(N^2) in line count and
415/// dominated runtime on multi-frame trajectories.
416///
417/// Phase 2: parallel parse of each frame slice using rayon.
418///
419/// Requires the `parallel` feature.
420#[cfg(feature = "parallel")]
421pub fn parse_frames_parallel(
422    file_contents: &str,
423) -> Vec<Result<types::ConFrame, error::ParseError>> {
424    use rayon::prelude::*;
425
426    // Phase 1: walk the file once with forward_fast and snapshot the
427    // cursor before each frame.
428    let mut boundaries: Vec<usize> = Vec::new();
429    let mut scanner = ConFrameIterator::new(file_contents);
430    loop {
431        let start = scanner.cursor;
432        if start >= scanner.bytes.len() {
433            break;
434        }
435        boundaries.push(start);
436        match scanner.forward_fast() {
437            Some(Ok(())) => {}
438            Some(Err(_)) | None => break,
439        }
440    }
441
442    // Phase 2: parallel parse each frame chunk
443    let num_frames = boundaries.len();
444    (0..num_frames)
445        .into_par_iter()
446        .map(|i| {
447            let start = boundaries[i];
448            let end = if i + 1 < num_frames {
449                boundaries[i + 1]
450            } else {
451                file_contents.len()
452            };
453            let chunk = &file_contents[start..end];
454            let mut iter = ConFrameIterator::new(chunk);
455            match iter.next() {
456                Some(result) => result,
457                None => Err(error::ParseError::IncompleteFrame),
458            }
459        })
460        .collect()
461}