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    /// Absolute offsets of every `\n`. Set only for a frame-start scan.
24    /// `None` keeps the per-line `memchr` path used by a full parse.
25    newlines: Option<Vec<usize>>,
26    /// Index of the line that starts at `pos` when `newlines` is in use.
27    line_no: usize,
28}
29
30impl<'a> MemchrLines<'a> {
31    pub fn new(text: &'a str) -> Self {
32        Self::from_parts(text.as_bytes(), false)
33    }
34
35    /// Cursor over bytes that are not yet known to be UTF-8.
36    ///
37    /// ASCII coordinate rows are accepted by the numeric scan. Text lines
38    /// are validated when they are materialized, so there is no separate
39    /// whole-buffer UTF-8 pass.
40    pub fn from_bytes(bytes: &'a [u8]) -> Self {
41        Self::from_parts(bytes, true)
42    }
43
44    fn from_parts(bytes: &'a [u8], check_utf8: bool) -> Self {
45        Self {
46            bytes,
47            pos: 0,
48            peeked: None,
49            check_utf8,
50            utf8_bad: false,
51            newlines: None,
52            line_no: 0,
53        }
54    }
55
56    #[inline]
57    fn read_one(&mut self) -> Option<&'a str> {
58        if self.newlines.is_some() {
59            return self.read_one_indexed();
60        }
61        if self.utf8_bad || self.pos >= self.bytes.len() {
62            return None;
63        }
64        let rest = &self.bytes[self.pos..];
65        let (line_bytes, advance) = match memchr::memchr(b'\n', rest) {
66            Some(i) => (&rest[..i], i + 1),
67            None => (rest, rest.len()),
68        };
69        let trimmed = if line_bytes.last() == Some(&b'\r') {
70            &line_bytes[..line_bytes.len() - 1]
71        } else {
72            line_bytes
73        };
74        if self.check_utf8 {
75            match std::str::from_utf8(trimmed) {
76                Ok(s) => {
77                    self.pos += advance;
78                    Some(s)
79                }
80                Err(_) => {
81                    // Stay on the rejected line. Moving past it makes a
82                    // whitespace tail look like a clean end of file.
83                    self.utf8_bad = true;
84                    None
85                }
86            }
87        } else {
88            self.pos += advance;
89            // SAFETY: source was `&str`; line is a UTF-8 prefix cut on ASCII `\n`/`\r`.
90            Some(unsafe { std::str::from_utf8_unchecked(trimmed) })
91        }
92    }
93
94    #[inline]
95    pub fn next_line(&mut self) -> Option<&'a str> {
96        if let Some(p) = self.peeked.take() {
97            return Some(p);
98        }
99        self.read_one()
100    }
101
102    #[inline]
103    pub fn peek_line(&mut self) -> Option<&'a str> {
104        if self.peeked.is_none() {
105            self.peeked = self.read_one();
106        }
107        self.peeked
108    }
109
110    /// Drop any peek buffer (required before bulk cursor advances).
111    fn clear_peek(&mut self) {
112        if let Some(p) = self.peeked.take() {
113            // Rewind pos to the start of the peeked line.
114            let start = p.as_ptr() as usize - self.bytes.as_ptr() as usize;
115            self.pos = start;
116            if let Some(nl) = self.newlines.as_deref() {
117                self.line_no = line_no_for_pos(nl, self.bytes.len(), start);
118            }
119        }
120    }
121
122    /// One line from the precomputed newline index.
123    ///
124    /// The cursor must sit on a line start (or at the end). The bytes of
125    /// the line are the same slice `memchr` would return, including a line
126    /// whose body crosses a chunk boundary: only the `\n` is indexed.
127    fn read_one_indexed(&mut self) -> Option<&'a str> {
128        if self.utf8_bad || self.pos >= self.bytes.len() {
129            return None;
130        }
131        let len = self.bytes.len();
132        let line_no = {
133            let nl = self.newlines.as_deref().unwrap();
134            if self.pos == line_start_pos(nl, len, self.line_no) {
135                self.line_no
136            } else {
137                line_no_for_pos(nl, len, self.pos)
138            }
139        };
140        let Some(span) = indexed_span(self.newlines.as_deref().unwrap(), self.bytes, line_no)
141        else {
142            return None;
143        };
144        let trimmed = &self.bytes[span.start..span.content_end];
145        if self.check_utf8 {
146            match std::str::from_utf8(trimmed) {
147                Ok(s) => {
148                    self.pos = span.next_pos;
149                    self.line_no = line_no + 1;
150                    Some(s)
151                }
152                Err(_) => {
153                    self.utf8_bad = true;
154                    self.line_no = line_no;
155                    None
156                }
157            }
158        } else {
159            self.pos = span.next_pos;
160            self.line_no = line_no + 1;
161            // SAFETY: source was `&str`; the span is a UTF-8 line cut on ASCII `\n`/`\r`.
162            Some(unsafe { std::str::from_utf8_unchecked(trimmed) })
163        }
164    }
165
166    fn utf8_error(&self) -> Option<error::ParseError> {
167        self.utf8_bad.then(invalid_utf8)
168    }
169
170    /// Keep `err` unless this cursor already rejected a line as UTF-8.
171    fn prefer_utf8(&self, err: error::ParseError) -> error::ParseError {
172        if self.utf8_bad { invalid_utf8() } else { err }
173    }
174}
175
176fn invalid_utf8() -> error::ParseError {
177    error::ParseError::ValidationError("invalid UTF-8".into())
178}
179
180impl<'a> Iterator for MemchrLines<'a> {
181    type Item = &'a str;
182    fn next(&mut self) -> Option<&'a str> {
183        self.next_line()
184    }
185}
186
187impl<'a> LineStream<'a> for MemchrLines<'a> {
188    #[inline]
189    fn next_line(&mut self) -> Option<&'a str> {
190        MemchrLines::next_line(self)
191    }
192    #[inline]
193    fn peek_line(&mut self) -> Option<&'a str> {
194        MemchrLines::peek_line(self)
195    }
196    #[inline]
197    fn next_coord_row(
198        &mut self,
199        spec_version: u32,
200        default_id: u64,
201    ) -> Result<([f64; 3], [bool; 3], u64), crate::error::ParseError> {
202        if let Some(p) = self.peeked.take() {
203            return crate::parser::parse_coord_row_at(p.as_bytes(), 0, spec_version, default_id)
204                .map(|(row, _)| row);
205        }
206        let (row, pos) =
207            crate::parser::parse_coord_row_at(self.bytes, self.pos, spec_version, default_id)?;
208        self.pos = pos;
209        Ok(row)
210    }
211    fn remainder_is_whitespace(&mut self) -> bool {
212        if self.utf8_bad {
213            return false;
214        }
215        if let Some(line) = self.peek_line()
216            && !line.trim().is_empty()
217        {
218            return false;
219        }
220        // `peek_line` may have rejected a line. That is a decode error,
221        // including when every byte after it is ASCII whitespace.
222        if self.utf8_bad {
223            return false;
224        }
225        self.bytes[self.pos..]
226            .iter()
227            .all(|b| b.is_ascii_whitespace())
228    }
229    fn blank_line_opens_section(&mut self) -> bool {
230        // `peek_line` has already moved `pos` to the line after the peek.
231        let Some(peeked) = self.peeked else {
232            return false;
233        };
234        if !peeked.trim().is_empty() {
235            return false;
236        }
237        match probe_section(&self.bytes[self.pos..]) {
238            SectionProbe::Opens => true,
239            SectionProbe::Closed => false,
240            // Leave the rejected bytes on the cursor. `prefer_utf8` then
241            // reports the decode error instead of a missing section.
242            SectionProbe::InvalidUtf8 => {
243                self.utf8_bad = true;
244                false
245            }
246        }
247    }
248
249    fn blank_opens_velocity_block(&mut self) -> bool {
250        match self.peek_line() {
251            Some(line) if line.trim().is_empty() => self.blank_line_opens_section(),
252            _ => true,
253        }
254    }
255}
256
257fn split_raw_line(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
258    if bytes.is_empty() {
259        return None;
260    }
261    match memchr::memchr(b'\n', bytes) {
262        Some(i) => Some((&bytes[..i], &bytes[i + 1..])),
263        None => Some((bytes, &[])),
264    }
265}
266
267fn strip_cr(line: &[u8]) -> &[u8] {
268    if line.last() == Some(&b'\r') {
269        &line[..line.len() - 1]
270    } else {
271        line
272    }
273}
274
275enum SectionProbe {
276    Opens,
277    Closed,
278    InvalidUtf8,
279}
280
281/// A blank line opens a section when the next line is a symbol and the
282/// line after that is a component label (CON-file.5, CON-header.4).
283fn probe_section(after_blank: &[u8]) -> SectionProbe {
284    let Some((raw1, after1)) = split_raw_line(after_blank) else {
285        return SectionProbe::Closed;
286    };
287    let line1 = strip_cr(raw1);
288    if line1.iter().all(|b| matches!(b, b' ' | b'\t')) {
289        return SectionProbe::Closed;
290    }
291    let Ok(text1) = std::str::from_utf8(line1) else {
292        return SectionProbe::InvalidUtf8;
293    };
294    if text1.trim_start().starts_with('{') {
295        return SectionProbe::Closed;
296    }
297    let Some((raw2, _)) = split_raw_line(after1) else {
298        return SectionProbe::Closed;
299    };
300    let Ok(text2) = std::str::from_utf8(strip_cr(raw2)) else {
301        return SectionProbe::InvalidUtf8;
302    };
303    if crate::parser::line_has_component_label(text2) {
304        SectionProbe::Opens
305    } else {
306        SectionProbe::Closed
307    }
308}
309
310fn blank_opens_section(after_blank: &[u8]) -> bool {
311    matches!(probe_section(after_blank), SectionProbe::Opens)
312}
313
314/// An iterator that lazily parses simulation frames from a `.con` or `.convel`
315/// file's contents.
316///
317/// This struct wraps a memchr line cursor over the file buffer and, upon each
318/// iteration, attempts to parse a complete `ConFrame`. A blank line after the
319/// coordinates is a velocity section only when it begins a complete velocity
320/// block. Otherwise that line is line 1 of the next frame, and it may be empty.
321///
322/// The iterator yields items of type `Result<ConFrame, ParseError>`, allowing for
323/// robust error handling for each frame.
324pub struct ConFrameIterator<'a> {
325    pub(crate) lines: MemchrLines<'a>,
326}
327
328impl<'a> ConFrameIterator<'a> {
329    /// Creates a new `ConFrameIterator` from a string slice of the entire file.
330    ///
331    /// # Arguments
332    ///
333    /// * `file_contents` - A string slice containing the text of one or more `.con` frames.
334    pub fn new(file_contents: &'a str) -> Self {
335        ConFrameIterator {
336            lines: MemchrLines::new(file_contents),
337        }
338    }
339
340    /// Like [`Self::new`], but reading from byte `pos` of `file_contents`.
341    ///
342    /// `pos` must be 0 or follow a `\n`. The iterator keeps no state from
343    /// earlier frames, so skipping from `pos` sees what a scan sees there.
344    pub(crate) fn starting_at(file_contents: &'a str, pos: usize) -> Self {
345        debug_assert!(pos == 0 || file_contents.as_bytes()[pos - 1] == b'\n');
346        let mut it = Self::new(file_contents);
347        it.lines.pos = pos;
348        it
349    }
350
351    /// Like [`Self::new`] for a buffer that has not been UTF-8 validated.
352    ///
353    /// Text lines are checked as they are tokenized. Numeric rows reject a
354    /// non-ASCII byte during the fused parse, which is the UTF-8 check for
355    /// those lines.
356    pub fn from_bytes(bytes: &'a [u8]) -> Self {
357        ConFrameIterator {
358            lines: MemchrLines::from_bytes(bytes),
359        }
360    }
361
362    /// Bulk-skips `n` lines from the shared memchr cursor.
363    ///
364    /// A final line with no trailing newline still counts when it is the
365    /// last line this call needs and its bytes are present. The full
366    /// parser accepts that line. Further lines after it are missing.
367    fn advance_lines(&mut self, n: usize) -> Result<(), error::ParseError> {
368        self.lines.clear_peek();
369        if self.lines.newlines.is_some() {
370            return self.advance_lines_indexed(n);
371        }
372        for i in 0..n {
373            let rest = &self.lines.bytes[self.lines.pos..];
374            match memchr::memchr(b'\n', rest) {
375                Some(pos) => self.lines.pos += pos + 1,
376                None => {
377                    if rest.is_empty() {
378                        return Err(error::ParseError::IncompleteFrame);
379                    }
380                    self.lines.pos = self.lines.bytes.len();
381                    if i + 1 == n {
382                        return Ok(());
383                    }
384                    return Err(error::ParseError::IncompleteFrame);
385                }
386            }
387        }
388        Ok(())
389    }
390
391    /// [`Self::advance_lines`] for `n` lines expected to end at `end`.
392    ///
393    /// When `end` follows a `\n` and exactly `n` newlines lie before it,
394    /// the per-line search would stop at `end` too, so one vectorized
395    /// count replaces it. Any other case takes the per-line search.
396    fn advance_lines_to(&mut self, n: usize, end: Option<usize>) -> Result<(), error::ParseError> {
397        // The newline index jumps by line count. `end` does not change
398        // where that jump lands; it only shortcuts the per-line search.
399        if self.lines.newlines.is_some() {
400            return self.advance_lines(n);
401        }
402        self.lines.clear_peek();
403        let pos = self.lines.pos;
404        let bytes = self.lines.bytes;
405        if let Some(end) = end
406            && pos < end
407            && end <= bytes.len()
408            && bytes[end - 1] == b'\n'
409            && memchr::memchr_iter(b'\n', &bytes[pos..end]).count() == n
410        {
411            self.lines.pos = end;
412            return Ok(());
413        }
414        self.advance_lines(n)
415    }
416
417    /// [`Self::advance_lines`] using the stitched newline index.
418    ///
419    /// A missing line and a final line with no `\n` land on the same byte
420    /// as the `memchr` loop. The index is not consulted for a full parse.
421    fn advance_lines_indexed(&mut self, n: usize) -> Result<(), error::ParseError> {
422        let len = self.lines.bytes.len();
423        let step = {
424            let nl = self.lines.newlines.as_deref().unwrap();
425            let nlines = line_count(nl, len);
426            let line_no = if self.lines.pos == line_start_pos(nl, len, self.lines.line_no) {
427                self.lines.line_no
428            } else {
429                line_no_for_pos(nl, len, self.lines.pos)
430            };
431            if n == 0 {
432                IndexStep::Stay(line_no)
433            } else if line_no >= nlines {
434                IndexStep::Missing(line_no)
435            } else {
436                match line_no.checked_add(n) {
437                    Some(target) if target <= nlines => {
438                        let pos = if target == nlines {
439                            len
440                        } else {
441                            line_start_pos(nl, len, target)
442                        };
443                        IndexStep::Landed(target, pos)
444                    }
445                    _ => IndexStep::Short(nlines, len),
446                }
447            }
448        };
449        match step {
450            IndexStep::Stay(line_no) => {
451                self.lines.line_no = line_no;
452                Ok(())
453            }
454            IndexStep::Missing(line_no) => {
455                self.lines.line_no = line_no;
456                Err(error::ParseError::IncompleteFrame)
457            }
458            IndexStep::Landed(line_no, pos) => {
459                self.lines.line_no = line_no;
460                self.lines.pos = pos;
461                Ok(())
462            }
463            IndexStep::Short(line_no, pos) => {
464                self.lines.line_no = line_no;
465                self.lines.pos = pos;
466                Err(error::ParseError::IncompleteFrame)
467            }
468        }
469    }
470
471    /// One line from the shared cursor (same as full-parse path).
472    fn read_line_str(&mut self) -> Option<&'a str> {
473        self.lines.clear_peek();
474        self.lines.next_line()
475    }
476
477    /// memchr-backed equivalent of [`Self::forward`]. Skips the next
478    /// frame without fully parsing its atom data. Shares the same line
479    /// cursor as [`Iterator::next`], so skip and full parse interleave safely.
480    pub fn forward_fast(&mut self) -> Option<Result<(), error::ParseError>> {
481        self.forward_fast_to(None)
482    }
483
484    /// [`Self::forward_fast`] for a frame expected to end at byte `end`.
485    ///
486    /// The result and the new position are the same as without `end`.
487    /// Line blocks that end exactly at `end` are counted, not searched.
488    pub(crate) fn forward_fast_to(
489        &mut self,
490        end: Option<usize>,
491    ) -> Option<Result<(), error::ParseError>> {
492        self.lines.clear_peek();
493        if self.lines.pos >= self.lines.bytes.len() {
494            return None;
495        }
496        // Lines 1..=6 of the header are skipped wholesale.
497        if let Err(e) = self.advance_lines(6) {
498            return Some(Err(e));
499        }
500        // Line 7: natm_types.
501        let natm_types: usize = match self.read_line_str() {
502            Some(line) => match crate::parser::parse_count_line(line, 1) {
503                Ok(v) => v[0],
504                Err(e) => return Some(Err(e)),
505            },
506            None => return Some(Err(error::ParseError::IncompleteHeader)),
507        };
508        if natm_types == 0 {
509            // Lines 8 and 9 are whitespace, or absent at EOF. Do not scan
510            // for coordinates or a legacy velocity block (CON-count.1).
511            return Some(crate::parser::read_zero_atom_tail(|| self.read_line_str()));
512        }
513        // Line 8: natms_per_type.
514        let natms_per_type: Vec<usize> = match self.read_line_str() {
515            Some(line) => match crate::parser::parse_count_line(line, natm_types) {
516                Ok(v) => v,
517                Err(e) => return Some(Err(e)),
518            },
519            None => return Some(Err(error::ParseError::IncompleteHeader)),
520        };
521        if natms_per_type.iter().any(|n| *n == 0) {
522            return Some(Err(error::ParseError::InvalidCount(
523                "each per-type count must be >= 1".into(),
524            )));
525        }
526        // Line 9: masses_per_type, consumed.
527        if let Err(e) = self.advance_lines(1) {
528            return Some(Err(e));
529        }
530        let total_atoms: usize = natms_per_type.iter().sum();
531        let coord_block_lines = total_atoms + natm_types * 2;
532        if let Err(e) = self.advance_lines_to(coord_block_lines, end) {
533            return Some(Err(e));
534        }
535        // Optional sections: blank line + same-shape block, repeated.
536        self.lines.clear_peek();
537        loop {
538            let rest = &self.lines.bytes[self.lines.pos..];
539            if rest.is_empty() {
540                break;
541            }
542            let next_eol = memchr::memchr(b'\n', rest);
543            let line = match next_eol {
544                Some(pos) => &rest[..pos],
545                None => rest,
546            };
547            let is_blank = line.iter().all(|b| matches!(b, b' ' | b'\t' | b'\r'));
548            if !is_blank {
549                break;
550            }
551            let after = match next_eol {
552                Some(pos) => &rest[pos + 1..],
553                None => &[][..],
554            };
555            // A blank line and then only whitespace is the end of the file,
556            // not a section (CON-frame.5).
557            if after.iter().all(|b| b.is_ascii_whitespace()) {
558                self.lines.pos = self.lines.bytes.len();
559                break;
560            }
561            // A blank that does not begin a section is line 1 of the next
562            // frame, including an empty user line (CON-frame.7, CON-header.4).
563            if !blank_opens_section(after) {
564                break;
565            }
566            // Consume the blank separator and the section block.
567            self.lines.pos += next_eol.map(|p| p + 1).unwrap_or(rest.len());
568            if let Err(e) = self.advance_lines_to(coord_block_lines, end) {
569                return Some(Err(e));
570            }
571        }
572        Some(Ok(()))
573    }
574
575    /// Skips the next frame without fully parsing its atomic data.
576    ///
577    /// This is more efficient than `next()` if you only need to advance the
578    /// iterator. It reads the frame's header to determine how many lines to skip,
579    /// including any velocity section if present.
580    ///
581    /// # Returns
582    ///
583    /// * `Some(Ok(()))` on a successful skip.
584    /// * `Some(Err(ParseError::...))` if there's an error parsing the header.
585    /// * `None` if the iterator is already at the end.
586    pub fn forward(&mut self) -> Option<Result<(), error::ParseError>> {
587        // Prefer the shared memchr skip path (same cursor as full parse).
588        self.forward_fast()
589    }
590
591    /// Byte offset of the next frame start in the buffer passed to [`Self::new`].
592    pub fn byte_pos(&self) -> usize {
593        self.lines.pos
594    }
595
596    /// Skip `n` frames without parsing atom data. Returns how many were skipped.
597    /// Named `skip_frames` so it does not collide with [`Iterator::skip`].
598    pub fn skip_frames(&mut self, n: usize) -> Result<usize, error::ParseError> {
599        let mut skipped = 0usize;
600        for _ in 0..n {
601            match self.forward() {
602                None => break,
603                Some(Err(e)) => return Err(e),
604                Some(Ok(())) => skipped += 1,
605            }
606        }
607        Ok(skipped)
608    }
609
610    /// Next frame plus the exact substring of the buffer passed to [`Self::new`].
611    ///
612    /// **Corpus ingest contract:** successive successful spans from the same
613    /// `file_contents` are contiguous (`end` of frame *i* equals `start` of frame
614    /// *i+1*) and, for a buffer that is only multi-frame CON (no prefix garbage),
615    /// concatenating all spans reproduces the trajectory text. Campaign stores
616    /// (`readcon-db`) must persist these spans as authoritative blobs—do not
617    /// re-serialize on the hot ingest path unless the caller supplied in-memory
618    /// [`types::ConFrame`] values without source text.
619    ///
620    /// See also [`crate::index_proj::frame_byte_spans`] and
621    /// [`crate::index_proj::spans_cover_buffer`].
622    pub fn next_with_raw_span(
623        &mut self,
624        file_contents: &'a str,
625    ) -> Option<Result<(types::ConFrame, &'a str), error::ParseError>> {
626        let base = file_contents.as_ptr() as usize;
627        let start = {
628            let line = self.lines.peek_line()?;
629            line.as_ptr() as usize - base
630        };
631        let frame = match self.next()? {
632            Ok(f) => f,
633            Err(e) => return Some(Err(e)),
634        };
635        let end = match self.lines.peek_line() {
636            Some(line) => line.as_ptr() as usize - base,
637            None => file_contents.len(),
638        };
639        debug_assert!(end >= start && end <= file_contents.len());
640        Some(Ok((frame, &file_contents[start..end])))
641    }
642
643    /// [`Iterator::next`] plus the byte offset where the frame ends.
644    pub(crate) fn next_with_end(
645        &mut self,
646    ) -> Option<Result<(types::ConFrame, usize), error::ParseError>> {
647        let frame = match self.next()? {
648            Ok(f) => f,
649            Err(e) => return Some(Err(e)),
650        };
651        let end = match self.lines.peek_line() {
652            Some(line) => line.as_ptr() as usize - self.lines.bytes.as_ptr() as usize,
653            None => self.lines.pos,
654        };
655        Some(Ok((frame, end)))
656    }
657}
658
659impl<'a> Iterator for ConFrameIterator<'a> {
660    /// The type of item yielded by the iterator.
661    ///
662    /// Each item is a `Result` that contains a successfully parsed `ConFrame` or a
663    /// `ParseError` if the frame's data is malformed.
664    type Item = Result<types::ConFrame, error::ParseError>;
665
666    /// Advances the iterator and attempts to parse the next frame.
667    ///
668    /// This method will return `None` only when there are no more lines to consume.
669    /// If there are lines but they do not form a complete frame, it will return
670    /// `Some(Err(ParseError::...))`. Invalid UTF-8 is a validation error, not
671    /// the end of the stream.
672    fn next(&mut self) -> Option<Self::Item> {
673        // A line rejected earlier in this cursor (for example while probing
674        // for a velocity block) must not be reported as a clean EOF.
675        if let Some(err) = self.lines.utf8_error() {
676            return Some(Err(err));
677        }
678        // Trailing whitespace after the last complete frame is not a frame
679        // (CON-frame.1, D2). An empty line that is followed by more text is
680        // line 1 of the next frame (CON-frame.7).
681        if self.lines.remainder_is_whitespace() {
682            if let Some(err) = self.lines.utf8_error() {
683                return Some(Err(err));
684            }
685            while self.lines.next_line().is_some() {}
686            return None;
687        }
688        if let Some(err) = self.lines.utf8_error() {
689            return Some(Err(err));
690        }
691        // Otherwise, attempt to parse the next frame from the available lines.
692        let mut frame = match crate::parser::parse_single_frame_ls(&mut self.lines) {
693            Ok(f) => f,
694            Err(e) => return Some(Err(self.lines.prefer_utf8(e))),
695        };
696        // Optional sections mutate AoS; only re-sync section SoA when needed.
697        // Plain .con assembly already filled positions/ids/masses (no O(N)
698        // post-scan when no velocity/force sections were applied).
699        let sections =
700            match parse_declared_sections(&mut self.lines, &mut frame.header, &mut frame.atom_data)
701            {
702                Ok(n) => n,
703                // A section line that failed UTF-8 makes `next_line` return
704                // `None`. That must not surface as `IncompleteSection`.
705                Err(e) => return Some(Err(self.lines.prefer_utf8(e))),
706            };
707        if sections > 0 {
708            frame.sync_arrays_from_atom_data();
709        }
710        Some(Ok(frame))
711    }
712}
713
714#[cfg(test)]
715mod aos_soa_agreement_tests {
716    use super::*;
717    use std::path::PathBuf;
718
719    #[test]
720    fn iterator_vel_forces_soa_matches_aos() {
721        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
722            .join("resources/test/tiny_cuh2_vel_forces.con");
723        let text = std::fs::read_to_string(&p).expect("fixture");
724        let fr = ConFrameIterator::new(&text)
725            .next()
726            .expect("frame")
727            .expect("parse");
728        let n = fr.atom_data.len();
729        assert!(n > 0);
730        assert_eq!(fr.positions.nrows(), n);
731        let has_vel = fr.atom_data.iter().any(|a| a.velocity().is_some());
732        let has_frc = fr.atom_data.iter().any(|a| a.force().is_some());
733        if has_vel {
734            assert_eq!(
735                fr.velocities.nrows(),
736                n,
737                "SoA velocities must match AoS after section parse"
738            );
739        }
740        if has_frc {
741            assert_eq!(fr.forces.nrows(), n, "SoA forces must match AoS");
742        }
743        for (i, a) in fr.atom_data.iter().enumerate() {
744            let p = fr.positions.as_f64_row(i);
745            assert_eq!([a.x, a.y, a.z], p);
746            if let Some(v) = a.velocity() {
747                assert_eq!(v, fr.velocities.as_f64_row(i));
748            }
749            if let Some(f) = a.force() {
750                assert_eq!(f, fr.forces.as_f64_row(i));
751            }
752        }
753    }
754
755    /// After SoA-primary parse, section sync must not require rewriting positions
756    /// (nrows already equals N); forces SoA still filled from AoS.
757    #[test]
758    fn sync_skips_pos_when_nrows_matches_keeps_force_soa() {
759        let p =
760            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2_forces.con");
761        let text = std::fs::read_to_string(&p).expect("fixture");
762        let fr = ConFrameIterator::new(&text)
763            .next()
764            .expect("frame")
765            .expect("parse");
766        let n = fr.atom_data.len();
767        assert_eq!(fr.positions.nrows(), n);
768        assert_eq!(fr.forces.nrows(), n);
769        // Snapshot first position SoA row then re-sync; coords must stay bit-identical
770        // (no needless rewrite would change nothing but we still require agreement).
771        let p0 = fr.positions.as_f64_row(0);
772        let mut fr2 = fr.clone();
773        fr2.sync_arrays_from_atom_data();
774        assert_eq!(fr2.positions.as_f64_row(0), p0);
775        assert_eq!(fr2.forces.nrows(), n);
776        assert_eq!(
777            fr2.forces.as_f64_row(0),
778            fr2.atom_data[0].force().expect("force")
779        );
780    }
781}
782
783/// Reads all frames from a file.
784///
785/// The bytes are read into one owned buffer and parsed on the calling
786/// thread. A worker pool runs only when [`read_all_frames_with_threads`]
787/// is asked for two or more threads.
788pub fn read_all_frames(path: &Path) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
789    read_all_frames_with_threads(path, None)
790}
791
792/// Collect every frame from `text`.
793///
794/// `None`, `Some(0)`, and `Some(1)` parse on the calling thread.
795/// `Some(n)` with `n >= 2` uses a Rayon pool of that size when the
796/// `parallel` feature is on. Builds without `parallel` always parse
797/// sequentially.
798pub fn frames_from_text(
799    text: &str,
800    num_threads: Option<usize>,
801) -> Result<Vec<types::ConFrame>, error::ParseError> {
802    #[cfg(feature = "parallel")]
803    {
804        if let Some(n) = num_threads.filter(|n| *n >= 2) {
805            let parts = parse_frames_parallel_with_threads(text, Some(n));
806            let mut frames = Vec::with_capacity(parts.len());
807            for r in parts {
808                frames.push(r?);
809            }
810            return Ok(frames);
811        }
812    }
813    #[cfg(not(feature = "parallel"))]
814    {
815        let _ = num_threads;
816    }
817    ConFrameIterator::new(text).collect()
818}
819
820/// Like [`read_all_frames`], with an explicit worker count.
821///
822/// `None`, `Some(0)`, and `Some(1)` parse on the calling thread.
823/// `Some(n)` with `n >= 2` pins a Rayon pool when `parallel` is on.
824/// The file is read into one owned buffer. The caller who wants a map
825/// uses the streaming iterator.
826pub fn read_all_frames_with_threads(
827    path: &Path,
828    num_threads: Option<usize>,
829) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
830    let bytes = crate::compression::read_file_bytes(path)?;
831    Ok(frames_from_bytes(&bytes, num_threads)?)
832}
833
834/// Positions of every frame, one row-major `f64` buffer.
835///
836/// The boundary walk records each frame start. Each worker then parses
837/// only its span and writes that frame's coordinates at
838/// `frame * n_atoms * 3`. The returned buffer is the only retained
839/// coordinate storage. A file whose frames do not share one atom count
840/// is rejected, because the buffer is rectangular.
841pub struct PositionBlock {
842    pub n_frames: usize,
843    pub n_atoms: usize,
844    pub values: Vec<f64>,
845}
846
847pub fn read_positions_with_threads(
848    path: &Path,
849    num_threads: Option<usize>,
850) -> Result<PositionBlock, Box<dyn std::error::Error>> {
851    let contents = crate::compression::read_file_contents(path)?;
852    let block = match &contents {
853        crate::compression::FileContents::Owned(text) => {
854            fill_position_block(text.as_bytes(), num_threads)
855        }
856        crate::compression::FileContents::Mapped(map) => fill_position_block(map, num_threads),
857    }?;
858    Ok(block)
859}
860
861fn frame_starts(bytes: &[u8]) -> Result<Vec<usize>, error::ParseError> {
862    let mut scanner = ConFrameIterator::from_bytes(bytes);
863    let mut boundaries = Vec::new();
864    loop {
865        scanner.lines.clear_peek();
866        let start = scanner.lines.pos;
867        if start >= bytes.len() || bytes[start..].iter().all(u8::is_ascii_whitespace) {
868            break;
869        }
870        boundaries.push(start);
871        match scanner.forward_fast() {
872            Some(Ok(())) => {}
873            Some(Err(err)) => return Err(err),
874            None => break,
875        }
876    }
877    Ok(boundaries)
878}
879
880fn parse_span(
881    bytes: &[u8],
882    start: usize,
883    end: usize,
884) -> Result<types::ConFrame, error::ParseError> {
885    let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
886    match iter.next() {
887        Some(result) => result,
888        None => Err(error::ParseError::IncompleteFrame),
889    }
890}
891
892fn copy_positions(frame: &types::ConFrame, dest: &mut [f64]) -> Result<(), error::ParseError> {
893    if frame.positions.nrows().saturating_mul(3) != dest.len() {
894        return Err(error::ParseError::InvalidCount(
895            "frame atom count does not match the first frame".into(),
896        ));
897    }
898    if let Some(src) = frame.positions.f64_slice() {
899        dest.copy_from_slice(src);
900        return Ok(());
901    }
902    for (i, slot) in dest.chunks_exact_mut(3).enumerate() {
903        slot.copy_from_slice(&frame.positions.as_f64_row(i));
904    }
905    Ok(())
906}
907
908fn fill_position_block(
909    bytes: &[u8],
910    num_threads: Option<usize>,
911) -> Result<PositionBlock, error::ParseError> {
912    let boundaries = frame_starts(bytes)?;
913    if boundaries.is_empty() {
914        return Ok(PositionBlock {
915            n_frames: 0,
916            n_atoms: 0,
917            values: Vec::new(),
918        });
919    }
920    let end0 = boundaries.get(1).copied().unwrap_or(bytes.len());
921    let first = parse_span(bytes, boundaries[0], end0)?;
922    let n_atoms = first.positions.nrows();
923    let n_frames = boundaries.len();
924    let stride = n_atoms.saturating_mul(3);
925    let mut values = vec![0.0_f64; n_frames.saturating_mul(stride)];
926    if stride == 0 {
927        return Ok(PositionBlock {
928            n_frames,
929            n_atoms,
930            values,
931        });
932    }
933    fill_position_spans(bytes, &boundaries, n_atoms, num_threads, &mut values)?;
934    Ok(PositionBlock {
935        n_frames,
936        n_atoms,
937        values,
938    })
939}
940
941fn fill_position_spans(
942    bytes: &[u8],
943    boundaries: &[usize],
944    n_atoms: usize,
945    num_threads: Option<usize>,
946    values: &mut [f64],
947) -> Result<(), error::ParseError> {
948    let stride = n_atoms * 3;
949    let write_one = |i: usize, dest: &mut [f64]| {
950        let start = boundaries[i];
951        let end = boundaries.get(i + 1).copied().unwrap_or(bytes.len());
952        let frame = parse_span(bytes, start, end)?;
953        copy_positions(&frame, dest)
954    };
955    #[cfg(feature = "parallel")]
956    {
957        let parallel = !matches!(num_threads, Some(n) if n <= 1);
958        if parallel {
959            use rayon::prelude::*;
960            let mut run = || {
961                values
962                    .par_chunks_mut(stride)
963                    .enumerate()
964                    .try_for_each(|(i, dest)| write_one(i, dest))
965            };
966            return match num_threads {
967                Some(n) if n >= 2 => {
968                    let pool = rayon::ThreadPoolBuilder::new()
969                        .num_threads(n)
970                        .build()
971                        .map_err(|err| error::ParseError::ValidationError(err.to_string()))?;
972                    pool.install(run)
973                }
974                _ => run(),
975            };
976        }
977    }
978    #[cfg(not(feature = "parallel"))]
979    {
980        let _ = num_threads;
981    }
982    for (i, dest) in values.chunks_mut(stride).enumerate() {
983        write_one(i, dest)?;
984    }
985    Ok(())
986}
987
988/// [`frames_from_text`] for bytes that may not be UTF-8 yet.
989pub fn frames_from_bytes(
990    bytes: &[u8],
991    num_threads: Option<usize>,
992) -> Result<Vec<types::ConFrame>, error::ParseError> {
993    #[cfg(feature = "parallel")]
994    {
995        if let Some(n) = num_threads.filter(|n| *n >= 2) {
996            let parts = parse_frames_parallel_bytes(bytes, Some(n));
997            let mut frames = Vec::with_capacity(parts.len());
998            for r in parts {
999                frames.push(r?);
1000            }
1001            return Ok(frames);
1002        }
1003    }
1004    #[cfg(not(feature = "parallel"))]
1005    {
1006        let _ = num_threads;
1007    }
1008    let mut frames = Vec::new();
1009    for r in ConFrameIterator::from_bytes(bytes) {
1010        frames.push(r?);
1011    }
1012    Ok(frames)
1013}
1014
1015#[cfg(feature = "parallel")]
1016fn span_end(boundaries: &[usize], index: usize, len: usize) -> usize {
1017    if index + 1 < boundaries.len() {
1018        boundaries[index + 1]
1019    } else {
1020        len
1021    }
1022}
1023
1024/// Pointer to slot storage shared by the workers that claim chunks.
1025///
1026/// Each claimed index is written by one thread, so the alias is the
1027/// partition. The pointer stays valid until those workers join.
1028#[cfg(feature = "parallel")]
1029#[derive(Copy, Clone)]
1030struct SlotWritePtr(*mut std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>);
1031
1032#[cfg(feature = "parallel")]
1033impl SlotWritePtr {
1034    /// # Safety
1035    /// `index` is in range and this call is the only write of that slot.
1036    unsafe fn write_slot(self, index: usize, value: Result<types::ConFrame, error::ParseError>) {
1037        unsafe {
1038            (*self.0.add(index)).write(value);
1039        }
1040    }
1041}
1042
1043// SAFETY: `SlotWritePtr` is copied into the claim loop, and that loop
1044// writes each slot index from exactly one worker. The allocation
1045// outlives the join.
1046#[cfg(feature = "parallel")]
1047unsafe impl Send for SlotWritePtr {}
1048
1049// SAFETY: same partition as `Send`. Workers do not read a slot another
1050// worker is writing.
1051#[cfg(feature = "parallel")]
1052unsafe impl Sync for SlotWritePtr {}
1053
1054/// Minor faults of the calling thread.
1055#[cfg(feature = "parallel")]
1056fn thread_minflt() -> i64 {
1057    unsafe {
1058        let mut usage = std::mem::zeroed::<libc::rusage>();
1059        libc::getrusage(libc::RUSAGE_THREAD, &mut usage);
1060        usage.ru_minflt
1061    }
1062}
1063
1064/// Hardware LLC-miss counter for this process.
1065///
1066/// `-1` means the guest refused the event. This KVM guest returns
1067/// `EPERM` for `perf_event_open`, so the parse explanation uses the
1068/// per-worker minor faults instead.
1069#[cfg(feature = "parallel")]
1070fn sample_llc_misses() -> i64 {
1071    #[repr(C)]
1072    struct PerfEventAttr {
1073        typ: u32,
1074        size: u32,
1075        config: u64,
1076        rest: [u64; 14],
1077    }
1078    let mut attr = PerfEventAttr {
1079        typ: 3,
1080        size: std::mem::size_of::<PerfEventAttr>() as u32,
1081        // PERF_COUNT_HW_CACHE_LL, OP_READ, RESULT_MISS.
1082        config: 2 | (1 << 16),
1083        rest: [0; 14],
1084    };
1085    let fd = unsafe {
1086        libc::syscall(
1087            libc::SYS_perf_event_open,
1088            &attr as *const PerfEventAttr,
1089            0i32,
1090            -1i32,
1091            -1i32,
1092            0u64,
1093        )
1094    };
1095    if fd < 0 {
1096        return -1;
1097    }
1098    let fd = fd as libc::c_int;
1099    let mut value = 0u64;
1100    let n = unsafe { libc::read(fd, &mut value as *mut u64 as *mut libc::c_void, 8) };
1101    unsafe {
1102        libc::close(fd);
1103    }
1104    if n == 8 { value as i64 } else { -1 }
1105}
1106
1107/// Pages that do not sit on node 0.
1108///
1109/// A guest with one memory node has no remote pages. The count is 0
1110/// in that case. A wider node list sums the pages `numa_maps` records
1111/// on every node other than 0.
1112#[cfg(feature = "parallel")]
1113fn numa_remote_pages() -> i64 {
1114    let online = std::fs::read_to_string("/sys/devices/system/node/online").unwrap_or_default();
1115    let online = online.trim();
1116    let nodes = if let Some((start, end)) = online.split_once('-') {
1117        let start: i64 = start.parse().unwrap_or(0);
1118        let end: i64 = end.parse().unwrap_or(0);
1119        end - start + 1
1120    } else {
1121        1
1122    };
1123    if nodes <= 1 {
1124        return 0;
1125    }
1126    let text = std::fs::read_to_string("/proc/self/numa_maps").unwrap_or_default();
1127    let mut remote = 0i64;
1128    for line in text.lines() {
1129        for token in line.split_whitespace() {
1130            if let Some(rest) = token.strip_prefix('N') {
1131                if let Some((node, pages)) = rest.split_once('=') {
1132                    if node != "0" {
1133                        remote += pages.parse::<i64>().unwrap_or(0);
1134                    }
1135                }
1136            }
1137        }
1138    }
1139    remote
1140}
1141
1142/// Parse each frame into a slot allocated before any worker runs.
1143///
1144/// Workers claim chunk indexes from an atomic counter and write only
1145/// those slots. The returned vector is that slot storage, in boundary
1146/// order, which is the serial iterator order. `workers < 2` parses on
1147/// the calling thread.
1148#[cfg(feature = "parallel")]
1149fn parse_into_slots<F>(
1150    bytes: &[u8],
1151    len: usize,
1152    boundaries: &[usize],
1153    workers: usize,
1154    parse_span: F,
1155) -> Vec<Result<types::ConFrame, error::ParseError>>
1156where
1157    F: Fn(usize, usize) -> Result<types::ConFrame, error::ParseError> + Sync,
1158{
1159    parse_into_slots_timed(bytes, len, boundaries, workers, parse_span).slots
1160}
1161
1162/// Slot allocation and the worker parse, timed apart.
1163#[cfg(feature = "parallel")]
1164struct SlotParse {
1165    slots: Vec<Result<types::ConFrame, error::ParseError>>,
1166    alloc_s: f64,
1167    work_s: f64,
1168    sum_s: f64,
1169    max_s: f64,
1170    /// One entry per worker range, in worker order. This is CPU time.
1171    chunk_s: Vec<f64>,
1172    /// Minor faults on each worker during its range.
1173    worker_minflt: Vec<i64>,
1174    /// Frames in each worker's share.
1175    share_frames: Vec<u64>,
1176    /// File bytes in each worker's share.
1177    share_bytes: Vec<u64>,
1178}
1179
1180/// Slot allocation and the worker parse, timed apart.
1181///
1182/// `alloc_s` is the slot vector. `work_s` is the parse wall. `sum_s` is
1183/// the sum of per-chunk parse times and `max_s` is the slowest chunk.
1184/// `chunk_s` keeps every chunk. `workers < 2` still reserves the slot
1185/// vector before the calling thread fills it, and that one chunk's time
1186/// is the sum, the max, and the only entry.
1187#[cfg(feature = "parallel")]
1188fn parse_into_slots_timed<F>(
1189    bytes: &[u8],
1190    len: usize,
1191    boundaries: &[usize],
1192    workers: usize,
1193    parse_span: F,
1194) -> SlotParse
1195where
1196    F: Fn(usize, usize) -> Result<types::ConFrame, error::ParseError> + Sync,
1197{
1198    let n = boundaries.len();
1199    if n == 0 {
1200        return SlotParse {
1201            slots: Vec::new(),
1202            alloc_s: 0.0,
1203            work_s: 0.0,
1204            sum_s: 0.0,
1205            max_s: 0.0,
1206            chunk_s: Vec::new(),
1207            worker_minflt: Vec::new(),
1208            share_frames: Vec::new(),
1209            share_bytes: Vec::new(),
1210        };
1211    }
1212    if workers < 2 {
1213        let t_alloc = std::time::Instant::now();
1214        let mut slots = Vec::with_capacity(n);
1215        let alloc_s = t_alloc.elapsed().as_secs_f64();
1216        let flt0 = thread_minflt();
1217        let t_work = std::time::Instant::now();
1218        for i in 0..n {
1219            slots.push(parse_span(boundaries[i], span_end(boundaries, i, len)));
1220        }
1221        let work_s = t_work.elapsed().as_secs_f64();
1222        let worker_minflt = vec![thread_minflt().saturating_sub(flt0)];
1223        return SlotParse {
1224            slots,
1225            alloc_s,
1226            work_s,
1227            sum_s: work_s,
1228            max_s: work_s,
1229            chunk_s: vec![work_s],
1230            worker_minflt,
1231            share_frames: vec![n as u64],
1232            share_bytes: vec![len as u64],
1233        };
1234    }
1235    let t_alloc = std::time::Instant::now();
1236    let mut slots = take_slot_buf(n);
1237    let alloc_s = t_alloc.elapsed().as_secs_f64();
1238    // One contiguous range per worker. The width is
1239    // `ceil(frames / workers)`, so equal work stays sequential and the
1240    // claim count is the worker count. A short tail is the last range.
1241    let share = n.div_ceil(workers);
1242    let chunk_ns: Vec<std::sync::atomic::AtomicU64> = (0..workers)
1243        .map(|_| std::sync::atomic::AtomicU64::new(0))
1244        .collect();
1245    let chunk_flt: Vec<std::sync::atomic::AtomicI64> = (0..workers)
1246        .map(|_| std::sync::atomic::AtomicI64::new(0))
1247        .collect();
1248    let slots_ptr = SlotWritePtr(slots.as_mut_ptr());
1249    let t_work = std::time::Instant::now();
1250    {
1251        let parse_share = |worker: usize| {
1252            let start = worker * share;
1253            if start >= n {
1254                return;
1255            }
1256            let end = (start + share).min(n);
1257            let flt0 = thread_minflt();
1258            let t_chunk = std::time::Instant::now();
1259            // One iterator for the share, the same walk a single thread
1260            // uses. A fresh iterator per frame faulted a private heap
1261            // on eight workers.
1262            let byte_start = boundaries[start];
1263            let byte_end = span_end(boundaries, end - 1, len);
1264            let mut iter = ConFrameIterator::from_bytes(&bytes[byte_start..byte_end]);
1265            for i in start..end {
1266                let frame = match iter.next() {
1267                    Some(result) => result,
1268                    None => Err(error::ParseError::IncompleteFrame),
1269                };
1270                // SAFETY: this worker owns `start..end`, so `i` has one
1271                // writer. `slots` outlives the join.
1272                unsafe {
1273                    slots_ptr.write_slot(i, frame);
1274                }
1275            }
1276            chunk_ns[worker].store(
1277                t_chunk.elapsed().as_nanos() as u64,
1278                std::sync::atomic::Ordering::Relaxed,
1279            );
1280            chunk_flt[worker].store(
1281                thread_minflt().saturating_sub(flt0),
1282                std::sync::atomic::Ordering::Relaxed,
1283            );
1284        };
1285        // The thread index is the share index. Teardown drops that same
1286        // share on that same thread, so the pages come back without a
1287        // fresh fault on the next file.
1288        if rayon::current_thread_index().is_some() {
1289            rayon::broadcast(|ctx| {
1290                let worker = ctx.index();
1291                if worker < workers {
1292                    parse_share(worker);
1293                }
1294            });
1295        } else {
1296            use rayon::prelude::*;
1297            (0..workers).into_par_iter().for_each(parse_share);
1298        }
1299    }
1300    let work_s = t_work.elapsed().as_secs_f64();
1301    let mut worker_sum_s = 0.0_f64;
1302    let mut worker_max_s = 0.0_f64;
1303    let mut chunk_s = Vec::new();
1304    let mut worker_minflt = Vec::new();
1305    let mut share_frames = Vec::new();
1306    let mut share_bytes = Vec::new();
1307    for worker in 0..workers {
1308        let start = worker * share;
1309        if start >= n {
1310            continue;
1311        }
1312        let seconds = chunk_ns[worker].load(std::sync::atomic::Ordering::Relaxed) as f64 / 1.0e9;
1313        chunk_s.push(seconds);
1314        worker_sum_s += seconds;
1315        worker_max_s = worker_max_s.max(seconds);
1316        worker_minflt.push(chunk_flt[worker].load(std::sync::atomic::Ordering::Relaxed));
1317        let end = (start + share).min(n);
1318        share_frames.push((end - start) as u64);
1319        let bytes = span_end(boundaries, end - 1, len).saturating_sub(boundaries[start]);
1320        share_bytes.push(bytes as u64);
1321    }
1322    // Each index in `0..n` was written once. Move the frames out and
1323    // keep the slot pages for the next file of this width, so those
1324    // writes do not fault again.
1325    let mut owned = Vec::with_capacity(n);
1326    for index in 0..n {
1327        owned.push(unsafe { slots.as_mut_ptr().add(index).read().assume_init() });
1328    }
1329    unsafe { slots.set_len(0); }
1330    recycle_slot_buf(slots);
1331    let slots = owned;
1332    SlotParse {
1333        slots,
1334        alloc_s,
1335        work_s,
1336        sum_s: worker_sum_s,
1337        max_s: worker_max_s,
1338        chunk_s,
1339        worker_minflt,
1340        share_frames,
1341        share_bytes,
1342    }
1343}
1344
1345#[cfg(feature = "parallel")]
1346fn parse_byte_span(
1347    bytes: &[u8],
1348    start: usize,
1349    end: usize,
1350) -> Result<types::ConFrame, error::ParseError> {
1351    let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
1352    match iter.next() {
1353        Some(result) => result,
1354        None => Err(error::ParseError::IncompleteFrame),
1355    }
1356}
1357
1358#[cfg(feature = "parallel")]
1359fn parse_text_span(
1360    text: &str,
1361    start: usize,
1362    end: usize,
1363) -> Result<types::ConFrame, error::ParseError> {
1364    let mut iter = ConFrameIterator::new(&text[start..end]);
1365    match iter.next() {
1366        Some(result) => result,
1367        None => Err(error::ParseError::IncompleteFrame),
1368    }
1369}
1370
1371#[cfg(feature = "parallel")]
1372struct TimedParse {
1373    slots: Vec<Result<types::ConFrame, error::ParseError>>,
1374    /// Still alive so later phases of this read use the same workers.
1375    /// `None` when the call stayed on the calling thread.
1376    pool: Option<std::sync::Arc<rayon::ThreadPool>>,
1377    pool_s: f64,
1378    /// Zero while `pool` is still alive. The owner times the join.
1379    pool_join_s: f64,
1380    scan_s: f64,
1381    scan_minflt: i64,
1382    slot_alloc_s: f64,
1383    worker_parse_s: f64,
1384    worker_sum_s: f64,
1385    worker_max_s: f64,
1386    chunk_s: Vec<f64>,
1387    worker_minflt: Vec<i64>,
1388    share_frames: Vec<u64>,
1389    share_bytes: Vec<u64>,
1390    llc_misses: i64,
1391    numa_remote: i64,
1392    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`].
1393    scan_kind: u8,
1394}
1395
1396/// Keep freed parse pages in the heap.
1397///
1398/// Eight workers otherwise return those pages to the kernel, and the
1399/// next file faults them again. One worker already keeps a smaller set.
1400/// The same policy on every width leaves the parse fault count flat.
1401#[cfg(feature = "parallel")]
1402fn keep_parse_pages() {
1403    unsafe {
1404        // glibc `M_MMAP_MAX` and `M_TRIM_THRESHOLD`.
1405        libc::mallopt(-4, 0);
1406        libc::mallopt(-1, i32::MAX);
1407    }
1408}
1409
1410/// Slot storage kept between parses.
1411///
1412/// Eight workers write one `ConFrame` shell per frame into this buffer.
1413/// A fresh buffer is a new mapping, and those writes are the repeating
1414/// minor faults. The next parse reuses the same pages.
1415#[cfg(feature = "parallel")]
1416fn take_slot_buf(
1417    n: usize,
1418) -> Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>> {
1419    let mut buf = slot_cache()
1420        .lock()
1421        .map(|mut slot| std::mem::take(&mut *slot))
1422        .unwrap_or_default();
1423    if buf.capacity() < n {
1424        buf = Vec::with_capacity(n);
1425    }
1426    unsafe { buf.set_len(n) };
1427    buf
1428}
1429
1430#[cfg(feature = "parallel")]
1431fn recycle_slot_buf(
1432    mut buf: Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>>,
1433) {
1434    unsafe { buf.set_len(0) };
1435    if let Ok(mut slot) = slot_cache().lock() {
1436        if buf.capacity() >= slot.capacity() {
1437            *slot = buf;
1438        }
1439    }
1440}
1441
1442#[cfg(feature = "parallel")]
1443fn slot_cache() -> &'static std::sync::Mutex<
1444    Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>>,
1445> {
1446    static CACHE: std::sync::Mutex<
1447        Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>>,
1448    > = std::sync::Mutex::new(Vec::new());
1449    &CACHE
1450}
1451
1452/// One pool per worker count, kept for the life of the process.
1453///
1454/// A fresh pool on every file made each worker fault its frames again.
1455/// Reusing the workers lets the allocator hand back the pages just freed.
1456#[cfg(feature = "parallel")]
1457fn shared_pool(workers: usize) -> Option<std::sync::Arc<rayon::ThreadPool>> {
1458    use std::sync::{Arc, Mutex, OnceLock};
1459    static CACHE: OnceLock<Mutex<Vec<(usize, Arc<rayon::ThreadPool>)>>> = OnceLock::new();
1460    let cache = CACHE.get_or_init(|| Mutex::new(Vec::new()));
1461    let mut guard = cache.lock().ok()?;
1462    if let Some((_, pool)) = guard.iter().find(|(count, _)| *count == workers) {
1463        return Some(Arc::clone(pool));
1464    }
1465    keep_parse_pages();
1466    let pool = Arc::new(rayon::ThreadPoolBuilder::new().num_threads(workers).build().ok()?);
1467    guard.push((workers, Arc::clone(&pool)));
1468    Some(pool)
1469}
1470
1471/// Minor faults of this process.
1472#[cfg(feature = "parallel")]
1473pub fn process_minflt() -> i64 {
1474    unsafe {
1475        let mut usage = std::mem::zeroed::<libc::rusage>();
1476        libc::getrusage(libc::RUSAGE_SELF, &mut usage);
1477        usage.ru_minflt
1478    }
1479}
1480
1481/// Page size from `sysconf`, with the POSIX page as the fallback.
1482#[cfg(feature = "parallel")]
1483fn host_page_size() -> usize {
1484    let raw = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
1485    if raw > 0 { raw as usize } else { 4096 }
1486}
1487
1488/// Fault one word on each page of `values`.
1489///
1490/// The page count is split by the worker count. The step is the host
1491/// page, so the slice a worker faults is its own. No extra chunk size.
1492#[cfg(feature = "parallel")]
1493fn first_touch_f64(values: &mut [f64], workers: usize) {
1494    use rayon::prelude::*;
1495    if values.is_empty() || workers < 2 {
1496        return;
1497    }
1498    let page = host_page_size().max(std::mem::size_of::<f64>());
1499    let step = (page / std::mem::size_of::<f64>()).max(1);
1500    let pages = values.len().div_ceil(step);
1501    let share = pages.div_ceil(workers);
1502    if rayon::current_thread_index().is_some() {
1503        // One chunk per page. The installed pool hands each page to one
1504        // worker, so that worker faults its own slice.
1505        values.par_chunks_mut(step).for_each(|chunk| {
1506            if let Some(slot) = chunk.first_mut() {
1507                *slot = 0.0;
1508            }
1509        });
1510        return;
1511    }
1512    std::thread::scope(|scope| {
1513        let mut rest = values;
1514        for worker in 0..workers {
1515            if rest.is_empty() {
1516                break;
1517            }
1518            let pages_here = share.min(rest.len().div_ceil(step));
1519            let take = (pages_here * step).min(rest.len());
1520            let (head, tail) = rest.split_at_mut(take);
1521            rest = tail;
1522            let _ = worker;
1523            scope.spawn(move || {
1524                let mut at = 0;
1525                while at < head.len() {
1526                    head[at] = 0.0;
1527                    at += step;
1528                }
1529            });
1530        }
1531    });
1532}
1533
1534/// Move every `Ok` frame on the read's pool.
1535///
1536/// A serial walk of the slot vector grows with the frame count. The
1537/// pool that parsed the frames does this move. One thread still walks
1538/// the slots when that pool is absent.
1539#[cfg(feature = "parallel")]
1540fn finish_results(
1541    pool: Option<&rayon::ThreadPool>,
1542    slots: Vec<Result<types::ConFrame, error::ParseError>>,
1543) -> Result<Vec<types::ConFrame>, error::ParseError> {
1544    if slots.len() < 2 || pool.is_none() {
1545        let mut frames = Vec::with_capacity(slots.len());
1546        for part in slots {
1547            frames.push(part?);
1548        }
1549        return Ok(frames);
1550    }
1551    let pool = pool.expect("pool");
1552    pool.install(|| {
1553        use rayon::prelude::*;
1554        if let Some(index) = slots.par_iter().position_first(|item| item.is_err()) {
1555            return Err(match slots.into_iter().nth(index) {
1556                Some(Err(err)) => err,
1557                _ => error::ParseError::IncompleteFrame,
1558            });
1559        }
1560        Ok(slots.into_par_iter().map(|item| item.expect("ok")).collect())
1561    })
1562}
1563
1564/// Header walk on the calling thread.
1565#[cfg(feature = "parallel")]
1566pub const SCAN_SERIAL: u8 = 0;
1567/// Newline index in chunks, then a header walk on the calling thread.
1568///
1569/// Frames that do not share one byte length take this path. The newline
1570/// bytes are split across the pool. The header walk is not, because a
1571/// later frame start is the end of the skip, and that end is not known
1572/// until the skip runs.
1573#[cfg(feature = "parallel")]
1574pub const SCAN_INDEXED: u8 = 1;
1575/// Equal-length frames. Each worker boundary is checked.
1576#[cfg(feature = "parallel")]
1577pub const SCAN_EQUAL_SPAN: u8 = 2;
1578
1579/// Frame starts when every frame occupies the same number of bytes.
1580///
1581/// The first frame sets the span. Each worker boundary is skipped on
1582/// its own slice. A span that does not tile the
1583/// buffer, or a skip that does not land on the next span, returns
1584/// `None` and the caller keeps the serial walk. The starts are
1585/// `0, span, 2*span, ...` and match [`collect_frame_starts`].
1586#[cfg(feature = "parallel")]
1587fn equal_span_boundaries(bytes: &[u8], workers: usize) -> Option<Vec<usize>> {
1588    if workers < 2 || bytes.len() < 2 {
1589        return None;
1590    }
1591    let mut first = ConFrameIterator::from_bytes(bytes);
1592    match first.forward_fast() {
1593        Some(Ok(())) => {}
1594        _ => return None,
1595    }
1596    let span = first.lines.pos;
1597    if span < 2 || bytes.len() % span != 0 {
1598        return None;
1599    }
1600    let n = bytes.len() / span;
1601    if n < 2 {
1602        return None;
1603    }
1604    // Equal byte ranges. A cut that falls inside a frame resyncs to the
1605    // next frame start, and only that header is checked. The checks stay
1606    // on this worker: a nested pool wake was larger than the checks.
1607    let byte_share = bytes.len().div_ceil(workers);
1608    for worker in 0..workers {
1609        let byte_cut = worker.saturating_mul(byte_share).min(bytes.len());
1610        if byte_cut >= bytes.len() {
1611            continue;
1612        }
1613        let index = if worker == 0 {
1614            0
1615        } else {
1616            byte_cut.div_ceil(span).min(n - 1)
1617        };
1618        let start = index.saturating_mul(span);
1619        if start >= bytes.len() {
1620            continue;
1621        }
1622        let end = (start + span).min(bytes.len());
1623        let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
1624        match iter.forward_fast() {
1625            Some(Ok(())) if iter.lines.pos == end - start => {}
1626            _ => return None,
1627        }
1628    }
1629    Some(arithmetic_starts(span, n))
1630}
1631
1632/// Starts `0, span, 2*span, ...` from a buffer kept between files.
1633///
1634/// Building that buffer from a fresh allocation stalled for milliseconds
1635/// after a large free. The next file fills the same pages.
1636#[cfg(feature = "parallel")]
1637static START_CACHE: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
1638
1639#[cfg(feature = "parallel")]
1640fn arithmetic_starts(span: usize, n: usize) -> Vec<usize> {
1641    let mut starts = START_CACHE
1642        .lock()
1643        .map(|mut slot| std::mem::take(&mut *slot))
1644        .unwrap_or_default();
1645    if starts.len() != n {
1646        starts.resize(n, 0);
1647    }
1648    for (index, slot) in starts.iter_mut().enumerate() {
1649        *slot = index * span;
1650    }
1651    starts
1652}
1653
1654#[cfg(feature = "parallel")]
1655fn recycle_starts(starts: Vec<usize>) {
1656    if let Ok(mut slot) = START_CACHE.lock() {
1657        if starts.capacity() >= slot.capacity() {
1658            *slot = starts;
1659        }
1660    }
1661}
1662
1663#[cfg(feature = "parallel")]
1664fn parse_frames_parallel_bytes(
1665    bytes: &[u8],
1666    num_threads: Option<usize>,
1667) -> Vec<Result<types::ConFrame, error::ParseError>> {
1668    parse_bytes_timed(bytes, num_threads).slots
1669}
1670
1671/// Scan, slot allocation, and worker parse of `bytes`.
1672///
1673/// `Some(n)` with `n >= 2` uses a pool of that size. Every other count
1674/// stays on the calling thread. `None` follows [`scan_thread_count`].
1675/// The pool build is `pool_s`. The pool join is `pool_join_s`.
1676/// Neither one is inside the scan or the parse.
1677#[cfg(feature = "parallel")]
1678fn parse_bytes_timed(bytes: &[u8], num_threads: Option<usize>) -> TimedParse {
1679    let threads = scan_thread_count(num_threads, bytes.len());
1680    // There is no byte-length cutoff. Two or more workers scan a short
1681    // file as well as a long one. Equal-length frames walk their spans
1682    // on that pool instead of one serial header walk.
1683    let parse_workers = match num_threads {
1684        Some(n) if n >= 2 => n,
1685        Some(_) => 1,
1686        None => threads,
1687    };
1688    let run = || {
1689        let fault0 = process_minflt();
1690        let t_scan = std::time::Instant::now();
1691        let (boundaries, scan_kind) =
1692            if let Some(bounds) = equal_span_boundaries(bytes, parse_workers) {
1693                (bounds, SCAN_EQUAL_SPAN)
1694            } else {
1695                let mut scanner = ConFrameIterator::from_bytes(bytes);
1696                let scan_kind = if threads >= 2 {
1697                    // Same pool as the parse, so the newline chunks do not start
1698                    // a second set of workers. The header walk stays serial:
1699                    // unequal frames do not share a span to split on.
1700                    scanner.lines.newlines = Some(newline_positions(bytes, threads));
1701                    scanner.lines.line_no = 0;
1702                    SCAN_INDEXED
1703                } else {
1704                    SCAN_SERIAL
1705                };
1706                // Whitespace after the final frame does not open another frame.
1707                (collect_frame_starts(scanner, true), scan_kind)
1708            };
1709        let scan_s = t_scan.elapsed().as_secs_f64();
1710        let scan_minflt = process_minflt().saturating_sub(fault0);
1711        let parsed =
1712            parse_into_slots_timed(bytes, bytes.len(), &boundaries, parse_workers, |start, end| {
1713                parse_byte_span(bytes, start, end)
1714            });
1715        recycle_starts(boundaries);
1716        (parsed, scan_s, scan_kind, scan_minflt)
1717    };
1718    let (pool_s, pool, packed) = match num_threads {
1719        None => (0.0, None, run()),
1720        Some(n) if n <= 1 => {
1721            let _ = n;
1722            keep_parse_pages();
1723            let fault0 = process_minflt();
1724            let t_scan = std::time::Instant::now();
1725            let boundaries = collect_frame_starts(ConFrameIterator::from_bytes(bytes), true);
1726            let scan_s = t_scan.elapsed().as_secs_f64();
1727            let scan_minflt = process_minflt().saturating_sub(fault0);
1728            let parsed = parse_into_slots_timed(bytes, bytes.len(), &boundaries, 1, |start, end| {
1729                parse_byte_span(bytes, start, end)
1730            });
1731            (0.0, None, (parsed, scan_s, SCAN_SERIAL, scan_minflt))
1732        }
1733        Some(n) => {
1734            let t_pool = std::time::Instant::now();
1735            match shared_pool(n) {
1736                Some(pool) => {
1737                    let built = t_pool.elapsed().as_secs_f64();
1738                    let t_wake = std::time::Instant::now();
1739                    let (wake, body) = pool.install(|| {
1740                        let wake = t_wake.elapsed().as_secs_f64();
1741                        (wake, run())
1742                    });
1743                    // The same workers serve later files of this width.
1744                    // Their freed blocks stay warm for the next parse.
1745                    (built + wake, Some(pool), body)
1746                }
1747                None => (t_pool.elapsed().as_secs_f64(), None, run()),
1748            }
1749        }
1750    };
1751    let (parsed, scan_s, scan_kind, scan_minflt) = packed;
1752    TimedParse {
1753        slots: parsed.slots,
1754        pool,
1755        pool_s,
1756        pool_join_s: 0.0,
1757        scan_s,
1758        scan_minflt,
1759        slot_alloc_s: parsed.alloc_s,
1760        worker_parse_s: parsed.work_s,
1761        worker_sum_s: parsed.sum_s,
1762        worker_max_s: parsed.max_s,
1763        chunk_s: parsed.chunk_s,
1764        worker_minflt: parsed.worker_minflt,
1765        share_frames: parsed.share_frames,
1766        share_bytes: parsed.share_bytes,
1767        llc_misses: sample_llc_misses(),
1768        numa_remote: numa_remote_pages(),
1769        scan_kind,
1770    }
1771}
1772
1773/// Count frames without building atom payloads (uses [`ConFrameIterator::forward_fast`]
1774/// when possible, else [`ConFrameIterator::forward`]).
1775///
1776/// Prefer this over `read_all_frames(...).len()` when only the frame count is needed.
1777pub fn count_frames(path: &Path) -> Result<usize, Box<dyn std::error::Error>> {
1778    let contents = crate::compression::read_file_contents(path)?;
1779    let text = contents.as_str()?;
1780    let mut n = 0usize;
1781    let mut iter = ConFrameIterator::new(text);
1782    loop {
1783        match iter.forward_fast() {
1784            Some(Ok(())) => n += 1,
1785            Some(Err(e)) => return Err(Box::new(e)),
1786            None => break,
1787        }
1788    }
1789    Ok(n)
1790}
1791
1792/// Reads only the first frame from a file.
1793///
1794/// More efficient than `read_all_frames` for single-frame access because it
1795/// stops parsing after the first frame rather than collecting all of them.
1796pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
1797    let contents = crate::compression::read_file_contents(path)?;
1798    let text = contents.as_str()?;
1799    let mut iter = ConFrameIterator::new(text);
1800    match iter.next() {
1801        Some(Ok(frame)) => Ok(frame),
1802        Some(Err(e)) => Err(Box::new(e)),
1803        None => Err("No frames found in file".into()),
1804    }
1805}
1806
1807/// Skip `index` frames (no atom parse), then parse the next one.
1808///
1809/// `index == 0` is [`read_first_frame`]. Out of range is an error.
1810pub fn read_nth_frame(
1811    path: &Path,
1812    index: usize,
1813) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
1814    let contents = crate::compression::read_file_contents(path)?;
1815    let text = contents.as_str()?;
1816    read_nth_frame_from_text(text, index)
1817}
1818
1819/// Same as [`read_nth_frame`] on an already-loaded buffer.
1820pub fn read_nth_frame_from_text(
1821    text: &str,
1822    index: usize,
1823) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
1824    let mut iter = ConFrameIterator::new(text);
1825    let skipped = iter.skip_frames(index)?;
1826    if skipped < index {
1827        return Err(format!("frame {index} out of range (only {skipped} frames)").into());
1828    }
1829    match iter.next() {
1830        Some(Ok(frame)) => Ok(frame),
1831        Some(Err(e)) => Err(Box::new(e)),
1832        None => Err(format!("frame {index} out of range").into()),
1833    }
1834}
1835
1836/// Reads all frames using the checked `.rcidx` sidecar next to `path`.
1837///
1838/// Uncompressed input uses (and refreshes) `path.rcidx`; compressed
1839/// input is scanned in memory each time. UTF-8 is validated in
1840/// newline-aligned slices.
1841#[cfg(feature = "parallel")]
1842pub fn read_all_frames_indexed(
1843    path: &Path,
1844    num_threads: Option<usize>,
1845) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
1846    let contents = crate::compression::read_file_contents(path)?;
1847    let text = contents.as_str_sliced()?;
1848    let idx = match contents {
1849        crate::compression::FileContents::Mapped(_) => {
1850            crate::frame_index::load_or_build(path, text, true)?
1851        }
1852        crate::compression::FileContents::Owned(_) => {
1853            let on_disk = std::fs::metadata(path)?.len();
1854            crate::frame_index::load_or_build(path, text, on_disk == text.len() as u64)?
1855        }
1856    };
1857    let offs: Vec<usize> = idx.offsets.iter().map(|&o| o as usize).collect();
1858    parse_frames_at_offsets(text, &offs, num_threads)
1859        .into_iter()
1860        .map(|r| r.map_err(|e| Box::new(e) as Box<dyn std::error::Error>))
1861        .collect()
1862}
1863
1864enum IndexStep {
1865    Stay(usize),
1866    Missing(usize),
1867    Landed(usize, usize),
1868    Short(usize, usize),
1869}
1870
1871struct IndexedSpan {
1872    start: usize,
1873    content_end: usize,
1874    next_pos: usize,
1875}
1876
1877/// Lines in `bytes`, counting a final line that has no `\n`.
1878fn line_count(nl: &[usize], len: usize) -> usize {
1879    if len == 0 {
1880        0
1881    } else if nl.last().copied() == Some(len - 1) {
1882        nl.len()
1883    } else {
1884        nl.len() + 1
1885    }
1886}
1887
1888fn line_start_pos(nl: &[usize], len: usize, line_no: usize) -> usize {
1889    if line_no == 0 {
1890        return 0;
1891    }
1892    if line_no >= line_count(nl, len) {
1893        return len;
1894    }
1895    nl[line_no - 1] + 1
1896}
1897
1898/// Line index of the line that contains `pos`, or the line count at `len`.
1899fn line_no_for_pos(nl: &[usize], len: usize, pos: usize) -> usize {
1900    if pos >= len {
1901        return line_count(nl, len);
1902    }
1903    nl.partition_point(|&p| p < pos)
1904}
1905
1906fn indexed_span(nl: &[usize], bytes: &[u8], line_no: usize) -> Option<IndexedSpan> {
1907    let len = bytes.len();
1908    if line_no >= line_count(nl, len) {
1909        return None;
1910    }
1911    let start = if line_no == 0 { 0 } else { nl[line_no - 1] + 1 };
1912    let (raw_end, next_pos) = if line_no < nl.len() {
1913        (nl[line_no], nl[line_no] + 1)
1914    } else {
1915        (len, len)
1916    };
1917    let mut content_end = raw_end;
1918    if content_end > start && bytes[content_end - 1] == b'\r' {
1919        content_end -= 1;
1920    }
1921    Some(IndexedSpan {
1922        start,
1923        content_end,
1924        next_pos,
1925    })
1926}
1927
1928/// `n` cuts covering `0..=len`. A cut may fall in the middle of a header line.
1929#[cfg(feature = "parallel")]
1930fn equal_cuts(len: usize, n: usize) -> Vec<usize> {
1931    let n = n.max(1);
1932    let mut cuts = Vec::with_capacity(n + 1);
1933    for i in 0..=n {
1934        let at = ((i as u128) * (len as u128) / (n as u128)) as usize;
1935        let at = at.min(len);
1936        if cuts.last().copied() != Some(at) {
1937            cuts.push(at);
1938        }
1939    }
1940    if cuts.last().copied() != Some(len) {
1941        cuts.push(len);
1942    }
1943    cuts
1944}
1945
1946/// Newline offsets in `bytes`.
1947///
1948/// `cuts` splits the buffer into `[cuts[i], cuts[i + 1])`. Each piece is
1949/// scanned on its own and the offsets are concatenated. A `\n` belongs to
1950/// the piece that contains that byte, so a header line whose body crosses
1951/// a cut still contributes one newline.
1952#[cfg(feature = "parallel")]
1953fn newline_positions_at_cuts(bytes: &[u8], cuts: &[usize]) -> Vec<usize> {
1954    let spans: Vec<(usize, usize)> = cuts
1955        .windows(2)
1956        .map(|w| (w[0].min(bytes.len()), w[1].min(bytes.len())))
1957        .filter(|(start, end)| start < end)
1958        .collect();
1959    if spans.len() <= 1 {
1960        let (start, end) = spans.first().copied().unwrap_or((0, 0));
1961        return memchr::memchr_iter(b'\n', &bytes[start..end])
1962            .map(|p| start + p)
1963            .collect();
1964    }
1965    // The current Rayon pool, or the global pool when the caller has not
1966    // installed one. Chunk count is the worker cap: two chunks do not
1967    // wake every core.
1968    use rayon::prelude::*;
1969    let parts: Vec<Vec<usize>> = spans
1970        .par_iter()
1971        .map(|&(start, end)| {
1972            memchr::memchr_iter(b'\n', &bytes[start..end])
1973                .map(|p| start + p)
1974                .collect()
1975        })
1976        .collect();
1977    let mut all = Vec::with_capacity(bytes.len() / 32);
1978    for part in parts {
1979        all.extend(part);
1980    }
1981    all
1982}
1983
1984#[cfg(feature = "parallel")]
1985fn newline_positions(bytes: &[u8], threads: usize) -> Vec<usize> {
1986    newline_positions_at_cuts(bytes, &equal_cuts(bytes.len(), threads))
1987}
1988
1989/// Worker count for a newline scan.
1990///
1991/// `Some(n)` with `n >= 2` uses that many workers at every file length.
1992/// A file shorter than two bytes has nothing to split, and [`equal_cuts`]
1993/// drops a cut that repeats the previous byte, so a short file does not
1994/// invent an empty chunk. `None`, `Some(0)`, and `Some(1)` stay on the
1995/// calling thread. An omitted count must not start the global pool:
1996/// that pool made a one-frame file slower. There is no byte-length
1997/// cutoff taken from one host.
1998#[cfg(feature = "parallel")]
1999fn scan_thread_count(num_threads: Option<usize>, len: usize) -> usize {
2000    let requested = match num_threads {
2001        Some(n) if n >= 2 => n,
2002        _ => return 1,
2003    };
2004    if len < 2 { 1 } else { requested }
2005}
2006
2007fn collect_frame_starts(
2008    mut scanner: ConFrameIterator<'_>,
2009    skip_whitespace_tail: bool,
2010) -> Vec<usize> {
2011    let mut boundaries = Vec::new();
2012    loop {
2013        scanner.lines.clear_peek();
2014        let start = scanner.lines.pos;
2015        if start >= scanner.lines.bytes.len() {
2016            break;
2017        }
2018        if skip_whitespace_tail {
2019            let rest = &scanner.lines.bytes[start..];
2020            if rest.iter().all(u8::is_ascii_whitespace) {
2021                break;
2022            }
2023        }
2024        boundaries.push(start);
2025        match scanner.forward_fast() {
2026            Some(Ok(())) => {}
2027            Some(Err(_)) | None => break,
2028        }
2029    }
2030    boundaries
2031}
2032
2033/// Serial header walk. This is the reference the chunked scan must match.
2034pub fn frame_start_offsets_serial(file_contents: &str) -> Vec<usize> {
2035    collect_frame_starts(ConFrameIterator::new(file_contents), false)
2036}
2037
2038#[cfg(feature = "parallel")]
2039fn scanner_with_newlines<'a>(text: &'a str, newlines: Vec<usize>) -> ConFrameIterator<'a> {
2040    let mut scanner = ConFrameIterator::new(text);
2041    scanner.lines.line_no = 0;
2042    scanner.lines.newlines = Some(newlines);
2043    scanner
2044}
2045
2046#[cfg(feature = "parallel")]
2047fn frame_start_offsets_indexed(text: &str, newlines: Vec<usize>) -> Vec<usize> {
2048    collect_frame_starts(scanner_with_newlines(text, newlines), false)
2049}
2050
2051/// Frame starts for `text` when the newline scan is split at `cuts`.
2052///
2053/// `cuts` is the chunk edges, including 0 and `text.len()`. A cut inside a
2054/// header line is the straddling case: the stitched offsets are still the
2055/// serial offsets.
2056#[cfg(all(feature = "parallel", test))]
2057fn frame_start_offsets_at_cuts(text: &str, cuts: &[usize]) -> Vec<usize> {
2058    let newlines = newline_positions_at_cuts(text.as_bytes(), cuts);
2059    frame_start_offsets_indexed(text, newlines)
2060}
2061
2062/// Frame start byte offsets via [`ConFrameIterator::forward_fast`] (no atom parse).
2063///
2064/// Stops at the first skip error (that start is still included).
2065///
2066/// With the `parallel` feature and more than one worker, the buffer is
2067/// split into chunks and the newline bytes are scanned together. The
2068/// length of the buffer does not choose the worker count. The chunks
2069/// are not aligned to lines. A header
2070/// that straddles a cut is one line, because each `\n` is recorded by the
2071/// chunk that holds that byte and the lists are stitched in order. The
2072/// returned offsets match the serial walk.
2073pub fn frame_start_offsets(file_contents: &str) -> Vec<usize> {
2074    frame_start_offsets_with_threads(file_contents, None)
2075}
2076
2077/// [`frame_start_offsets`] using `num_threads` chunks.
2078///
2079/// `None` uses the machine's parallelism. `Some(0)` and `Some(1)` stay on
2080/// the calling thread. The offsets do not depend on the thread count.
2081///
2082/// The timed work includes the cut setup, the per-chunk scans, the
2083/// stitch, and the frame walk that reads the stitched newline index.
2084pub fn frame_start_offsets_with_threads(
2085    file_contents: &str,
2086    num_threads: Option<usize>,
2087) -> Vec<usize> {
2088    #[cfg(feature = "parallel")]
2089    {
2090        let threads = scan_thread_count(num_threads, file_contents.len());
2091        if threads >= 2 {
2092            let newlines = newline_positions(file_contents.as_bytes(), threads);
2093            return frame_start_offsets_indexed(file_contents, newlines);
2094        }
2095    }
2096    #[cfg(not(feature = "parallel"))]
2097    {
2098        let _ = num_threads;
2099    }
2100    frame_start_offsets_serial(file_contents)
2101}
2102
2103/// Parses frames in parallel using rayon, splitting on frame boundaries.
2104///
2105/// Phase 1: frame-start offsets from [`frame_start_offsets_with_threads`].
2106/// The newline scan is split into chunks and stitched, including a header
2107/// that crosses a chunk edge. The offsets match a serial
2108/// [`ConFrameIterator::forward_fast`] walk. An earlier walk built a
2109/// `Vec<&str>` of every line and summed lengths up to each frame, which
2110/// is O(N^2) in the line count.
2111///
2112/// Phase 2: each worker parses a contiguous run of those frames into
2113/// slots allocated before the parse. The slot order is the boundary
2114/// order. See [`parse_frames_parallel_with_threads`].
2115///
2116/// Requires the `parallel` feature.
2117#[cfg(feature = "parallel")]
2118pub fn parse_frames_parallel(
2119    file_contents: &str,
2120) -> Vec<Result<types::ConFrame, error::ParseError>> {
2121    parse_frames_parallel_with_threads(file_contents, None)
2122}
2123
2124/// Like [`parse_frames_parallel`], but runs both phases on an explicit
2125/// Rayon pool with `num_threads` workers when `Some(n)` (`n` is clamped
2126/// to at least 1). `None` uses the global pool for a buffer of at least
2127/// two bytes, and the calling thread otherwise.
2128///
2129/// Phase 2 splits the frame offsets into one contiguous range per
2130/// worker. Each worker writes its frames into slots allocated before
2131/// the parse. The result matches a serial iterator, field for field
2132/// and in frame order.
2133///
2134/// Requires the `parallel` feature.
2135#[cfg(feature = "parallel")]
2136pub fn parse_frames_parallel_with_threads(
2137    file_contents: &str,
2138    num_threads: Option<usize>,
2139) -> Vec<Result<types::ConFrame, error::ParseError>> {
2140    // Phase 1 runs on the same pool as phase 2. Building the pool twice
2141    // would put the newline scan on the global pool and the parse on
2142    // another.
2143    let threads = scan_thread_count(num_threads, file_contents.len());
2144    let parse_workers = match num_threads {
2145        Some(n) if n >= 2 => n,
2146        Some(_) => 1,
2147        None => threads,
2148    };
2149    let run = || {
2150        // A whitespace tail is not a frame. The serial iterator stops
2151        // there. The offset scan used by callers still records that byte
2152        // when a skip error includes it.
2153        let boundaries = if threads >= 2 {
2154            let newlines = newline_positions(file_contents.as_bytes(), threads);
2155            collect_frame_starts(scanner_with_newlines(file_contents, newlines), true)
2156        } else {
2157            collect_frame_starts(ConFrameIterator::new(file_contents), true)
2158        };
2159        parse_into_slots(
2160            file_contents.as_bytes(),
2161            file_contents.len(),
2162            &boundaries,
2163            parse_workers,
2164            |start, end| parse_text_span(file_contents, start, end),
2165        )
2166    };
2167    match num_threads {
2168        Some(n) if n >= 2 => match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
2169            Ok(pool) => pool.install(run),
2170            Err(_) => run(),
2171        },
2172        _ => run(),
2173    }
2174}
2175
2176/// Phase 2 of [`parse_frames_parallel_with_threads`]: parse the frames
2177/// that start at `boundaries` (strictly increasing byte offsets into
2178/// `file_contents`; frame `i` ends where frame `i + 1` starts).
2179///
2180/// Offsets may come from [`frame_start_offsets`] or from a checked
2181/// [`crate::frame_index::FrameIndex`]. The output slots are allocated
2182/// first. Each worker then fills one contiguous index range. Results
2183/// stay in frame order.
2184///
2185/// The timer for this function starts after `boundaries` exist. It
2186/// includes slot allocation and the worker parse. It does not include
2187/// the newline scan or the stitch.
2188///
2189/// Requires the `parallel` feature.
2190#[cfg(feature = "parallel")]
2191pub fn parse_frames_at_offsets(
2192    file_contents: &str,
2193    boundaries: &[usize],
2194    num_threads: Option<usize>,
2195) -> Vec<Result<types::ConFrame, error::ParseError>> {
2196    let workers = match num_threads {
2197        Some(n) if n <= 1 => 1,
2198        Some(n) => n,
2199        None => rayon::current_num_threads().max(1),
2200    };
2201    let run = || {
2202        parse_into_slots(file_contents.as_bytes(), file_contents.len(), boundaries, workers, |start, end| {
2203            parse_text_span(file_contents, start, end)
2204        })
2205    };
2206    match num_threads {
2207        Some(n) if n >= 2 => match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
2208            Ok(pool) => pool.install(run),
2209            Err(_) => run(),
2210        },
2211        _ => run(),
2212    }
2213}
2214
2215/// Seconds for one phased read. The open time is the file read or the
2216/// map. Scan, slot allocation, and the worker parse are separate.
2217#[derive(Clone, Copy, Debug, Default)]
2218pub struct ReadPhaseTimes {
2219    pub open_mmap_s: f64,
2220    /// Rayon pool construction. Zero when the call stays on one thread.
2221    pub pool_s: f64,
2222    /// Joining the local pool after the scan and the parse. Zero when
2223    /// the call stays on one thread.
2224    pub pool_join_s: f64,
2225    pub scan_s: f64,
2226    pub slot_alloc_s: f64,
2227    pub worker_parse_s: f64,
2228    /// Moving each parsed `Result` into the frame vector.
2229    pub finish_s: f64,
2230    /// Sum of per-chunk parse times. This is CPU time, not wall time.
2231    pub worker_sum_s: f64,
2232    /// Slowest parse chunk. This is inside `worker_parse_s`.
2233    pub worker_max_s: f64,
2234    /// Drop of the mapped or owned file buffer after the parse.
2235    pub close_s: f64,
2236    /// Wake of the worker pool and the other gaps inside the read.
2237    pub schedule_s: f64,
2238    /// Minor faults during the scan.
2239    pub scan_minflt: i64,
2240    /// Sum of per-worker minor faults during the parse.
2241    pub parse_minflt: i64,
2242    /// LLC read misses, or `-1` when the guest refuses the counter.
2243    pub llc_misses: i64,
2244    /// Pages on a node other than node 0. Zero on a one-node guest.
2245    pub numa_remote: i64,
2246    /// Minor faults while the coordinate slab is filled.
2247    pub slab_minflt: i64,
2248    /// Minor faults during the touch checksum.
2249    pub touch_minflt: i64,
2250    /// True when the bytes came from a map rather than an owned buffer.
2251    pub mapped: bool,
2252}
2253
2254/// Frames from one phased read, plus the byte length of the buffer.
2255pub struct PhasedFrames {
2256    pub frames: Vec<types::ConFrame>,
2257    pub phases: ReadPhaseTimes,
2258    pub n_bytes: usize,
2259    /// Per-chunk parse seconds, in claim order.
2260    pub chunk_s: Vec<f64>,
2261    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`] when
2262    /// `parallel` is on. Zero otherwise.
2263    pub scan_kind: u8,
2264    /// Minor faults on each parse worker.
2265    pub worker_minflt: Vec<i64>,
2266    /// Frames in each worker share.
2267    pub share_frames: Vec<u64>,
2268    /// File bytes in each worker share.
2269    pub share_bytes: Vec<u64>,
2270    /// Pool kept for the slab, the touch, and frame teardown.
2271    #[cfg(feature = "parallel")]
2272    pub pool: Option<std::sync::Arc<rayon::ThreadPool>>,
2273}
2274
2275/// Read `path` and time open, scan, slot allocation, and the worker parse.
2276///
2277/// Plain text at or above the map threshold is mapped. Smaller files and
2278/// compressed files are owned buffers. `Some(n)` with `n >= 2` parses on
2279/// that many workers. Every other count parses on the calling thread.
2280/// The frame list matches [`frames_from_bytes`].
2281pub fn read_all_frames_phased(
2282    path: &Path,
2283    num_threads: Option<usize>,
2284) -> Result<PhasedFrames, Box<dyn std::error::Error>> {
2285    let t_all = std::time::Instant::now();
2286    let t_open = std::time::Instant::now();
2287    let contents = crate::compression::read_file_contents(path)?;
2288    let open_mmap_s = t_open.elapsed().as_secs_f64();
2289    let mapped = matches!(contents, crate::compression::FileContents::Mapped(_));
2290    let n_bytes = match &contents {
2291        crate::compression::FileContents::Owned(text) => text.len(),
2292        crate::compression::FileContents::Mapped(map) => map.len(),
2293    };
2294    let bytes: &[u8] = match &contents {
2295        crate::compression::FileContents::Owned(text) => text.as_bytes(),
2296        crate::compression::FileContents::Mapped(map) => map,
2297    };
2298    let phased = frames_from_bytes_phased(bytes, num_threads)?;
2299    let workers = match num_threads {
2300        Some(n) if n >= 2 => n,
2301        _ => 1,
2302    };
2303    let t_close = std::time::Instant::now();
2304    if let crate::compression::FileContents::Mapped(map) = &contents {
2305        // The serial unmap of a fully resident file is the multi-worker
2306        // tail. Advise the pages away on the read's pool first.
2307        // One worker unmaps directly, so a serial read does not walk
2308        // the pages twice.
2309        if workers >= 2 {
2310            #[cfg(feature = "parallel")]
2311            {
2312                if let Some(pool) = phased.pool.as_ref() {
2313                    pool.install(|| {
2314                        crate::compression::advise_dontneed_parallel(map, workers);
2315                    });
2316                }
2317            }
2318            #[cfg(not(feature = "parallel"))]
2319            {
2320                let _ = (map, workers);
2321            }
2322        }
2323    }
2324    drop(contents);
2325    let close_s = t_close.elapsed().as_secs_f64();
2326    let elapsed = t_all.elapsed().as_secs_f64();
2327    let named = open_mmap_s
2328        + phased.pool_s
2329        + phased.pool_join_s
2330        + phased.scan_s
2331        + phased.slot_alloc_s
2332        + phased.worker_parse_s
2333        + phased.finish_s
2334        + close_s;
2335    let schedule_s = (elapsed - named).max(0.0);
2336    let parse_minflt = phased.worker_minflt.iter().sum();
2337    Ok(PhasedFrames {
2338        frames: phased.frames,
2339        n_bytes,
2340        chunk_s: phased.chunk_s,
2341        scan_kind: phased.scan_kind,
2342        worker_minflt: phased.worker_minflt,
2343        share_frames: phased.share_frames,
2344        share_bytes: phased.share_bytes,
2345        phases: ReadPhaseTimes {
2346            open_mmap_s,
2347            pool_s: phased.pool_s,
2348            pool_join_s: phased.pool_join_s,
2349            scan_s: phased.scan_s,
2350            slot_alloc_s: phased.slot_alloc_s,
2351            worker_parse_s: phased.worker_parse_s,
2352            finish_s: phased.finish_s,
2353            worker_sum_s: phased.worker_sum_s,
2354            worker_max_s: phased.worker_max_s,
2355            close_s,
2356            schedule_s,
2357            scan_minflt: phased.scan_minflt,
2358            parse_minflt,
2359            llc_misses: phased.llc_misses,
2360            numa_remote: phased.numa_remote,
2361            mapped,
2362            ..ReadPhaseTimes::default()
2363        },
2364        #[cfg(feature = "parallel")]
2365        pool: phased.pool,
2366    })
2367}
2368
2369/// [`frames_from_bytes`] with scan, slot allocation, and worker-parse times.
2370///
2371/// `Some(n)` with `n >= 2` uses that pool. Every other count, including
2372/// `None`, stays on the calling thread. That is the `read_con` policy:
2373/// an omitted count does not start a pool.
2374pub struct PhasedByteTimes {
2375    pub frames: Vec<types::ConFrame>,
2376    pub pool_s: f64,
2377    pub pool_join_s: f64,
2378    pub scan_s: f64,
2379    pub slot_alloc_s: f64,
2380    pub worker_parse_s: f64,
2381    pub finish_s: f64,
2382    pub worker_sum_s: f64,
2383    pub worker_max_s: f64,
2384    /// Per-chunk parse seconds, in claim order.
2385    pub chunk_s: Vec<f64>,
2386    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`] when
2387    /// `parallel` is on. Zero otherwise.
2388    pub scan_kind: u8,
2389    /// Minor faults during the scan.
2390    pub scan_minflt: i64,
2391    /// Minor faults on each parse worker, in worker order.
2392    pub worker_minflt: Vec<i64>,
2393    pub share_frames: Vec<u64>,
2394    pub share_bytes: Vec<u64>,
2395    /// LLC read misses, or `-1` when the guest refuses the counter.
2396    pub llc_misses: i64,
2397    /// Pages on a node other than node 0.
2398    pub numa_remote: i64,
2399    /// Pool kept for the slab, the touch, and frame teardown.
2400    #[cfg(feature = "parallel")]
2401    pub pool: Option<std::sync::Arc<rayon::ThreadPool>>,
2402}
2403
2404pub fn frames_from_bytes_phased(
2405    bytes: &[u8],
2406    num_threads: Option<usize>,
2407) -> Result<PhasedByteTimes, error::ParseError> {
2408    #[cfg(feature = "parallel")]
2409    {
2410        // One thread walks the file once. A scan plus a second parse made
2411        // a short file slower than the plain iterator.
2412        if !matches!(num_threads, Some(n) if n >= 2) {
2413            let flt0 = thread_minflt();
2414            let t_work = std::time::Instant::now();
2415            let mut frames = Vec::new();
2416            for part in ConFrameIterator::from_bytes(bytes) {
2417                frames.push(part?);
2418            }
2419            let worker_parse_s = t_work.elapsed().as_secs_f64();
2420            let worker_flt = thread_minflt().saturating_sub(flt0);
2421            let n_frames = frames.len() as u64;
2422            return Ok(PhasedByteTimes {
2423                frames,
2424                pool_s: 0.0,
2425                pool_join_s: 0.0,
2426                scan_s: 0.0,
2427                slot_alloc_s: 0.0,
2428                worker_parse_s,
2429                finish_s: 0.0,
2430                worker_sum_s: worker_parse_s,
2431                worker_max_s: worker_parse_s,
2432                chunk_s: vec![worker_parse_s],
2433                scan_kind: SCAN_SERIAL,
2434                scan_minflt: 0,
2435                worker_minflt: vec![worker_flt],
2436                share_frames: vec![n_frames],
2437                share_bytes: vec![bytes.len() as u64],
2438                llc_misses: sample_llc_misses(),
2439                numa_remote: numa_remote_pages(),
2440                pool: None,
2441            });
2442        }
2443        let requested = Some(num_threads.expect("workers"));
2444        let timed = parse_bytes_timed(bytes, requested);
2445        let t_finish = std::time::Instant::now();
2446        let frames = finish_results(timed.pool.as_deref(), timed.slots)?;
2447        let finish_s = t_finish.elapsed().as_secs_f64();
2448        return Ok(PhasedByteTimes {
2449            frames,
2450            pool_s: timed.pool_s,
2451            pool_join_s: timed.pool_join_s,
2452            scan_s: timed.scan_s,
2453            slot_alloc_s: timed.slot_alloc_s,
2454            worker_parse_s: timed.worker_parse_s,
2455            finish_s,
2456            worker_sum_s: timed.worker_sum_s,
2457            worker_max_s: timed.worker_max_s,
2458            chunk_s: timed.chunk_s,
2459            scan_kind: timed.scan_kind,
2460            scan_minflt: timed.scan_minflt,
2461            worker_minflt: timed.worker_minflt,
2462            share_frames: timed.share_frames,
2463            share_bytes: timed.share_bytes,
2464            llc_misses: timed.llc_misses,
2465            numa_remote: timed.numa_remote,
2466            pool: timed.pool,
2467        });
2468    }
2469    #[cfg(not(feature = "parallel"))]
2470    {
2471        let _ = num_threads;
2472        let t_work = std::time::Instant::now();
2473        let mut frames = Vec::new();
2474        for part in ConFrameIterator::from_bytes(bytes) {
2475            frames.push(part?);
2476        }
2477        let worker_parse_s = t_work.elapsed().as_secs_f64();
2478        Ok(PhasedByteTimes {
2479            frames,
2480            pool_s: 0.0,
2481            pool_join_s: 0.0,
2482            scan_s: 0.0,
2483            slot_alloc_s: 0.0,
2484            worker_parse_s,
2485            finish_s: 0.0,
2486            worker_sum_s: worker_parse_s,
2487            worker_max_s: worker_parse_s,
2488            chunk_s: vec![worker_parse_s],
2489            scan_kind: 0,
2490            scan_minflt: 0,
2491            worker_minflt: Vec::new(),
2492            share_frames: Vec::new(),
2493            share_bytes: Vec::new(),
2494            llc_misses: -1,
2495            numa_remote: 0,
2496        })
2497    }
2498}
2499
2500/// Copy every frame's coordinates into one row-major buffer.
2501///
2502/// `None` means the frames do not share one atom count. `workers >= 2`
2503/// writes disjoint frame runs. The copy is bit-identical to reading
2504/// each frame's `f64` positions in order.
2505pub fn pack_position_slab(frames: &[types::ConFrame], workers: usize) -> Option<Vec<f64>> {
2506    if frames.is_empty() {
2507        return None;
2508    }
2509    let n_atoms = frames[0].positions.nrows();
2510    if frames
2511        .iter()
2512        .any(|frame| frame.positions.nrows() != n_atoms)
2513    {
2514        return None;
2515    }
2516    let stride = n_atoms.saturating_mul(3);
2517    let total = frames.len().saturating_mul(stride);
2518    let mut values = Vec::<f64>::with_capacity(total);
2519    if total > 0 {
2520        // The parallel writers are the first touch of these pages.
2521        // A zeroed allocation would fault the whole slab on this thread.
2522        // SAFETY: every index is overwritten before the buffer is read.
2523        unsafe {
2524            values.set_len(total);
2525        }
2526    }
2527    if stride == 0 {
2528        return Some(values);
2529    }
2530    let write = |frame: &types::ConFrame, dest: &mut [f64]| {
2531        if let Some(src) = frame.positions.f64_slice() {
2532            dest.copy_from_slice(src);
2533        } else {
2534            for (row, slot) in dest.chunks_exact_mut(3).enumerate() {
2535                slot.copy_from_slice(&frame.positions.as_f64_row(row));
2536            }
2537        }
2538    };
2539    #[cfg(feature = "parallel")]
2540    {
2541        if workers >= 2 {
2542            let chunk_frames = frames.len().div_ceil(workers).max(1);
2543            let chunk_vals = chunk_frames.saturating_mul(stride);
2544            let copy_chunks = |dest_all: &mut [f64]| {
2545                use rayon::prelude::*;
2546                dest_all
2547                    .par_chunks_mut(chunk_vals)
2548                    .enumerate()
2549                    .for_each(|(chunk_index, dest)| {
2550                        let base = chunk_index * chunk_frames;
2551                        for (offset, slot) in dest.chunks_exact_mut(stride).enumerate() {
2552                            let index = base + offset;
2553                            if index < frames.len() {
2554                                write(&frames[index], slot);
2555                            }
2556                        }
2557                    });
2558            };
2559            // `par_chunks_mut` follows the installed pool. Outside one,
2560            // a private scope of `workers` threads keeps the global pool
2561            // out of this phase.
2562            if rayon::current_thread_index().is_some() {
2563                first_touch_f64(&mut values, workers);
2564                copy_chunks(&mut values);
2565            } else {
2566                first_touch_f64(&mut values, workers);
2567                std::thread::scope(|scope| {
2568                    let mut rest = &mut values[..];
2569                    let mut base = 0usize;
2570                    let mut handles = Vec::new();
2571                    while base < frames.len() {
2572                        let take_frames = chunk_frames.min(frames.len() - base);
2573                        let take_vals = take_frames * stride;
2574                        let (dest, tail) = rest.split_at_mut(take_vals);
2575                        rest = tail;
2576                        let frame_slice = &frames[base..base + take_frames];
2577                        handles.push(scope.spawn(move || {
2578                            for (frame, slot) in
2579                                frame_slice.iter().zip(dest.chunks_exact_mut(stride))
2580                            {
2581                                write(frame, slot);
2582                            }
2583                        }));
2584                        base += take_frames;
2585                    }
2586                    for handle in handles {
2587                        handle.join().expect("slab worker");
2588                    }
2589                });
2590            }
2591            return Some(values);
2592        }
2593    }
2594    #[cfg(not(feature = "parallel"))]
2595    {
2596        let _ = workers;
2597    }
2598    for (frame, dest) in frames.iter().zip(values.chunks_exact_mut(stride)) {
2599        write(frame, dest);
2600    }
2601    Some(values)
2602}
2603
2604fn mix_touch(hash: u64, value: u64) -> u64 {
2605    hash.wrapping_mul(0x9E3779B185EBCA87) ^ value
2606}
2607
2608fn one_frame_touch(frame: &types::ConFrame, coords: &[f64], index: u64) -> u64 {
2609    let mut hash = index;
2610    for value in frame.header.boxl {
2611        hash = mix_touch(hash, value.to_bits());
2612    }
2613    for atom in &frame.atom_data {
2614        hash = mix_touch(hash, atom.symbol.len() as u64);
2615        for byte in atom.symbol.as_bytes() {
2616            hash = mix_touch(hash, u64::from(*byte));
2617        }
2618    }
2619    for value in coords {
2620        hash = mix_touch(hash, value.to_bits());
2621    }
2622    hash
2623}
2624
2625/// XOR of positions, cell lengths, and atom-type symbols.
2626///
2627/// `positions` is frame-major `[frames, atoms, 3]`. `None` means the
2628/// slice does not match the frames. `workers >= 2` folds disjoint
2629/// frame runs on that many threads. Each frame's hash includes its
2630/// index, and the returned word is the XOR of those hashes.
2631pub fn touch_frame_checksum(
2632    frames: &[types::ConFrame],
2633    positions: &[f64],
2634    workers: usize,
2635) -> Option<u64> {
2636    if frames.is_empty() {
2637        return Some(0);
2638    }
2639    let n_atoms = frames[0].atom_data.len();
2640    let stride = n_atoms.saturating_mul(3);
2641    if stride == 0 || positions.len() != frames.len().saturating_mul(stride) {
2642        return None;
2643    }
2644    if frames.iter().any(|frame| frame.atom_data.len() != n_atoms) {
2645        return None;
2646    }
2647    let one = |index: usize| {
2648        let start = index * stride;
2649        one_frame_touch(
2650            &frames[index],
2651            &positions[start..start + stride],
2652            index as u64,
2653        )
2654    };
2655    Some(reduce_touch(frames.len(), workers, one))
2656}
2657
2658/// [`touch_frame_checksum`] for frames still sitting in optional slots.
2659///
2660/// `None` means a slot is empty or the position slice does not match.
2661pub fn touch_option_checksum(
2662    frames: &[Option<types::ConFrame>],
2663    positions: &[f64],
2664    workers: usize,
2665) -> Option<u64> {
2666    if frames.is_empty() {
2667        return Some(0);
2668    }
2669    if frames.iter().any(Option::is_none) {
2670        return None;
2671    }
2672    let n_atoms = frames[0].as_ref()?.atom_data.len();
2673    let stride = n_atoms.saturating_mul(3);
2674    if stride == 0 || positions.len() != frames.len().saturating_mul(stride) {
2675        return None;
2676    }
2677    if frames
2678        .iter()
2679        .any(|frame| frame.as_ref().map(|item| item.atom_data.len()) != Some(n_atoms))
2680    {
2681        return None;
2682    }
2683    let one = |index: usize| {
2684        let start = index * stride;
2685        one_frame_touch(
2686            frames[index].as_ref().expect("slot"),
2687            &positions[start..start + stride],
2688            index as u64,
2689        )
2690    };
2691    Some(reduce_touch(frames.len(), workers, one))
2692}
2693
2694fn reduce_touch(n: usize, workers: usize, one: impl Fn(usize) -> u64 + Sync) -> u64 {
2695    #[cfg(feature = "parallel")]
2696    if workers >= 2 {
2697        let fold_range = |first: usize, last: usize| {
2698            let mut acc = 0u64;
2699            for index in first..last {
2700                acc ^= one(index);
2701            }
2702            acc
2703        };
2704        let chunk = n.div_ceil(workers).max(1);
2705        // Reuse the installed read pool. A fresh pool per touch is the
2706        // build this phase used to pay on every call.
2707        if rayon::current_thread_index().is_some() {
2708            use rayon::prelude::*;
2709            return (0..workers)
2710                .into_par_iter()
2711                .map(|worker| {
2712                    let first = worker * chunk;
2713                    let last = (first + chunk).min(n);
2714                    fold_range(first, last)
2715                })
2716                .reduce(|| 0u64, |left, right| left ^ right);
2717        }
2718        let mut acc = 0u64;
2719        std::thread::scope(|scope| {
2720            let mut handles = Vec::with_capacity(workers);
2721            for worker in 0..workers {
2722                handles.push(scope.spawn(move || {
2723                    let first = worker * chunk;
2724                    let last = (first + chunk).min(n);
2725                    fold_range(first, last)
2726                }));
2727            }
2728            for handle in handles {
2729                acc ^= handle.join().unwrap_or(0);
2730            }
2731        });
2732        return acc;
2733    }
2734    #[cfg(not(feature = "parallel"))]
2735    {
2736        let _ = workers;
2737    }
2738    (0..n).map(one).fold(0, |acc, item| acc ^ item)
2739}
2740
2741/// Time a newline scan of `text`.
2742///
2743/// `chunked` with `threads >= 2` splits the buffer into that many
2744/// chunks, stitches the newline index, and walks frame starts. The
2745/// pool is built before the timer starts. The serial walk is
2746/// [`frame_start_offsets_serial`]. The returned offsets are the frame
2747/// starts. The caller chooses the worker count. File length does not.
2748pub fn measure_newline_scan(text: &str, threads: usize, chunked: bool) -> (Vec<usize>, f64) {
2749    #[cfg(feature = "parallel")]
2750    {
2751        if chunked && threads >= 2 {
2752            let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build();
2753            let t_scan = std::time::Instant::now();
2754            let offsets = match pool {
2755                Ok(pool) => pool.install(|| {
2756                    let newlines = newline_positions(text.as_bytes(), threads);
2757                    frame_start_offsets_indexed(text, newlines)
2758                }),
2759                Err(_) => {
2760                    let newlines = newline_positions(text.as_bytes(), threads);
2761                    frame_start_offsets_indexed(text, newlines)
2762                }
2763            };
2764            return (offsets, t_scan.elapsed().as_secs_f64());
2765        }
2766    }
2767    #[cfg(not(feature = "parallel"))]
2768    {
2769        let _ = (threads, chunked);
2770    }
2771    let t_scan = std::time::Instant::now();
2772    let offsets = frame_start_offsets_serial(text);
2773    (offsets, t_scan.elapsed().as_secs_f64())
2774}
2775
2776#[cfg(all(test, feature = "parallel"))]
2777mod parallel_strong_scale_tests {
2778    use super::*;
2779    use std::path::PathBuf;
2780
2781    fn multi_frame_fixture() -> String {
2782        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con");
2783        let one = std::fs::read_to_string(p).expect("fixture");
2784        // Enough frames for >1 worker to exercise the pool.
2785        one.repeat(8)
2786    }
2787
2788    fn sequential_frames(text: &str) -> Vec<types::ConFrame> {
2789        ConFrameIterator::new(text)
2790            .map(|r| r.expect("seq frame"))
2791            .collect()
2792    }
2793
2794    fn frames_payload_key(f: &types::ConFrame) -> (usize, Vec<(String, f64, f64, f64)>) {
2795        let atoms: Vec<_> = f
2796            .atom_data
2797            .iter()
2798            .map(|a| (a.symbol.to_string(), a.x, a.y, a.z))
2799            .collect();
2800        (f.atom_data.len(), atoms)
2801    }
2802
2803    #[test]
2804    fn parallel_workers_match_sequential_payloads() {
2805        let text = multi_frame_fixture();
2806        let seq = sequential_frames(&text);
2807        assert!(seq.len() >= 8);
2808        let seq_keys: Vec<_> = seq.iter().map(frames_payload_key).collect();
2809
2810        for workers in [1usize, 2, 4] {
2811            let par = parse_frames_parallel_with_threads(&text, Some(workers));
2812            assert_eq!(par.len(), seq.len(), "workers={workers}");
2813            let par_keys: Vec<_> = par
2814                .into_iter()
2815                .map(|r| frames_payload_key(&r.expect("par frame")))
2816                .collect();
2817            assert_eq!(par_keys, seq_keys, "workers={workers} frame payloads");
2818        }
2819
2820        // Global pool path agrees too.
2821        let par_default = parse_frames_parallel(&text);
2822        let def_keys: Vec<_> = par_default
2823            .into_iter()
2824            .map(|r| frames_payload_key(&r.expect("par")))
2825            .collect();
2826        assert_eq!(def_keys, seq_keys);
2827    }
2828
2829    fn live_threads() -> usize {
2830        std::fs::read_to_string("/proc/self/status")
2831            .unwrap()
2832            .lines()
2833            .find(|line| line.starts_with("Threads:"))
2834            .unwrap()
2835            .split_whitespace()
2836            .nth(1)
2837            .unwrap()
2838            .parse()
2839            .unwrap()
2840    }
2841
2842    /// A file past the old map and pool gates stays on this thread.
2843    #[test]
2844    fn default_read_of_a_wide_file_stays_on_the_calling_thread() {
2845        const CHILD: &str = "READCON_DEFAULT_THREAD_CHILD";
2846        const NAME: &str = "iterators::parallel_strong_scale_tests::default_read_of_a_wide_file_stays_on_the_calling_thread";
2847        if std::env::var_os(CHILD).is_none() {
2848            let out = std::process::Command::new(std::env::current_exe().unwrap())
2849                .args([NAME, "--exact", "--test-threads=1"])
2850                .env(CHILD, "1")
2851                .output()
2852                .unwrap();
2853            let stdout = String::from_utf8_lossy(&out.stdout);
2854            assert!(
2855                out.status.success() && stdout.contains("test result: ok. 1 passed;"),
2856                "{stdout}{}",
2857                String::from_utf8_lossy(&out.stderr)
2858            );
2859            return;
2860        }
2861        let dir = tempfile::tempdir().unwrap();
2862        let path = dir.path().join("wide.con");
2863        let one = std::fs::read_to_string(
2864            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con"),
2865        )
2866        .unwrap();
2867        std::fs::write(&path, one.repeat(200)).unwrap();
2868        assert!(std::fs::metadata(&path).unwrap().len() > 64 * 1024);
2869        let before = live_threads();
2870        let frames = super::read_all_frames(&path).unwrap();
2871        let after = live_threads();
2872        assert_eq!(before, after, "default read started worker threads");
2873        assert!(frames.len() > 1);
2874        let text = std::fs::read_to_string(&path).unwrap();
2875        let sequential = super::frames_from_text(&text, None).unwrap();
2876        assert_eq!(sequential.len(), frames.len());
2877        assert_eq!(
2878            sequential[0].atom_data[0].x, frames[0].atom_data[0].x,
2879            "default read changed a coordinate"
2880        );
2881    }
2882}
2883
2884#[cfg(all(test, feature = "parallel"))]
2885mod parallel_header_scan_tests {
2886    use super::*;
2887    use std::path::PathBuf;
2888
2889    fn fixture(name: &str) -> String {
2890        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2891            .join("resources/test")
2892            .join(name);
2893        std::fs::read_to_string(path).expect(name)
2894    }
2895
2896    /// A cut on the byte between `\r` and `\n`. The `\n` is in the next chunk.
2897    fn assert_crlf_gap_cut(text: &str) {
2898        let bytes = text.as_bytes();
2899        let mut found = 0usize;
2900        let mut i = 0usize;
2901        while i + 1 < bytes.len() && found < 6 {
2902            if bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
2903                let cut = i + 1;
2904                assert_eq!(bytes[cut - 1], b'\r');
2905                assert_eq!(bytes[cut], b'\n');
2906                assert_cuts_match(text, &[0, cut, bytes.len()]);
2907                found += 1;
2908                i += 2;
2909                continue;
2910            }
2911            i += 1;
2912        }
2913        assert!(found > 0, "fixture has no CRLF pair");
2914    }
2915
2916    fn assert_cuts_match(text: &str, cuts: &[usize]) {
2917        let serial_nl: Vec<usize> = memchr::memchr_iter(b'\n', text.as_bytes()).collect();
2918        let stitched = newline_positions_at_cuts(text.as_bytes(), cuts);
2919        assert_eq!(stitched, serial_nl, "newline stitch at {cuts:?}");
2920        let serial = frame_start_offsets_serial(text);
2921        let parallel = frame_start_offsets_at_cuts(text, cuts);
2922        assert_eq!(parallel, serial, "frame offsets at {cuts:?}");
2923    }
2924
2925    fn header_straddle_cuts(text: &str) -> Vec<Vec<usize>> {
2926        let serial = frame_start_offsets_serial(text);
2927        let bytes = text.as_bytes();
2928        let mut out = Vec::new();
2929        // Equal splits, plus a cut forced into a header line of every frame.
2930        for n in [2usize, 3, 4, 8] {
2931            out.push(equal_cuts(bytes.len(), n));
2932        }
2933        for (index, &start) in serial.iter().enumerate() {
2934            let next = serial.get(index + 1).copied().unwrap_or(bytes.len());
2935            // The header ends at the coordinate label. The window is that
2936            // header, not a fixed count of bytes past the frame start.
2937            let window = super::header_window_end(bytes, start, next);
2938            let mut line = start;
2939            while line < window {
2940                let Some(rel) = memchr::memchr(b'\n', &bytes[line..window]) else {
2941                    break;
2942                };
2943                let end = line + rel;
2944                if end - line > 1 {
2945                    let mid = line + (end - line) / 2;
2946                    // mid is inside the line, so the header byte sits across two chunks.
2947                    assert!(mid > line && mid < end);
2948                    out.push(vec![0, mid, bytes.len()]);
2949                }
2950                line = end + 1;
2951            }
2952        }
2953        out
2954    }
2955
2956    fn assert_slab(text: &str) {
2957        assert!(!text.is_empty());
2958        for cuts in header_straddle_cuts(&text) {
2959            assert_cuts_match(&text, &cuts);
2960        }
2961        let bare = text.trim_end_matches(['\n', '\r']);
2962        if !bare.is_empty() {
2963            assert_cuts_match(bare, &equal_cuts(bare.len(), 4));
2964            let mid = bare.len() / 2;
2965            let line_start = bare[..mid].rfind('\n').map(|i| i + 1).unwrap_or(0);
2966            let line_end = bare[mid..]
2967                .find('\n')
2968                .map(|i| mid + i)
2969                .unwrap_or(bare.len());
2970            if line_end - line_start > 1 {
2971                let cut = line_start + (line_end - line_start) / 2;
2972                assert_cuts_match(bare, &[0, cut, bare.len()]);
2973            }
2974        }
2975        let tailed = format!("{text}\n   \n\t\n");
2976        assert_cuts_match(
2977            &tailed,
2978            &[0, 3, tailed.len() / 2, tailed.len() - 1, tailed.len()],
2979        );
2980        let crlf = text.replace('\n', "\r\n");
2981        assert_cuts_match(&crlf, &equal_cuts(crlf.len(), 5));
2982        assert_crlf_gap_cut(&crlf);
2983        let serial = frame_start_offsets_serial(text);
2984        if serial.len() >= 2 {
2985            let second = serial[1];
2986            let next = serial.get(2).copied().unwrap_or(text.len());
2987            let broken_at = second + (next - second) / 2;
2988            let broken = &text[..broken_at.max(1)];
2989            assert_cuts_match(broken, &[0, 1.max(broken.len() / 3), broken.len()]);
2990        }
2991    }
2992
2993    #[test]
2994    fn chunked_header_scan_matches_serial_offsets() {
2995        assert_eq!(frame_start_offsets_serial(""), frame_start_offsets(""));
2996        assert_cuts_match("\n", &[0, 1]);
2997        assert_cuts_match("   \n\n", &[0, 2, 5]);
2998        for name in [
2999            "tiny_cuh2.con",
3000            "tiny_cuh2.convel",
3001            "tiny_cuh2_forces.con",
3002            "tiny_cuh2_vel_forces.con",
3003            "cuh2.con",
3004        ] {
3005            let one = fixture(name);
3006            assert_slab(&one);
3007            assert_slab(&one.repeat(3));
3008        }
3009        // The bytes parser (the read_con path) uses the same stitch.
3010        // The single frame is the short file. The repeat is a wider file.
3011        // Both use the requested worker count. There is no length cutoff.
3012        let one = fixture("cuh2.con");
3013        for wide in [one.clone(), one.repeat(8)] {
3014            let serial = frame_start_offsets_serial(&wide);
3015            for workers in [2usize, 3, 4, 8] {
3016                let parallel = frame_start_offsets_with_threads(&wide, Some(workers));
3017                assert_eq!(parallel, serial, "workers={workers} bytes={}", wide.len());
3018                let parsed =
3019                    frames_from_bytes(wide.as_bytes(), Some(workers)).expect("bytes parse");
3020                assert_eq!(parsed.len(), serial.len());
3021                let seq = frames_from_text(&wide, Some(1)).expect("serial parse");
3022                assert_eq!(parsed.len(), seq.len());
3023                assert_eq!(parsed[0].atom_data[0].x, seq[0].atom_data[0].x);
3024                assert_eq!(
3025                    parsed.last().unwrap().atom_data.last().unwrap().z,
3026                    seq.last().unwrap().atom_data.last().unwrap().z
3027                );
3028            }
3029        }
3030    }
3031}
3032
3033/// End of the header window used by straddle cuts.
3034///
3035/// The header runs up to the line that starts the coordinate block.
3036/// When that label is absent the window is the next frame start.
3037#[cfg(all(test, feature = "parallel"))]
3038fn header_window_end(bytes: &[u8], start: usize, limit: usize) -> usize {
3039    let limit = limit.min(bytes.len());
3040    let marker = b"Coordinates";
3041    let mut line = start;
3042    while line < limit {
3043        let rest = &bytes[line..limit];
3044        let rel = memchr::memchr(b'\n', rest).unwrap_or(rest.len());
3045        let content_end = line + rel;
3046        let content = &bytes[line..content_end];
3047        let trimmed = match content.iter().position(|byte| !byte.is_ascii_whitespace()) {
3048            Some(index) => &content[index..],
3049            None => b"",
3050        };
3051        if trimmed.starts_with(marker) {
3052            return line;
3053        }
3054        if content_end >= limit {
3055            break;
3056        }
3057        line = content_end + 1;
3058    }
3059    limit
3060}
3061
3062#[cfg(all(test, feature = "parallel"))]
3063mod parallel_frame_parse_tests {
3064    use super::*;
3065    use std::path::PathBuf;
3066
3067    const ZERO_ATOM: &str = "\
3068zero-atom frame
3069{\"con_spec_version\":3,\"con_spec_minor\":1,\"units\":{\"length\":\"angstrom\",\"energy\":\"eV\"}}
307010.0 10.0 10.0
307190.0 90.0 90.0
3072
3073
30740
3075
3076
3077";
3078
3079    fn fixture(name: &str) -> String {
3080        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3081            .join("resources/test")
3082            .join(name);
3083        std::fs::read_to_string(path).expect(name)
3084    }
3085
3086    fn serial_results(text: &str) -> Vec<Result<types::ConFrame, error::ParseError>> {
3087        ConFrameIterator::new(text).collect()
3088    }
3089
3090    fn written(frame: &types::ConFrame) -> Vec<u8> {
3091        let mut out = Vec::new();
3092        {
3093            let mut writer = crate::writer::ConFrameWriter::new(&mut out);
3094            writer.write_frame(frame).expect("write");
3095            writer.flush().expect("flush");
3096        }
3097        out
3098    }
3099
3100    fn assert_same_results(
3101        label: &str,
3102        serial: Vec<Result<types::ConFrame, error::ParseError>>,
3103        parallel: Vec<Result<types::ConFrame, error::ParseError>>,
3104    ) {
3105        assert_eq!(serial.len(), parallel.len(), "{label} count");
3106        for (i, (left, right)) in serial.into_iter().zip(parallel).enumerate() {
3107            match (left, right) {
3108                (Ok(a), Ok(b)) => {
3109                    assert_eq!(written(&a), written(&b), "{label} bytes frame {i}");
3110                    assert_eq!(a, b, "{label} fields frame {i}");
3111                }
3112                (Err(a), Err(b)) => {
3113                    assert_eq!(
3114                        format!("{a:?}"),
3115                        format!("{b:?}"),
3116                        "{label} error frame {i}"
3117                    );
3118                }
3119                (Ok(_), Err(err)) => panic!("{label} frame {i} serial ok parallel {err:?}"),
3120                (Err(err), Ok(_)) => panic!("{label} frame {i} serial {err:?} parallel ok"),
3121            }
3122        }
3123    }
3124
3125    fn assert_workers(label: &str, text: &str) {
3126        let serial_ok = serial_results(text).iter().all(|item| item.is_ok());
3127        for workers in [1usize, 2, 3, 4, 8] {
3128            let parallel = parse_frames_parallel_with_threads(text, Some(workers));
3129            assert_same_results(
3130                &format!("{label} workers {workers}"),
3131                serial_results(text),
3132                parallel,
3133            );
3134            let bounds = collect_frame_starts(ConFrameIterator::new(text), true);
3135            let at = parse_frames_at_offsets(text, &bounds, Some(workers));
3136            assert_same_results(
3137                &format!("{label} offsets workers {workers}"),
3138                serial_results(text),
3139                at,
3140            );
3141        }
3142        if serial_ok {
3143            for workers in [2usize, 3, 8] {
3144                let bytes = frames_from_bytes(text.as_bytes(), Some(workers)).expect(label);
3145                let seq = frames_from_text(text, Some(1)).expect(label);
3146                assert_eq!(bytes.len(), seq.len(), "{label} byte count");
3147                for (i, (a, b)) in bytes.iter().zip(seq.iter()).enumerate() {
3148                    assert_eq!(written(a), written(b), "{label} byte path frame {i}");
3149                    assert_eq!(a, b, "{label} byte path fields frame {i}");
3150                }
3151            }
3152        }
3153    }
3154
3155    fn crlf_gap_offsets(text: &str) -> Vec<usize> {
3156        let bytes = text.as_bytes();
3157        let mut cut = None;
3158        let mut i = 0usize;
3159        while i + 1 < bytes.len() {
3160            if bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
3161                cut = Some(i + 1);
3162                break;
3163            }
3164            i += 1;
3165        }
3166        let cut = cut.expect("crlf");
3167        frame_start_offsets_at_cuts(text, &[0, cut, bytes.len()])
3168    }
3169
3170    #[test]
3171    fn parallel_slots_match_serial_frames() {
3172        assert_workers("empty", "");
3173        assert_workers("blank", "\n");
3174        assert_workers("space", "   \n\n");
3175        assert_workers("zero", ZERO_ATOM);
3176        assert_workers("zero-repeat", &ZERO_ATOM.repeat(5));
3177        for name in [
3178            "tiny_cuh2.con",
3179            "tiny_cuh2.convel",
3180            "tiny_cuh2_forces.con",
3181            "tiny_cuh2_vel_forces.con",
3182            "tiny_cuh2_charges_spins_magmoms.con",
3183            "tiny_cuh2_displacements.con",
3184            "tiny_cuh2_spreads.con",
3185            "cuh2.con",
3186        ] {
3187            let one = fixture(name);
3188            assert_workers(name, &one);
3189            assert_workers(&format!("{name} x5"), &one.repeat(5));
3190            let bare = one.trim_end_matches(['\n', '\r']);
3191            assert_workers(&format!("{name} bare"), bare);
3192            let tailed = format!("{one}\n   \n\t\n");
3193            assert_workers(&format!("{name} tail"), &tailed);
3194            let crlf = one.replace('\n', "\r\n");
3195            assert_workers(&format!("{name} crlf"), &crlf);
3196            let gap = crlf_gap_offsets(&crlf);
3197            assert_eq!(
3198                gap,
3199                frame_start_offsets_serial(&crlf),
3200                "{name} crlf gap offsets"
3201            );
3202            let parsed = parse_frames_at_offsets(&crlf, &gap, Some(4));
3203            assert_same_results(
3204                &format!("{name} crlf gap parse"),
3205                serial_results(&crlf),
3206                parsed,
3207            );
3208            let serial_offs = frame_start_offsets_serial(&one);
3209            let broken: &str = if serial_offs.len() >= 2 {
3210                let second = serial_offs[1];
3211                let next = serial_offs.get(2).copied().unwrap_or(one.len());
3212                &one[..second + (next - second) / 2]
3213            } else if one.len() > 8 {
3214                &one[..one.len() / 2]
3215            } else {
3216                ""
3217            };
3218            if !broken.is_empty() {
3219                let serial_err = frames_from_text(broken, Some(1))
3220                    .err()
3221                    .map(|e| format!("{e:?}"));
3222                assert!(serial_err.is_some(), "{name} chopped still parsed");
3223                for workers in [1usize, 2, 4, 8] {
3224                    let parallel_err = frames_from_text(broken, Some(workers))
3225                        .err()
3226                        .map(|e| format!("{e:?}"));
3227                    assert_eq!(parallel_err, serial_err, "{name} chopped workers {workers}");
3228                    let byte_err = frames_from_bytes(broken.as_bytes(), Some(workers))
3229                        .err()
3230                        .map(|e| format!("{e:?}"));
3231                    assert_eq!(
3232                        byte_err, serial_err,
3233                        "{name} chopped bytes workers {workers}"
3234                    );
3235                }
3236            }
3237        }
3238    }
3239}
3240
3241#[cfg(all(test, feature = "parallel"))]
3242mod phased_read_tests {
3243    use super::*;
3244    use std::path::PathBuf;
3245
3246    fn fixture(name: &str) -> String {
3247        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3248            .join("resources/test")
3249            .join(name);
3250        std::fs::read_to_string(path).expect(name)
3251    }
3252
3253    #[test]
3254    fn phased_bytes_match_serial_frames() {
3255        let text = fixture("cuh2.con").repeat(4);
3256        let serial = frames_from_text(&text, Some(1)).expect("serial");
3257        for workers in [1usize, 2, 4, 8] {
3258            let phased = frames_from_bytes_phased(text.as_bytes(), Some(workers)).expect("phased");
3259            let parallel = phased.frames;
3260            assert_eq!(parallel.len(), serial.len(), "workers={workers}");
3261            assert!(
3262                phased.scan_s >= 0.0 && phased.slot_alloc_s >= 0.0 && phased.worker_parse_s >= 0.0
3263            );
3264            assert_eq!(parallel[0], serial[0], "workers={workers} first");
3265            assert_eq!(
3266                parallel.last().unwrap(),
3267                serial.last().unwrap(),
3268                "workers={workers} last"
3269            );
3270            let slab = pack_position_slab(&serial, workers).expect("slab");
3271            let stride = serial[0].positions.nrows() * 3;
3272            let src = serial[0].positions.f64_slice().expect("f64");
3273            assert_eq!(&slab[..stride], src);
3274            let (chunked, _) = measure_newline_scan(&text, workers.max(2), true);
3275            let (plain, _) = measure_newline_scan(&text, 1, false);
3276            if workers >= 2 {
3277                assert_eq!(chunked, plain);
3278            }
3279        }
3280    }
3281
3282    #[test]
3283    fn phased_file_matches_serial_and_maps_a_wide_file() {
3284        let text = fixture("cuh2.con").repeat(5);
3285        let dir = std::env::temp_dir();
3286        let path = dir.join("readcon-phased-wide.con");
3287        std::fs::write(&path, &text).expect("write");
3288        let phased = read_all_frames_phased(&path, Some(4)).expect("phased");
3289        let serial = frames_from_text(&text, Some(1)).expect("serial");
3290        assert_eq!(phased.frames, serial);
3291        assert!(phased.phases.mapped, "a file past 64 KiB is mapped");
3292        assert!(phased.phases.worker_parse_s >= 0.0);
3293        assert_eq!(phased.n_bytes, text.len());
3294        let _ = std::fs::remove_file(&path);
3295    }
3296
3297    #[test]
3298    fn equal_span_boundaries_match_the_serial_walk() {
3299        let text = fixture("cuh2.con").repeat(8);
3300        let bytes = text.as_bytes();
3301        let got = equal_span_boundaries(bytes, 4).expect("equal span");
3302        let expect = collect_frame_starts(ConFrameIterator::from_bytes(bytes), true);
3303        assert_eq!(got, expect);
3304        assert_eq!(got.len(), 8);
3305    }
3306
3307    #[test]
3308    fn a_whitespace_tail_skips_the_equal_span_walk() {
3309        let mut text = fixture("cuh2.con").repeat(3);
3310        text.push('\n');
3311        assert!(equal_span_boundaries(text.as_bytes(), 4).is_none());
3312        let serial = frames_from_text(&text, Some(1)).expect("serial");
3313        let parallel = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("phased");
3314        assert_eq!(parallel.frames, serial);
3315    }
3316
3317    #[test]
3318    fn touch_checksum_matches_across_worker_counts() {
3319        let text = fixture("cuh2.con").repeat(4);
3320        let serial = frames_from_bytes_phased(text.as_bytes(), Some(1)).expect("serial");
3321        let parallel = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("parallel");
3322        let slab_s = pack_position_slab(&serial.frames, 1).expect("slab");
3323        let slab_p = pack_position_slab(&parallel.frames, 4).expect("slab");
3324        let left = touch_frame_checksum(&serial.frames, &slab_s, 1).expect("touch");
3325        let right = touch_frame_checksum(&parallel.frames, &slab_p, 4).expect("touch");
3326        assert_eq!(left, right);
3327        assert_ne!(left, 0);
3328    }
3329}