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::{LineStream, parse_declared_sections};
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    /// When the buffer was not already a `&str`, header lines are checked
20    /// as they are tokenized. Coordinate rows stay on the byte parser.
21    check_utf8: bool,
22    utf8_bad: bool,
23}
24
25impl<'a> MemchrLines<'a> {
26    pub fn new(text: &'a str) -> Self {
27        Self::from_parts(text.as_bytes(), false)
28    }
29
30    /// Cursor over bytes that are not yet known to be UTF-8.
31    ///
32    /// ASCII coordinate rows are accepted by the numeric scan. Text lines
33    /// are validated when they are materialized, so there is no separate
34    /// whole-buffer UTF-8 pass.
35    pub fn from_bytes(bytes: &'a [u8]) -> Self {
36        Self::from_parts(bytes, true)
37    }
38
39    fn from_parts(bytes: &'a [u8], check_utf8: bool) -> Self {
40        Self {
41            bytes,
42            pos: 0,
43            peeked: None,
44            check_utf8,
45            utf8_bad: false,
46        }
47    }
48
49    #[inline]
50    fn read_one(&mut self) -> Option<&'a str> {
51        if self.utf8_bad || self.pos >= self.bytes.len() {
52            return None;
53        }
54        let rest = &self.bytes[self.pos..];
55        let (line_bytes, advance) = match memchr::memchr(b'\n', rest) {
56            Some(i) => (&rest[..i], i + 1),
57            None => (rest, rest.len()),
58        };
59        let trimmed = if line_bytes.last() == Some(&b'\r') {
60            &line_bytes[..line_bytes.len() - 1]
61        } else {
62            line_bytes
63        };
64        if self.check_utf8 {
65            match std::str::from_utf8(trimmed) {
66                Ok(s) => {
67                    self.pos += advance;
68                    Some(s)
69                }
70                Err(_) => {
71                    // Stay on the rejected line. Moving past it makes a
72                    // whitespace tail look like a clean end of file.
73                    self.utf8_bad = true;
74                    None
75                }
76            }
77        } else {
78            self.pos += advance;
79            // SAFETY: source was `&str`; line is a UTF-8 prefix cut on ASCII `\n`/`\r`.
80            Some(unsafe { std::str::from_utf8_unchecked(trimmed) })
81        }
82    }
83
84    #[inline]
85    pub fn next_line(&mut self) -> Option<&'a str> {
86        if let Some(p) = self.peeked.take() {
87            return Some(p);
88        }
89        self.read_one()
90    }
91
92    #[inline]
93    pub fn peek_line(&mut self) -> Option<&'a str> {
94        if self.peeked.is_none() {
95            self.peeked = self.read_one();
96        }
97        self.peeked
98    }
99
100    /// Drop any peek buffer (required before bulk cursor advances).
101    fn clear_peek(&mut self) {
102        if let Some(p) = self.peeked.take() {
103            // Rewind pos to the start of the peeked line.
104            let start = p.as_ptr() as usize - self.bytes.as_ptr() as usize;
105            self.pos = start;
106        }
107    }
108
109    fn utf8_error(&self) -> Option<error::ParseError> {
110        self.utf8_bad.then(invalid_utf8)
111    }
112
113    /// Keep `err` unless this cursor already rejected a line as UTF-8.
114    fn prefer_utf8(&self, err: error::ParseError) -> error::ParseError {
115        if self.utf8_bad { invalid_utf8() } else { err }
116    }
117}
118
119fn invalid_utf8() -> error::ParseError {
120    error::ParseError::ValidationError("invalid UTF-8".into())
121}
122
123impl<'a> Iterator for MemchrLines<'a> {
124    type Item = &'a str;
125    fn next(&mut self) -> Option<&'a str> {
126        self.next_line()
127    }
128}
129
130impl<'a> LineStream<'a> for MemchrLines<'a> {
131    #[inline]
132    fn next_line(&mut self) -> Option<&'a str> {
133        MemchrLines::next_line(self)
134    }
135    #[inline]
136    fn peek_line(&mut self) -> Option<&'a str> {
137        MemchrLines::peek_line(self)
138    }
139    #[inline]
140    fn next_coord_row(
141        &mut self,
142        spec_version: u32,
143        default_id: u64,
144    ) -> Result<([f64; 3], [bool; 3], u64), crate::error::ParseError> {
145        if let Some(p) = self.peeked.take() {
146            return crate::parser::parse_coord_row_at(p.as_bytes(), 0, spec_version, default_id)
147                .map(|(row, _)| row);
148        }
149        let (row, pos) =
150            crate::parser::parse_coord_row_at(self.bytes, self.pos, spec_version, default_id)?;
151        self.pos = pos;
152        Ok(row)
153    }
154    fn remainder_is_whitespace(&mut self) -> bool {
155        if self.utf8_bad {
156            return false;
157        }
158        if let Some(line) = self.peek_line()
159            && !line.trim().is_empty()
160        {
161            return false;
162        }
163        // `peek_line` may have rejected a line. That is a decode error,
164        // including when every byte after it is ASCII whitespace.
165        if self.utf8_bad {
166            return false;
167        }
168        self.bytes[self.pos..]
169            .iter()
170            .all(|b| b.is_ascii_whitespace())
171    }
172    fn blank_line_opens_section(&mut self) -> bool {
173        // `peek_line` has already moved `pos` to the line after the peek.
174        let Some(peeked) = self.peeked else {
175            return false;
176        };
177        if !peeked.trim().is_empty() {
178            return false;
179        }
180        match probe_section(&self.bytes[self.pos..]) {
181            SectionProbe::Opens => true,
182            SectionProbe::Closed => false,
183            // Leave the rejected bytes on the cursor. `prefer_utf8` then
184            // reports the decode error instead of a missing section.
185            SectionProbe::InvalidUtf8 => {
186                self.utf8_bad = true;
187                false
188            }
189        }
190    }
191
192    fn blank_opens_velocity_block(&mut self) -> bool {
193        match self.peek_line() {
194            Some(line) if line.trim().is_empty() => self.blank_line_opens_section(),
195            _ => true,
196        }
197    }
198}
199
200fn split_raw_line(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
201    if bytes.is_empty() {
202        return None;
203    }
204    match memchr::memchr(b'\n', bytes) {
205        Some(i) => Some((&bytes[..i], &bytes[i + 1..])),
206        None => Some((bytes, &[])),
207    }
208}
209
210fn strip_cr(line: &[u8]) -> &[u8] {
211    if line.last() == Some(&b'\r') {
212        &line[..line.len() - 1]
213    } else {
214        line
215    }
216}
217
218enum SectionProbe {
219    Opens,
220    Closed,
221    InvalidUtf8,
222}
223
224/// A blank line opens a section when the next line is a symbol and the
225/// line after that is a component label (CON-file.5, CON-header.4).
226fn probe_section(after_blank: &[u8]) -> SectionProbe {
227    let Some((raw1, after1)) = split_raw_line(after_blank) else {
228        return SectionProbe::Closed;
229    };
230    let line1 = strip_cr(raw1);
231    if line1.iter().all(|b| matches!(b, b' ' | b'\t')) {
232        return SectionProbe::Closed;
233    }
234    let Ok(text1) = std::str::from_utf8(line1) else {
235        return SectionProbe::InvalidUtf8;
236    };
237    if text1.trim_start().starts_with('{') {
238        return SectionProbe::Closed;
239    }
240    let Some((raw2, _)) = split_raw_line(after1) else {
241        return SectionProbe::Closed;
242    };
243    let Ok(text2) = std::str::from_utf8(strip_cr(raw2)) else {
244        return SectionProbe::InvalidUtf8;
245    };
246    if crate::parser::line_has_component_label(text2) {
247        SectionProbe::Opens
248    } else {
249        SectionProbe::Closed
250    }
251}
252
253fn blank_opens_section(after_blank: &[u8]) -> bool {
254    matches!(probe_section(after_blank), SectionProbe::Opens)
255}
256
257/// An iterator that lazily parses simulation frames from a `.con` or `.convel`
258/// file's contents.
259///
260/// This struct wraps a memchr line cursor over the file buffer and, upon each
261/// iteration, attempts to parse a complete `ConFrame`. A blank line after the
262/// coordinates is a velocity section only when it begins a complete velocity
263/// block. Otherwise that line is line 1 of the next frame, and it may be empty.
264///
265/// The iterator yields items of type `Result<ConFrame, ParseError>`, allowing for
266/// robust error handling for each frame.
267pub struct ConFrameIterator<'a> {
268    pub(crate) lines: MemchrLines<'a>,
269}
270
271impl<'a> ConFrameIterator<'a> {
272    /// Creates a new `ConFrameIterator` from a string slice of the entire file.
273    ///
274    /// # Arguments
275    ///
276    /// * `file_contents` - A string slice containing the text of one or more `.con` frames.
277    pub fn new(file_contents: &'a str) -> Self {
278        ConFrameIterator {
279            lines: MemchrLines::new(file_contents),
280        }
281    }
282
283    /// Like [`Self::new`], but reading from byte `pos` of `file_contents`.
284    ///
285    /// `pos` must be 0 or follow a `\n`. The iterator keeps no state from
286    /// earlier frames, so skipping from `pos` sees what a scan sees there.
287    pub(crate) fn starting_at(file_contents: &'a str, pos: usize) -> Self {
288        debug_assert!(pos == 0 || file_contents.as_bytes()[pos - 1] == b'\n');
289        let mut it = Self::new(file_contents);
290        it.lines.pos = pos;
291        it
292    }
293
294    /// Like [`Self::new`] for a buffer that has not been UTF-8 validated.
295    ///
296    /// Text lines are checked as they are tokenized. Numeric rows reject a
297    /// non-ASCII byte during the fused parse, which is the UTF-8 check for
298    /// those lines.
299    pub fn from_bytes(bytes: &'a [u8]) -> Self {
300        ConFrameIterator {
301            lines: MemchrLines::from_bytes(bytes),
302        }
303    }
304
305    /// Bulk-skips `n` lines from the shared memchr cursor.
306    ///
307    /// A final line with no trailing newline still counts when it is the
308    /// last line this call needs and its bytes are present. The full
309    /// parser accepts that line. Further lines after it are missing.
310    fn advance_lines(&mut self, n: usize) -> Result<(), error::ParseError> {
311        self.lines.clear_peek();
312        for i in 0..n {
313            let rest = &self.lines.bytes[self.lines.pos..];
314            match memchr::memchr(b'\n', rest) {
315                Some(pos) => self.lines.pos += pos + 1,
316                None => {
317                    if rest.is_empty() {
318                        return Err(error::ParseError::IncompleteFrame);
319                    }
320                    self.lines.pos = self.lines.bytes.len();
321                    if i + 1 == n {
322                        return Ok(());
323                    }
324                    return Err(error::ParseError::IncompleteFrame);
325                }
326            }
327        }
328        Ok(())
329    }
330
331    /// [`Self::advance_lines`] for `n` lines expected to end at `end`.
332    ///
333    /// When `end` follows a `\n` and exactly `n` newlines lie before it,
334    /// the per-line search would stop at `end` too, so one vectorized
335    /// count replaces it. Any other case takes the per-line search.
336    fn advance_lines_to(&mut self, n: usize, end: Option<usize>) -> Result<(), error::ParseError> {
337        self.lines.clear_peek();
338        let pos = self.lines.pos;
339        let bytes = self.lines.bytes;
340        if let Some(end) = end
341            && pos < end
342            && end <= bytes.len()
343            && bytes[end - 1] == b'\n'
344            && memchr::memchr_iter(b'\n', &bytes[pos..end]).count() == n
345        {
346            self.lines.pos = end;
347            return Ok(());
348        }
349        self.advance_lines(n)
350    }
351
352    /// One line from the shared cursor (same as full-parse path).
353    fn read_line_str(&mut self) -> Option<&'a str> {
354        self.lines.clear_peek();
355        self.lines.next_line()
356    }
357
358    /// memchr-backed equivalent of [`Self::forward`]. Skips the next
359    /// frame without fully parsing its atom data. Shares the same line
360    /// cursor as [`Iterator::next`], so skip and full parse interleave safely.
361    pub fn forward_fast(&mut self) -> Option<Result<(), error::ParseError>> {
362        self.forward_fast_to(None)
363    }
364
365    /// [`Self::forward_fast`] for a frame expected to end at byte `end`.
366    ///
367    /// The result and the new position are the same as without `end`.
368    /// Line blocks that end exactly at `end` are counted, not searched.
369    pub(crate) fn forward_fast_to(
370        &mut self,
371        end: Option<usize>,
372    ) -> Option<Result<(), error::ParseError>> {
373        self.lines.clear_peek();
374        if self.lines.pos >= self.lines.bytes.len() {
375            return None;
376        }
377        // Lines 1..=6 of the header are skipped wholesale.
378        if let Err(e) = self.advance_lines(6) {
379            return Some(Err(e));
380        }
381        // Line 7: natm_types.
382        let natm_types: usize = match self.read_line_str() {
383            Some(line) => match crate::parser::parse_count_line(line, 1) {
384                Ok(v) => v[0],
385                Err(e) => return Some(Err(e)),
386            },
387            None => return Some(Err(error::ParseError::IncompleteHeader)),
388        };
389        if natm_types == 0 {
390            // Lines 8 and 9 are whitespace, or absent at EOF. Do not scan
391            // for coordinates or a legacy velocity block (CON-count.1).
392            return Some(crate::parser::read_zero_atom_tail(|| self.read_line_str()));
393        }
394        // Line 8: natms_per_type.
395        let natms_per_type: Vec<usize> = match self.read_line_str() {
396            Some(line) => match crate::parser::parse_count_line(line, natm_types) {
397                Ok(v) => v,
398                Err(e) => return Some(Err(e)),
399            },
400            None => return Some(Err(error::ParseError::IncompleteHeader)),
401        };
402        if natms_per_type.iter().any(|n| *n == 0) {
403            return Some(Err(error::ParseError::InvalidCount(
404                "each per-type count must be >= 1".into(),
405            )));
406        }
407        // Line 9: masses_per_type, consumed.
408        if let Err(e) = self.advance_lines(1) {
409            return Some(Err(e));
410        }
411        let total_atoms: usize = natms_per_type.iter().sum();
412        let coord_block_lines = total_atoms + natm_types * 2;
413        if let Err(e) = self.advance_lines_to(coord_block_lines, end) {
414            return Some(Err(e));
415        }
416        // Optional sections: blank line + same-shape block, repeated.
417        self.lines.clear_peek();
418        loop {
419            let rest = &self.lines.bytes[self.lines.pos..];
420            if rest.is_empty() {
421                break;
422            }
423            let next_eol = memchr::memchr(b'\n', rest);
424            let line = match next_eol {
425                Some(pos) => &rest[..pos],
426                None => rest,
427            };
428            let is_blank = line.iter().all(|b| matches!(b, b' ' | b'\t' | b'\r'));
429            if !is_blank {
430                break;
431            }
432            let after = match next_eol {
433                Some(pos) => &rest[pos + 1..],
434                None => &[][..],
435            };
436            // A blank line and then only whitespace is the end of the file,
437            // not a section (CON-frame.5).
438            if after.iter().all(|b| b.is_ascii_whitespace()) {
439                self.lines.pos = self.lines.bytes.len();
440                break;
441            }
442            // A blank that does not begin a section is line 1 of the next
443            // frame, including an empty user line (CON-frame.7, CON-header.4).
444            if !blank_opens_section(after) {
445                break;
446            }
447            // Consume the blank separator and the section block.
448            self.lines.pos += next_eol.map(|p| p + 1).unwrap_or(rest.len());
449            if let Err(e) = self.advance_lines_to(coord_block_lines, end) {
450                return Some(Err(e));
451            }
452        }
453        Some(Ok(()))
454    }
455
456    /// Skips the next frame without fully parsing its atomic data.
457    ///
458    /// This is more efficient than `next()` if you only need to advance the
459    /// iterator. It reads the frame's header to determine how many lines to skip,
460    /// including any velocity section if present.
461    ///
462    /// # Returns
463    ///
464    /// * `Some(Ok(()))` on a successful skip.
465    /// * `Some(Err(ParseError::...))` if there's an error parsing the header.
466    /// * `None` if the iterator is already at the end.
467    pub fn forward(&mut self) -> Option<Result<(), error::ParseError>> {
468        // Prefer the shared memchr skip path (same cursor as full parse).
469        self.forward_fast()
470    }
471
472    /// Byte offset of the next frame start in the buffer passed to [`Self::new`].
473    pub fn byte_pos(&self) -> usize {
474        self.lines.pos
475    }
476
477    /// Skip `n` frames without parsing atom data. Returns how many were skipped.
478    /// Named `skip_frames` so it does not collide with [`Iterator::skip`].
479    pub fn skip_frames(&mut self, n: usize) -> Result<usize, error::ParseError> {
480        let mut skipped = 0usize;
481        for _ in 0..n {
482            match self.forward() {
483                None => break,
484                Some(Err(e)) => return Err(e),
485                Some(Ok(())) => skipped += 1,
486            }
487        }
488        Ok(skipped)
489    }
490
491    /// Next frame plus the exact substring of the buffer passed to [`Self::new`].
492    ///
493    /// **Corpus ingest contract:** successive successful spans from the same
494    /// `file_contents` are contiguous (`end` of frame *i* equals `start` of frame
495    /// *i+1*) and, for a buffer that is only multi-frame CON (no prefix garbage),
496    /// concatenating all spans reproduces the trajectory text. Campaign stores
497    /// (`readcon-db`) must persist these spans as authoritative blobs—do not
498    /// re-serialize on the hot ingest path unless the caller supplied in-memory
499    /// [`types::ConFrame`] values without source text.
500    ///
501    /// See also [`crate::index_proj::frame_byte_spans`] and
502    /// [`crate::index_proj::spans_cover_buffer`].
503    pub fn next_with_raw_span(
504        &mut self,
505        file_contents: &'a str,
506    ) -> Option<Result<(types::ConFrame, &'a str), error::ParseError>> {
507        let base = file_contents.as_ptr() as usize;
508        let start = {
509            let line = self.lines.peek_line()?;
510            line.as_ptr() as usize - base
511        };
512        let frame = match self.next()? {
513            Ok(f) => f,
514            Err(e) => return Some(Err(e)),
515        };
516        let end = match self.lines.peek_line() {
517            Some(line) => line.as_ptr() as usize - base,
518            None => file_contents.len(),
519        };
520        debug_assert!(end >= start && end <= file_contents.len());
521        Some(Ok((frame, &file_contents[start..end])))
522    }
523
524    /// [`Iterator::next`] plus the byte offset where the frame ends.
525    pub(crate) fn next_with_end(
526        &mut self,
527    ) -> Option<Result<(types::ConFrame, usize), error::ParseError>> {
528        let frame = match self.next()? {
529            Ok(f) => f,
530            Err(e) => return Some(Err(e)),
531        };
532        let end = match self.lines.peek_line() {
533            Some(line) => line.as_ptr() as usize - self.lines.bytes.as_ptr() as usize,
534            None => self.lines.pos,
535        };
536        Some(Ok((frame, end)))
537    }
538}
539
540impl<'a> Iterator for ConFrameIterator<'a> {
541    /// The type of item yielded by the iterator.
542    ///
543    /// Each item is a `Result` that contains a successfully parsed `ConFrame` or a
544    /// `ParseError` if the frame's data is malformed.
545    type Item = Result<types::ConFrame, error::ParseError>;
546
547    /// Advances the iterator and attempts to parse the next frame.
548    ///
549    /// This method will return `None` only when there are no more lines to consume.
550    /// If there are lines but they do not form a complete frame, it will return
551    /// `Some(Err(ParseError::...))`. Invalid UTF-8 is a validation error, not
552    /// the end of the stream.
553    fn next(&mut self) -> Option<Self::Item> {
554        // A line rejected earlier in this cursor (for example while probing
555        // for a velocity block) must not be reported as a clean EOF.
556        if let Some(err) = self.lines.utf8_error() {
557            return Some(Err(err));
558        }
559        // Trailing whitespace after the last complete frame is not a frame
560        // (CON-frame.1, D2). An empty line that is followed by more text is
561        // line 1 of the next frame (CON-frame.7).
562        if self.lines.remainder_is_whitespace() {
563            if let Some(err) = self.lines.utf8_error() {
564                return Some(Err(err));
565            }
566            while self.lines.next_line().is_some() {}
567            return None;
568        }
569        if let Some(err) = self.lines.utf8_error() {
570            return Some(Err(err));
571        }
572        // Otherwise, attempt to parse the next frame from the available lines.
573        let mut frame = match crate::parser::parse_single_frame_ls(&mut self.lines) {
574            Ok(f) => f,
575            Err(e) => return Some(Err(self.lines.prefer_utf8(e))),
576        };
577        // Optional sections mutate AoS; only re-sync section SoA when needed.
578        // Plain .con assembly already filled positions/ids/masses (no O(N)
579        // post-scan when no velocity/force sections were applied).
580        let sections =
581            match parse_declared_sections(&mut self.lines, &mut frame.header, &mut frame.atom_data)
582            {
583                Ok(n) => n,
584                // A section line that failed UTF-8 makes `next_line` return
585                // `None`. That must not surface as `IncompleteSection`.
586                Err(e) => return Some(Err(self.lines.prefer_utf8(e))),
587            };
588        if sections > 0 {
589            frame.sync_arrays_from_atom_data();
590        }
591        Some(Ok(frame))
592    }
593}
594
595#[cfg(test)]
596mod aos_soa_agreement_tests {
597    use super::*;
598    use std::path::PathBuf;
599
600    #[test]
601    fn iterator_vel_forces_soa_matches_aos() {
602        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
603            .join("resources/test/tiny_cuh2_vel_forces.con");
604        let text = std::fs::read_to_string(&p).expect("fixture");
605        let fr = ConFrameIterator::new(&text)
606            .next()
607            .expect("frame")
608            .expect("parse");
609        let n = fr.atom_data.len();
610        assert!(n > 0);
611        assert_eq!(fr.positions.nrows(), n);
612        let has_vel = fr.atom_data.iter().any(|a| a.velocity().is_some());
613        let has_frc = fr.atom_data.iter().any(|a| a.force().is_some());
614        if has_vel {
615            assert_eq!(
616                fr.velocities.nrows(),
617                n,
618                "SoA velocities must match AoS after section parse"
619            );
620        }
621        if has_frc {
622            assert_eq!(fr.forces.nrows(), n, "SoA forces must match AoS");
623        }
624        for (i, a) in fr.atom_data.iter().enumerate() {
625            let p = fr.positions.as_f64_row(i);
626            assert_eq!([a.x, a.y, a.z], p);
627            if let Some(v) = a.velocity() {
628                assert_eq!(v, fr.velocities.as_f64_row(i));
629            }
630            if let Some(f) = a.force() {
631                assert_eq!(f, fr.forces.as_f64_row(i));
632            }
633        }
634    }
635
636    /// After SoA-primary parse, section sync must not require rewriting positions
637    /// (nrows already equals N); forces SoA still filled from AoS.
638    #[test]
639    fn sync_skips_pos_when_nrows_matches_keeps_force_soa() {
640        let p =
641            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2_forces.con");
642        let text = std::fs::read_to_string(&p).expect("fixture");
643        let fr = ConFrameIterator::new(&text)
644            .next()
645            .expect("frame")
646            .expect("parse");
647        let n = fr.atom_data.len();
648        assert_eq!(fr.positions.nrows(), n);
649        assert_eq!(fr.forces.nrows(), n);
650        // Snapshot first position SoA row then re-sync; coords must stay bit-identical
651        // (no needless rewrite would change nothing but we still require agreement).
652        let p0 = fr.positions.as_f64_row(0);
653        let mut fr2 = fr.clone();
654        fr2.sync_arrays_from_atom_data();
655        assert_eq!(fr2.positions.as_f64_row(0), p0);
656        assert_eq!(fr2.forces.nrows(), n);
657        assert_eq!(
658            fr2.forces.as_f64_row(0),
659            fr2.atom_data[0].force().expect("force")
660        );
661    }
662}
663
664/// Reads all frames from a file.
665///
666/// For files smaller than 64 KiB, uses a simple `read_to_string` to avoid
667/// the fixed overhead of mmap (VMA creation, page fault, munmap). For larger
668/// trajectory files, uses memory-mapped I/O to let the OS page cache handle
669/// the data.
670/// Byte-size gate for Rayon multi-frame parse. Avoids an extra O(n) frame-count
671/// scan: phase-1 of [`parse_frames_parallel`] already walks boundaries when we
672/// choose parallel. Below this size, sequential parse wins on small multi-frame
673/// files (pool scheduling overhead).
674#[cfg(feature = "parallel")]
675pub(crate) const PARALLEL_BYTES_THRESHOLD: usize = 48 * 1024;
676
677pub fn read_all_frames(path: &Path) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
678    read_all_frames_with_threads(path, None)
679}
680
681/// Collect every frame from `text`.
682///
683/// `num_threads` is `None` for the automatic policy (Rayon when the
684/// `parallel` feature is on and the buffer is at least
685/// [`PARALLEL_BYTES_THRESHOLD`]), `Some(1)` for a sequential iterator,
686/// and `Some(n)` with `n >= 2` for an explicit Rayon pool. Builds
687/// without `parallel` always parse sequentially.
688pub fn frames_from_text(
689    text: &str,
690    num_threads: Option<usize>,
691) -> Result<Vec<types::ConFrame>, error::ParseError> {
692    #[cfg(feature = "parallel")]
693    {
694        let use_parallel = match num_threads {
695            Some(1) => false,
696            Some(n) if n >= 2 => true,
697            // None and Some(0): automatic policy.
698            _ => text.len() >= PARALLEL_BYTES_THRESHOLD,
699        };
700        if use_parallel {
701            let pool = num_threads.filter(|&n| n >= 2);
702            let parts = parse_frames_parallel_with_threads(text, pool);
703            let mut frames = Vec::with_capacity(parts.len());
704            for r in parts {
705                frames.push(r?);
706            }
707            return Ok(frames);
708        }
709    }
710    #[cfg(not(feature = "parallel"))]
711    {
712        let _ = num_threads;
713    }
714    ConFrameIterator::new(text).collect()
715}
716
717/// Like [`read_all_frames`], with an explicit worker count.
718///
719/// `None` is the automatic policy used by [`read_all_frames`]. `Some(1)`
720/// forces sequential parse. `Some(n)` with `n >= 2` pins a Rayon pool
721/// (ignored when the `parallel` feature is off).
722pub fn read_all_frames_with_threads(
723    path: &Path,
724    num_threads: Option<usize>,
725) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
726    let contents = crate::compression::read_file_contents(path)?;
727    match contents {
728        crate::compression::FileContents::Owned(text) => Ok(frames_from_text(&text, num_threads)?),
729        // Mapped bytes are not pre-validated. The cursor checks UTF-8 on
730        // text lines and the coordinate scan rejects non-ASCII tokens.
731        crate::compression::FileContents::Mapped(map) => Ok(frames_from_bytes(&map, num_threads)?),
732    }
733}
734
735/// [`frames_from_text`] for bytes that may not be UTF-8 yet.
736pub fn frames_from_bytes(
737    bytes: &[u8],
738    num_threads: Option<usize>,
739) -> Result<Vec<types::ConFrame>, error::ParseError> {
740    #[cfg(feature = "parallel")]
741    {
742        let use_parallel = match num_threads {
743            Some(1) => false,
744            Some(n) if n >= 2 => true,
745            _ => bytes.len() >= PARALLEL_BYTES_THRESHOLD,
746        };
747        if use_parallel {
748            let pool = num_threads.filter(|&n| n >= 2);
749            let parts = parse_frames_parallel_bytes(bytes, pool);
750            let mut frames = Vec::with_capacity(parts.len());
751            for r in parts {
752                frames.push(r?);
753            }
754            return Ok(frames);
755        }
756    }
757    #[cfg(not(feature = "parallel"))]
758    {
759        let _ = num_threads;
760    }
761    let mut frames = Vec::new();
762    for r in ConFrameIterator::from_bytes(bytes) {
763        frames.push(r?);
764    }
765    Ok(frames)
766}
767
768#[cfg(feature = "parallel")]
769fn parse_frames_parallel_bytes(
770    bytes: &[u8],
771    num_threads: Option<usize>,
772) -> Vec<Result<types::ConFrame, error::ParseError>> {
773    use rayon::prelude::*;
774
775    let mut scanner = ConFrameIterator::from_bytes(bytes);
776    let mut boundaries = Vec::new();
777    loop {
778        scanner.lines.clear_peek();
779        let start = scanner.lines.pos;
780        // Whitespace after the final frame does not open another frame.
781        if start >= bytes.len() || bytes[start..].iter().all(u8::is_ascii_whitespace) {
782            break;
783        }
784        boundaries.push(start);
785        match scanner.forward_fast() {
786            Some(Ok(())) => {}
787            Some(Err(_)) | None => break,
788        }
789    }
790    let parse_chunks = || {
791        let num_frames = boundaries.len();
792        (0..num_frames)
793            .into_par_iter()
794            .map(|i| {
795                let start = boundaries[i];
796                let end = if i + 1 < num_frames {
797                    boundaries[i + 1]
798                } else {
799                    bytes.len()
800                };
801                let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
802                match iter.next() {
803                    Some(result) => result,
804                    None => Err(error::ParseError::IncompleteFrame),
805                }
806            })
807            .collect()
808    };
809    match num_threads {
810        None => parse_chunks(),
811        Some(n) => {
812            let n = n.max(1);
813            match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
814                Ok(pool) => pool.install(parse_chunks),
815                Err(_) => parse_chunks(),
816            }
817        }
818    }
819}
820
821/// Count frames without building atom payloads (uses [`ConFrameIterator::forward_fast`]
822/// when possible, else [`ConFrameIterator::forward`]).
823///
824/// Prefer this over `read_all_frames(...).len()` when only the frame count is needed.
825pub fn count_frames(path: &Path) -> Result<usize, Box<dyn std::error::Error>> {
826    let contents = crate::compression::read_file_contents(path)?;
827    let text = contents.as_str()?;
828    let mut n = 0usize;
829    let mut iter = ConFrameIterator::new(text);
830    loop {
831        match iter.forward_fast() {
832            Some(Ok(())) => n += 1,
833            Some(Err(e)) => return Err(Box::new(e)),
834            None => break,
835        }
836    }
837    Ok(n)
838}
839
840/// Reads only the first frame from a file.
841///
842/// More efficient than `read_all_frames` for single-frame access because it
843/// stops parsing after the first frame rather than collecting all of them.
844pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
845    let contents = crate::compression::read_file_contents(path)?;
846    let text = contents.as_str()?;
847    let mut iter = ConFrameIterator::new(text);
848    match iter.next() {
849        Some(Ok(frame)) => Ok(frame),
850        Some(Err(e)) => Err(Box::new(e)),
851        None => Err("No frames found in file".into()),
852    }
853}
854
855/// Skip `index` frames (no atom parse), then parse the next one.
856///
857/// `index == 0` is [`read_first_frame`]. Out of range is an error.
858pub fn read_nth_frame(
859    path: &Path,
860    index: usize,
861) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
862    let contents = crate::compression::read_file_contents(path)?;
863    let text = contents.as_str()?;
864    read_nth_frame_from_text(text, index)
865}
866
867/// Same as [`read_nth_frame`] on an already-loaded buffer.
868pub fn read_nth_frame_from_text(
869    text: &str,
870    index: usize,
871) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
872    let mut iter = ConFrameIterator::new(text);
873    let skipped = iter.skip_frames(index)?;
874    if skipped < index {
875        return Err(format!("frame {index} out of range (only {skipped} frames)").into());
876    }
877    match iter.next() {
878        Some(Ok(frame)) => Ok(frame),
879        Some(Err(e)) => Err(Box::new(e)),
880        None => Err(format!("frame {index} out of range").into()),
881    }
882}
883
884/// Reads all frames using the checked `.rcidx` sidecar next to `path`.
885///
886/// Uncompressed input uses (and refreshes) `path.rcidx`; compressed
887/// input is scanned in memory each time. UTF-8 is validated in
888/// newline-aligned slices.
889#[cfg(feature = "parallel")]
890pub fn read_all_frames_indexed(
891    path: &Path,
892    num_threads: Option<usize>,
893) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
894    let contents = crate::compression::read_file_contents(path)?;
895    let text = contents.as_str_sliced()?;
896    let idx = match contents {
897        crate::compression::FileContents::Mapped(_) => {
898            crate::frame_index::load_or_build(path, text, true)?
899        }
900        crate::compression::FileContents::Owned(_) => {
901            let on_disk = std::fs::metadata(path)?.len();
902            crate::frame_index::load_or_build(path, text, on_disk == text.len() as u64)?
903        }
904    };
905    let offs: Vec<usize> = idx.offsets.iter().map(|&o| o as usize).collect();
906    parse_frames_at_offsets(text, &offs, num_threads)
907        .into_iter()
908        .map(|r| r.map_err(|e| Box::new(e) as Box<dyn std::error::Error>))
909        .collect()
910}
911
912/// Frame start byte offsets via [`ConFrameIterator::forward_fast`] (no atom parse).
913/// Stops at the first skip error (that start is still included).
914pub fn frame_start_offsets(file_contents: &str) -> Vec<usize> {
915    let mut boundaries = Vec::new();
916    let mut scanner = ConFrameIterator::new(file_contents);
917    loop {
918        scanner.lines.clear_peek();
919        let start = scanner.lines.pos;
920        if start >= scanner.lines.bytes.len() {
921            break;
922        }
923        boundaries.push(start);
924        match scanner.forward_fast() {
925            Some(Ok(())) => {}
926            Some(Err(_)) | None => break,
927        }
928    }
929    boundaries
930}
931
932/// Parses frames in parallel using rayon, splitting on frame boundaries.
933///
934/// Phase 1: sequential O(N) scan via memchr-backed
935/// [`ConFrameIterator::forward_fast`] to find byte offsets of every
936/// frame's start. The previous implementation built a `Vec<&str>` of
937/// every line and called `lines[..i].iter().map(|l| l.len() +
938/// 1).sum()` on every frame, which is O(N^2) in line count and
939/// dominated runtime on multi-frame trajectories.
940///
941/// Phase 2: parallel parse of each frame slice using rayon on the
942/// **global** Rayon pool (see also [`parse_frames_parallel_with_threads`]
943/// for strong-scaling control of the worker count).
944///
945/// Requires the `parallel` feature.
946#[cfg(feature = "parallel")]
947pub fn parse_frames_parallel(
948    file_contents: &str,
949) -> Vec<Result<types::ConFrame, error::ParseError>> {
950    parse_frames_parallel_with_threads(file_contents, None)
951}
952
953/// Like [`parse_frames_parallel`], but runs phase-2 on an explicit Rayon
954/// pool with `num_threads` workers when `Some(n)` (`n` is clamped to at
955/// least 1). `None` uses the global pool (same as [`parse_frames_parallel`]).
956///
957/// Strong-scaling tests pin worker counts without racing the global pool.
958/// Results are ordered by frame index (stable vs sequential iterator order).
959///
960/// Requires the `parallel` feature.
961#[cfg(feature = "parallel")]
962pub fn parse_frames_parallel_with_threads(
963    file_contents: &str,
964    num_threads: Option<usize>,
965) -> Vec<Result<types::ConFrame, error::ParseError>> {
966    // Phase 1: skip walk (no atom parse) for frame start offsets.
967    let boundaries = frame_start_offsets(file_contents);
968    parse_frames_at_offsets(file_contents, &boundaries, num_threads)
969}
970
971/// Phase 2 of [`parse_frames_parallel_with_threads`]: parse the frames
972/// that start at `boundaries` (strictly increasing byte offsets into
973/// `file_contents`; frame `i` ends where frame `i + 1` starts).
974///
975/// Offsets may come from [`frame_start_offsets`] or from a checked
976/// [`crate::frame_index::FrameIndex`]. Results are ordered by frame index.
977///
978/// Requires the `parallel` feature.
979#[cfg(feature = "parallel")]
980pub fn parse_frames_at_offsets(
981    file_contents: &str,
982    boundaries: &[usize],
983    num_threads: Option<usize>,
984) -> Vec<Result<types::ConFrame, error::ParseError>> {
985    use rayon::prelude::*;
986
987    let parse_chunks = || {
988        let num_frames = boundaries.len();
989        (0..num_frames)
990            .into_par_iter()
991            .map(|i| {
992                let start = boundaries[i];
993                let end = if i + 1 < num_frames {
994                    boundaries[i + 1]
995                } else {
996                    file_contents.len()
997                };
998                let chunk = &file_contents[start..end];
999                let mut iter = ConFrameIterator::new(chunk);
1000                match iter.next() {
1001                    Some(result) => result,
1002                    None => Err(error::ParseError::IncompleteFrame),
1003                }
1004            })
1005            .collect()
1006    };
1007
1008    match num_threads {
1009        None => parse_chunks(),
1010        Some(n) => {
1011            let n = n.max(1);
1012            match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
1013                Ok(pool) => pool.install(parse_chunks),
1014                Err(_) => parse_chunks(),
1015            }
1016        }
1017    }
1018}
1019
1020#[cfg(all(test, feature = "parallel"))]
1021mod parallel_strong_scale_tests {
1022    use super::*;
1023    use std::path::PathBuf;
1024
1025    fn multi_frame_fixture() -> String {
1026        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con");
1027        let one = std::fs::read_to_string(p).expect("fixture");
1028        // Enough frames for >1 worker to exercise the pool.
1029        one.repeat(8)
1030    }
1031
1032    fn sequential_frames(text: &str) -> Vec<types::ConFrame> {
1033        ConFrameIterator::new(text)
1034            .map(|r| r.expect("seq frame"))
1035            .collect()
1036    }
1037
1038    fn frames_payload_key(f: &types::ConFrame) -> (usize, Vec<(String, f64, f64, f64)>) {
1039        let atoms: Vec<_> = f
1040            .atom_data
1041            .iter()
1042            .map(|a| (a.symbol.to_string(), a.x, a.y, a.z))
1043            .collect();
1044        (f.atom_data.len(), atoms)
1045    }
1046
1047    #[test]
1048    fn parallel_workers_match_sequential_payloads() {
1049        let text = multi_frame_fixture();
1050        let seq = sequential_frames(&text);
1051        assert!(seq.len() >= 8);
1052        let seq_keys: Vec<_> = seq.iter().map(frames_payload_key).collect();
1053
1054        for workers in [1usize, 2, 4] {
1055            let par = parse_frames_parallel_with_threads(&text, Some(workers));
1056            assert_eq!(par.len(), seq.len(), "workers={workers}");
1057            let par_keys: Vec<_> = par
1058                .into_iter()
1059                .map(|r| frames_payload_key(&r.expect("par frame")))
1060                .collect();
1061            assert_eq!(par_keys, seq_keys, "workers={workers} frame payloads");
1062        }
1063
1064        // Global pool path agrees too.
1065        let par_default = parse_frames_parallel(&text);
1066        let def_keys: Vec<_> = par_default
1067            .into_iter()
1068            .map(|r| frames_payload_key(&r.expect("par")))
1069            .collect();
1070        assert_eq!(def_keys, seq_keys);
1071    }
1072}