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, parse_single_frame};
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    fn remainder_is_whitespace(&mut self) -> bool {
93        if let Some(line) = self.peek_line()
94            && !line.trim().is_empty()
95        {
96            return false;
97        }
98        self.bytes[self.pos..]
99            .iter()
100            .all(|b| b.is_ascii_whitespace())
101    }
102}
103
104/// An iterator that lazily parses simulation frames from a `.con` or `.convel`
105/// file's contents.
106///
107/// This struct wraps a memchr line cursor over the file buffer and, upon each
108/// iteration, attempts to parse a complete `ConFrame`. Velocity sections are
109/// detected automatically: if a blank line follows the coordinate blocks, the
110/// velocity data is parsed into the atoms.
111///
112/// The iterator yields items of type `Result<ConFrame, ParseError>`, allowing for
113/// robust error handling for each frame.
114pub struct ConFrameIterator<'a> {
115    pub(crate) lines: MemchrLines<'a>,
116}
117
118impl<'a> ConFrameIterator<'a> {
119    /// Creates a new `ConFrameIterator` from a string slice of the entire file.
120    ///
121    /// # Arguments
122    ///
123    /// * `file_contents` - A string slice containing the text of one or more `.con` frames.
124    pub fn new(file_contents: &'a str) -> Self {
125        ConFrameIterator {
126            lines: MemchrLines::new(file_contents),
127        }
128    }
129
130    /// Bulk-skips `n` lines from the shared memchr cursor.
131    fn advance_lines(&mut self, n: usize) -> Result<(), error::ParseError> {
132        self.lines.clear_peek();
133        for _ in 0..n {
134            let rest = &self.lines.bytes[self.lines.pos..];
135            match memchr::memchr(b'\n', rest) {
136                Some(pos) => self.lines.pos += pos + 1,
137                None => {
138                    if rest.is_empty() {
139                        return Err(error::ParseError::IncompleteFrame);
140                    }
141                    self.lines.pos = self.lines.bytes.len();
142                    return Err(error::ParseError::IncompleteFrame);
143                }
144            }
145        }
146        Ok(())
147    }
148
149    /// One line from the shared cursor (same as full-parse path).
150    fn read_line_str(&mut self) -> Option<&'a str> {
151        self.lines.clear_peek();
152        self.lines.next_line()
153    }
154
155    /// memchr-backed equivalent of [`Self::forward`]. Skips the next
156    /// frame without fully parsing its atom data. Shares the same line
157    /// cursor as [`Iterator::next`], so skip and full parse interleave safely.
158    pub fn forward_fast(&mut self) -> Option<Result<(), error::ParseError>> {
159        self.lines.clear_peek();
160        if self.lines.pos >= self.lines.bytes.len() {
161            return None;
162        }
163        // Lines 1..=6 of the header are skipped wholesale.
164        if let Err(e) = self.advance_lines(6) {
165            return Some(Err(e));
166        }
167        // Line 7: natm_types.
168        let natm_types: usize = match self.read_line_str() {
169            Some(line) => match crate::parser::parse_count_line(line, 1) {
170                Ok(v) => v[0],
171                Err(e) => return Some(Err(e)),
172            },
173            None => return Some(Err(error::ParseError::IncompleteHeader)),
174        };
175        if natm_types == 0 {
176            // Lines 8 and 9 are whitespace, or absent at EOF. Do not scan
177            // for coordinates or a legacy velocity block (CON-count.1).
178            return Some(crate::parser::read_zero_atom_tail(|| self.read_line_str()));
179        }
180        // Line 8: natms_per_type.
181        let natms_per_type: Vec<usize> = match self.read_line_str() {
182            Some(line) => match crate::parser::parse_count_line(line, natm_types) {
183                Ok(v) => v,
184                Err(e) => return Some(Err(e)),
185            },
186            None => return Some(Err(error::ParseError::IncompleteHeader)),
187        };
188        if natms_per_type.iter().any(|n| *n == 0) {
189            return Some(Err(error::ParseError::InvalidCount(
190                "each per-type count must be >= 1".into(),
191            )));
192        }
193        // Line 9: masses_per_type, consumed.
194        if let Err(e) = self.advance_lines(1) {
195            return Some(Err(e));
196        }
197        let total_atoms: usize = natms_per_type.iter().sum();
198        let coord_block_lines = total_atoms + natm_types * 2;
199        if let Err(e) = self.advance_lines(coord_block_lines) {
200            return Some(Err(e));
201        }
202        // Optional sections: blank line + same-shape block, repeated.
203        self.lines.clear_peek();
204        loop {
205            let rest = &self.lines.bytes[self.lines.pos..];
206            if rest.is_empty() {
207                break;
208            }
209            let next_eol = memchr::memchr(b'\n', rest);
210            let line = match next_eol {
211                Some(pos) => &rest[..pos],
212                None => rest,
213            };
214            let is_blank = line.iter().all(|b| matches!(b, b' ' | b'\t' | b'\r'));
215            if !is_blank {
216                break;
217            }
218            let after = match next_eol {
219                Some(pos) => &rest[pos + 1..],
220                None => &[][..],
221            };
222            // A blank line and then only whitespace is the end of the file,
223            // not a section (CON-frame.5).
224            if after.iter().all(|b| b.is_ascii_whitespace()) {
225                self.lines.pos = self.lines.bytes.len();
226                break;
227            }
228            // Consume the blank separator and the section block.
229            self.lines.pos += next_eol.map(|p| p + 1).unwrap_or(rest.len());
230            if let Err(e) = self.advance_lines(coord_block_lines) {
231                return Some(Err(e));
232            }
233        }
234        Some(Ok(()))
235    }
236
237    /// Skips the next frame without fully parsing its atomic data.
238    ///
239    /// This is more efficient than `next()` if you only need to advance the
240    /// iterator. It reads the frame's header to determine how many lines to skip,
241    /// including any velocity section if present.
242    ///
243    /// # Returns
244    ///
245    /// * `Some(Ok(()))` on a successful skip.
246    /// * `Some(Err(ParseError::...))` if there's an error parsing the header.
247    /// * `None` if the iterator is already at the end.
248    pub fn forward(&mut self) -> Option<Result<(), error::ParseError>> {
249        // Prefer the shared memchr skip path (same cursor as full parse).
250        self.forward_fast()
251    }
252
253    /// Byte offset of the next frame start in the buffer passed to [`Self::new`].
254    pub fn byte_pos(&self) -> usize {
255        self.lines.pos
256    }
257
258    /// Skip `n` frames without parsing atom data. Returns how many were skipped.
259    /// Named `skip_frames` so it does not collide with [`Iterator::skip`].
260    pub fn skip_frames(&mut self, n: usize) -> Result<usize, error::ParseError> {
261        let mut skipped = 0usize;
262        for _ in 0..n {
263            match self.forward() {
264                None => break,
265                Some(Err(e)) => return Err(e),
266                Some(Ok(())) => skipped += 1,
267            }
268        }
269        Ok(skipped)
270    }
271
272    /// Next frame plus the exact substring of the buffer passed to [`Self::new`].
273    ///
274    /// **Corpus ingest contract:** successive successful spans from the same
275    /// `file_contents` are contiguous (`end` of frame *i* equals `start` of frame
276    /// *i+1*) and, for a buffer that is only multi-frame CON (no prefix garbage),
277    /// concatenating all spans reproduces the trajectory text. Campaign stores
278    /// (`readcon-db`) must persist these spans as authoritative blobs—do not
279    /// re-serialize on the hot ingest path unless the caller supplied in-memory
280    /// [`types::ConFrame`] values without source text.
281    ///
282    /// See also [`crate::index_proj::frame_byte_spans`] and
283    /// [`crate::index_proj::spans_cover_buffer`].
284    pub fn next_with_raw_span(
285        &mut self,
286        file_contents: &'a str,
287    ) -> Option<Result<(types::ConFrame, &'a str), error::ParseError>> {
288        let base = file_contents.as_ptr() as usize;
289        let start = {
290            let line = self.lines.peek_line()?;
291            line.as_ptr() as usize - base
292        };
293        let frame = match self.next()? {
294            Ok(f) => f,
295            Err(e) => return Some(Err(e)),
296        };
297        let end = match self.lines.peek_line() {
298            Some(line) => line.as_ptr() as usize - base,
299            None => file_contents.len(),
300        };
301        debug_assert!(end >= start && end <= file_contents.len());
302        Some(Ok((frame, &file_contents[start..end])))
303    }
304}
305
306impl<'a> Iterator for ConFrameIterator<'a> {
307    /// The type of item yielded by the iterator.
308    ///
309    /// Each item is a `Result` that contains a successfully parsed `ConFrame` or a
310    /// `ParseError` if the frame's data is malformed.
311    type Item = Result<types::ConFrame, error::ParseError>;
312
313    /// Advances the iterator and attempts to parse the next frame.
314    ///
315    /// This method will return `None` only when there are no more lines to consume.
316    /// If there are lines but they do not form a complete frame, it will return
317    /// `Some(Err(ParseError::...))`.
318    fn next(&mut self) -> Option<Self::Item> {
319        // Trailing whitespace after the last complete frame is not a frame
320        // (CON-frame.1, D2). An empty line that is followed by more text is
321        // line 1 of the next frame (CON-frame.7).
322        if self.lines.remainder_is_whitespace() {
323            while self.lines.next_line().is_some() {}
324            return None;
325        }
326        // Otherwise, attempt to parse the next frame from the available lines.
327        let mut frame = match parse_single_frame(&mut self.lines) {
328            Ok(f) => f,
329            Err(e) => return Some(Err(e)),
330        };
331        // Optional sections mutate AoS; only re-sync section SoA when needed.
332        // Plain .con assembly already filled positions/ids/masses (no O(N)
333        // post-scan when no velocity/force sections were applied).
334        let sections =
335            match parse_declared_sections(&mut self.lines, &mut frame.header, &mut frame.atom_data)
336            {
337                Ok(n) => n,
338                Err(e) => return Some(Err(e)),
339            };
340        if sections > 0 {
341            frame.sync_arrays_from_atom_data();
342        }
343        Some(Ok(frame))
344    }
345}
346
347#[cfg(test)]
348mod aos_soa_agreement_tests {
349    use super::*;
350    use std::path::PathBuf;
351
352    #[test]
353    fn iterator_vel_forces_soa_matches_aos() {
354        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
355            .join("resources/test/tiny_cuh2_vel_forces.con");
356        let text = std::fs::read_to_string(&p).expect("fixture");
357        let fr = ConFrameIterator::new(&text)
358            .next()
359            .expect("frame")
360            .expect("parse");
361        let n = fr.atom_data.len();
362        assert!(n > 0);
363        assert_eq!(fr.positions.nrows(), n);
364        let has_vel = fr.atom_data.iter().any(|a| a.velocity.is_some());
365        let has_frc = fr.atom_data.iter().any(|a| a.force.is_some());
366        if has_vel {
367            assert_eq!(
368                fr.velocities.nrows(),
369                n,
370                "SoA velocities must match AoS after section parse"
371            );
372        }
373        if has_frc {
374            assert_eq!(fr.forces.nrows(), n, "SoA forces must match AoS");
375        }
376        for (i, a) in fr.atom_data.iter().enumerate() {
377            let p = fr.positions.as_f64_row(i);
378            assert_eq!([a.x, a.y, a.z], p);
379            if let Some(v) = a.velocity {
380                assert_eq!(v, fr.velocities.as_f64_row(i));
381            }
382            if let Some(f) = a.force {
383                assert_eq!(f, fr.forces.as_f64_row(i));
384            }
385        }
386    }
387
388    /// After SoA-primary parse, section sync must not require rewriting positions
389    /// (nrows already equals N); forces SoA still filled from AoS.
390    #[test]
391    fn sync_skips_pos_when_nrows_matches_keeps_force_soa() {
392        let p =
393            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2_forces.con");
394        let text = std::fs::read_to_string(&p).expect("fixture");
395        let fr = ConFrameIterator::new(&text)
396            .next()
397            .expect("frame")
398            .expect("parse");
399        let n = fr.atom_data.len();
400        assert_eq!(fr.positions.nrows(), n);
401        assert_eq!(fr.forces.nrows(), n);
402        // Snapshot first position SoA row then re-sync; coords must stay bit-identical
403        // (no needless rewrite would change nothing but we still require agreement).
404        let p0 = fr.positions.as_f64_row(0);
405        let mut fr2 = fr.clone();
406        fr2.sync_arrays_from_atom_data();
407        assert_eq!(fr2.positions.as_f64_row(0), p0);
408        assert_eq!(fr2.forces.nrows(), n);
409        assert_eq!(
410            fr2.forces.as_f64_row(0),
411            fr2.atom_data[0].force.expect("force")
412        );
413    }
414}
415
416/// Reads all frames from a file.
417///
418/// For files smaller than 64 KiB, uses a simple `read_to_string` to avoid
419/// the fixed overhead of mmap (VMA creation, page fault, munmap). For larger
420/// trajectory files, uses memory-mapped I/O to let the OS page cache handle
421/// the data.
422/// Byte-size gate for Rayon multi-frame parse. Avoids an extra O(n) frame-count
423/// scan: phase-1 of [`parse_frames_parallel`] already walks boundaries when we
424/// choose parallel. Below this size, sequential parse wins on small multi-frame
425/// files (pool scheduling overhead).
426#[cfg(feature = "parallel")]
427pub(crate) const PARALLEL_BYTES_THRESHOLD: usize = 48 * 1024;
428
429pub fn read_all_frames(path: &Path) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
430    read_all_frames_with_threads(path, None)
431}
432
433/// Collect every frame from `text`.
434///
435/// `num_threads` is `None` for the automatic policy (Rayon when the
436/// `parallel` feature is on and the buffer is at least
437/// [`PARALLEL_BYTES_THRESHOLD`]), `Some(1)` for a sequential iterator,
438/// and `Some(n)` with `n >= 2` for an explicit Rayon pool. Builds
439/// without `parallel` always parse sequentially.
440pub fn frames_from_text(
441    text: &str,
442    num_threads: Option<usize>,
443) -> Result<Vec<types::ConFrame>, error::ParseError> {
444    #[cfg(feature = "parallel")]
445    {
446        let use_parallel = match num_threads {
447            Some(1) => false,
448            Some(n) if n >= 2 => true,
449            // None and Some(0): automatic policy.
450            _ => text.len() >= PARALLEL_BYTES_THRESHOLD,
451        };
452        if use_parallel {
453            let pool = num_threads.filter(|&n| n >= 2);
454            let parts = parse_frames_parallel_with_threads(text, pool);
455            let mut frames = Vec::with_capacity(parts.len());
456            for r in parts {
457                frames.push(r?);
458            }
459            return Ok(frames);
460        }
461    }
462    #[cfg(not(feature = "parallel"))]
463    {
464        let _ = num_threads;
465    }
466    ConFrameIterator::new(text).collect()
467}
468
469/// Like [`read_all_frames`], with an explicit worker count.
470///
471/// `None` is the automatic policy used by [`read_all_frames`]. `Some(1)`
472/// forces sequential parse. `Some(n)` with `n >= 2` pins a Rayon pool
473/// (ignored when the `parallel` feature is off).
474pub fn read_all_frames_with_threads(
475    path: &Path,
476    num_threads: Option<usize>,
477) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
478    let contents = crate::compression::read_file_contents(path)?;
479    let text = contents.as_str()?;
480    Ok(frames_from_text(text, num_threads)?)
481}
482
483/// Count frames without building atom payloads (uses [`ConFrameIterator::forward_fast`]
484/// when possible, else [`ConFrameIterator::forward`]).
485///
486/// Prefer this over `read_all_frames(...).len()` when only the frame count is needed.
487pub fn count_frames(path: &Path) -> Result<usize, Box<dyn std::error::Error>> {
488    let contents = crate::compression::read_file_contents(path)?;
489    let text = contents.as_str()?;
490    let mut n = 0usize;
491    let mut iter = ConFrameIterator::new(text);
492    loop {
493        match iter.forward_fast() {
494            Some(Ok(())) => n += 1,
495            Some(Err(e)) => return Err(Box::new(e)),
496            None => break,
497        }
498    }
499    Ok(n)
500}
501
502/// Reads only the first frame from a file.
503///
504/// More efficient than `read_all_frames` for single-frame access because it
505/// stops parsing after the first frame rather than collecting all of them.
506pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
507    let contents = crate::compression::read_file_contents(path)?;
508    let text = contents.as_str()?;
509    let mut iter = ConFrameIterator::new(text);
510    match iter.next() {
511        Some(Ok(frame)) => Ok(frame),
512        Some(Err(e)) => Err(Box::new(e)),
513        None => Err("No frames found in file".into()),
514    }
515}
516
517/// Skip `index` frames (no atom parse), then parse the next one.
518///
519/// `index == 0` is [`read_first_frame`]. Out of range is an error.
520pub fn read_nth_frame(
521    path: &Path,
522    index: usize,
523) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
524    let contents = crate::compression::read_file_contents(path)?;
525    let text = contents.as_str()?;
526    read_nth_frame_from_text(text, index)
527}
528
529/// Same as [`read_nth_frame`] on an already-loaded buffer.
530pub fn read_nth_frame_from_text(
531    text: &str,
532    index: usize,
533) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
534    let mut iter = ConFrameIterator::new(text);
535    let skipped = iter.skip_frames(index)?;
536    if skipped < index {
537        return Err(format!("frame {index} out of range (only {skipped} frames)").into());
538    }
539    match iter.next() {
540        Some(Ok(frame)) => Ok(frame),
541        Some(Err(e)) => Err(Box::new(e)),
542        None => Err(format!("frame {index} out of range").into()),
543    }
544}
545
546/// Frame start byte offsets via [`ConFrameIterator::forward_fast`] (no atom parse).
547/// Stops at the first skip error (that start is still included).
548pub fn frame_start_offsets(file_contents: &str) -> Vec<usize> {
549    let mut boundaries = Vec::new();
550    let mut scanner = ConFrameIterator::new(file_contents);
551    loop {
552        scanner.lines.clear_peek();
553        let start = scanner.lines.pos;
554        if start >= scanner.lines.bytes.len() {
555            break;
556        }
557        boundaries.push(start);
558        match scanner.forward_fast() {
559            Some(Ok(())) => {}
560            Some(Err(_)) | None => break,
561        }
562    }
563    boundaries
564}
565
566/// Parses frames in parallel using rayon, splitting on frame boundaries.
567///
568/// Phase 1: sequential O(N) scan via memchr-backed
569/// [`ConFrameIterator::forward_fast`] to find byte offsets of every
570/// frame's start. The previous implementation built a `Vec<&str>` of
571/// every line and called `lines[..i].iter().map(|l| l.len() +
572/// 1).sum()` on every frame, which is O(N^2) in line count and
573/// dominated runtime on multi-frame trajectories.
574///
575/// Phase 2: parallel parse of each frame slice using rayon on the
576/// **global** Rayon pool (see also [`parse_frames_parallel_with_threads`]
577/// for strong-scaling control of the worker count).
578///
579/// Requires the `parallel` feature.
580#[cfg(feature = "parallel")]
581pub fn parse_frames_parallel(
582    file_contents: &str,
583) -> Vec<Result<types::ConFrame, error::ParseError>> {
584    parse_frames_parallel_with_threads(file_contents, None)
585}
586
587/// Like [`parse_frames_parallel`], but runs phase-2 on an explicit Rayon
588/// pool with `num_threads` workers when `Some(n)` (`n` is clamped to at
589/// least 1). `None` uses the global pool (same as [`parse_frames_parallel`]).
590///
591/// Strong-scaling tests pin worker counts without racing the global pool.
592/// Results are ordered by frame index (stable vs sequential iterator order).
593///
594/// Requires the `parallel` feature.
595#[cfg(feature = "parallel")]
596pub fn parse_frames_parallel_with_threads(
597    file_contents: &str,
598    num_threads: Option<usize>,
599) -> Vec<Result<types::ConFrame, error::ParseError>> {
600    use rayon::prelude::*;
601
602    // Phase 1: skip walk (no atom parse) for frame start offsets.
603    let boundaries = frame_start_offsets(file_contents);
604
605    let parse_chunks = || {
606        let num_frames = boundaries.len();
607        (0..num_frames)
608            .into_par_iter()
609            .map(|i| {
610                let start = boundaries[i];
611                let end = if i + 1 < num_frames {
612                    boundaries[i + 1]
613                } else {
614                    file_contents.len()
615                };
616                let chunk = &file_contents[start..end];
617                let mut iter = ConFrameIterator::new(chunk);
618                match iter.next() {
619                    Some(result) => result,
620                    None => Err(error::ParseError::IncompleteFrame),
621                }
622            })
623            .collect()
624    };
625
626    match num_threads {
627        None => parse_chunks(),
628        Some(n) => {
629            let n = n.max(1);
630            match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
631                Ok(pool) => pool.install(parse_chunks),
632                Err(_) => parse_chunks(),
633            }
634        }
635    }
636}
637
638#[cfg(all(test, feature = "parallel"))]
639mod parallel_strong_scale_tests {
640    use super::*;
641    use std::path::PathBuf;
642
643    fn multi_frame_fixture() -> String {
644        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con");
645        let one = std::fs::read_to_string(p).expect("fixture");
646        // Enough frames for >1 worker to exercise the pool.
647        one.repeat(8)
648    }
649
650    fn sequential_frames(text: &str) -> Vec<types::ConFrame> {
651        ConFrameIterator::new(text)
652            .map(|r| r.expect("seq frame"))
653            .collect()
654    }
655
656    fn frames_payload_key(f: &types::ConFrame) -> (usize, Vec<(String, f64, f64, f64)>) {
657        let atoms: Vec<_> = f
658            .atom_data
659            .iter()
660            .map(|a| (a.symbol.to_string(), a.x, a.y, a.z))
661            .collect();
662        (f.atom_data.len(), atoms)
663    }
664
665    #[test]
666    fn parallel_workers_match_sequential_payloads() {
667        let text = multi_frame_fixture();
668        let seq = sequential_frames(&text);
669        assert!(seq.len() >= 8);
670        let seq_keys: Vec<_> = seq.iter().map(frames_payload_key).collect();
671
672        for workers in [1usize, 2, 4] {
673            let par = parse_frames_parallel_with_threads(&text, Some(workers));
674            assert_eq!(par.len(), seq.len(), "workers={workers}");
675            let par_keys: Vec<_> = par
676                .into_iter()
677                .map(|r| frames_payload_key(&r.expect("par frame")))
678                .collect();
679            assert_eq!(par_keys, seq_keys, "workers={workers} frame payloads");
680        }
681
682        // Global pool path agrees too.
683        let par_default = parse_frames_parallel(&text);
684        let def_keys: Vec<_> = par_default
685            .into_iter()
686            .map(|r| frames_payload_key(&r.expect("par")))
687            .collect();
688        assert_eq!(def_keys, seq_keys);
689    }
690}