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    index_line: usize,
28    /// 1-based index of the last line produced by [`Self::read_one`], including
29    /// a line rejected as UTF-8. 0 before that.
30    line_no: u32,
31}
32
33impl<'a> MemchrLines<'a> {
34    pub fn new(text: &'a str) -> Self {
35        Self::from_parts(text.as_bytes(), false)
36    }
37
38    /// Cursor over bytes that are not yet known to be UTF-8.
39    ///
40    /// ASCII coordinate rows are accepted by the numeric scan. Text lines
41    /// are validated when they are materialized, so there is no separate
42    /// whole-buffer UTF-8 pass.
43    pub fn from_bytes(bytes: &'a [u8]) -> Self {
44        Self::from_parts(bytes, true)
45    }
46
47    fn from_parts(bytes: &'a [u8], check_utf8: bool) -> Self {
48        Self {
49            bytes,
50            pos: 0,
51            peeked: None,
52            check_utf8,
53            utf8_bad: false,
54            newlines: None,
55            index_line: 0,
56            line_no: 0,
57        }
58    }
59
60    /// 1-based index of the last line read, including a line that is only peeked.
61    #[inline]
62    pub fn line_no(&self) -> u32 {
63        self.line_no
64    }
65
66    #[inline]
67    fn read_one(&mut self) -> Option<&'a str> {
68        if self.newlines.is_some() {
69            return self.read_one_indexed();
70        }
71        if self.utf8_bad || self.pos >= self.bytes.len() {
72            return None;
73        }
74        let rest = &self.bytes[self.pos..];
75        let (line_bytes, advance) = match memchr::memchr(b'\n', rest) {
76            Some(i) => (&rest[..i], i + 1),
77            None => (rest, rest.len()),
78        };
79        let trimmed = if line_bytes.last() == Some(&b'\r') {
80            &line_bytes[..line_bytes.len() - 1]
81        } else {
82            line_bytes
83        };
84        if self.check_utf8 {
85            match std::str::from_utf8(trimmed) {
86                Ok(s) => {
87                    self.accept_line(advance);
88                    Some(s)
89                }
90                Err(_) => {
91                    // Stay on the rejected line. Moving past it makes a
92                    // whitespace tail look like a clean end of file.
93                    // The line number still names this line.
94                    self.line_no = self.line_no.saturating_add(1);
95                    self.utf8_bad = true;
96                    None
97                }
98            }
99        } else {
100            self.accept_line(advance);
101            // SAFETY: source was `&str`; line is a UTF-8 prefix cut on ASCII `\n`/`\r`.
102            Some(unsafe { std::str::from_utf8_unchecked(trimmed) })
103        }
104    }
105
106    #[inline]
107    fn accept_line(&mut self, advance: usize) {
108        self.pos += advance;
109        self.line_no = self.line_no.saturating_add(1);
110    }
111
112    #[inline]
113    pub fn next_line(&mut self) -> Option<&'a str> {
114        if let Some(p) = self.peeked.take() {
115            return Some(p);
116        }
117        self.read_one()
118    }
119
120    #[inline]
121    pub fn peek_line(&mut self) -> Option<&'a str> {
122        if self.peeked.is_none() {
123            self.peeked = self.read_one();
124        }
125        self.peeked
126    }
127
128    /// Drop any peek buffer (required before bulk cursor advances).
129    fn clear_peek(&mut self) {
130        if let Some(p) = self.peeked.take() {
131            // Rewind pos to the start of the peeked line.
132            let start = p.as_ptr() as usize - self.bytes.as_ptr() as usize;
133            self.pos = start;
134            // A peek already counted this line. Rewinding must drop that
135            // count so the next read names it once, and must put the
136            // newline index back on the line `pos` now starts.
137            self.line_no = self.line_no.saturating_sub(1);
138            if let Some(nl) = self.newlines.as_deref() {
139                self.index_line = line_no_for_pos(nl, self.bytes.len(), start);
140            }
141        }
142    }
143
144    /// One line from the precomputed newline index.
145    ///
146    /// The cursor must sit on a line start (or at the end). The bytes of
147    /// the line are the same slice `memchr` would return, including a line
148    /// whose body crosses a chunk boundary: only the `\n` is indexed.
149    fn read_one_indexed(&mut self) -> Option<&'a str> {
150        if self.utf8_bad || self.pos >= self.bytes.len() {
151            return None;
152        }
153        let len = self.bytes.len();
154        let line_no = {
155            let nl = self.newlines.as_deref().unwrap();
156            if self.pos == line_start_pos(nl, len, self.index_line) {
157                self.index_line
158            } else {
159                line_no_for_pos(nl, len, self.pos)
160            }
161        };
162        let span = indexed_span(self.newlines.as_deref().unwrap(), self.bytes, line_no)?;
163        let trimmed = &self.bytes[span.start..span.content_end];
164        if self.check_utf8 {
165            match std::str::from_utf8(trimmed) {
166                Ok(s) => {
167                    self.pos = span.next_pos;
168                    self.index_line = line_no + 1;
169                    self.line_no = self.index_line as u32;
170                    Some(s)
171                }
172                Err(_) => {
173                    self.utf8_bad = true;
174                    self.index_line = line_no;
175                    // The rejected line is 1-based `line_no + 1`. The index
176                    // stays on that line so a later read does not skip it.
177                    self.line_no = (line_no as u32).saturating_add(1);
178                    None
179                }
180            }
181        } else {
182            self.pos = span.next_pos;
183            self.index_line = line_no + 1;
184            self.line_no = self.index_line as u32;
185            // SAFETY: source was `&str`; the span is a UTF-8 line cut on ASCII `\n`/`\r`.
186            Some(unsafe { std::str::from_utf8_unchecked(trimmed) })
187        }
188    }
189
190    fn utf8_error(&self) -> Option<error::ParseError> {
191        self.utf8_bad.then(invalid_utf8)
192    }
193
194    /// Keep `err` unless this cursor already rejected a line as UTF-8.
195    fn prefer_utf8(&self, err: error::ParseError) -> error::ParseError {
196        if self.utf8_bad { invalid_utf8() } else { err }
197    }
198}
199
200fn invalid_utf8() -> error::ParseError {
201    error::ParseError::ValidationError("invalid UTF-8".into())
202}
203
204impl<'a> Iterator for MemchrLines<'a> {
205    type Item = &'a str;
206    fn next(&mut self) -> Option<&'a str> {
207        self.next_line()
208    }
209}
210
211impl<'a> LineStream<'a> for MemchrLines<'a> {
212    #[inline]
213    fn next_line(&mut self) -> Option<&'a str> {
214        MemchrLines::next_line(self)
215    }
216    #[inline]
217    fn peek_line(&mut self) -> Option<&'a str> {
218        MemchrLines::peek_line(self)
219    }
220    #[inline]
221    fn next_coord_row(
222        &mut self,
223        spec_version: u32,
224        default_id: u64,
225    ) -> Result<([f64; 3], [bool; 3], u64), crate::error::ParseError> {
226        if let Some(p) = self.peeked.take() {
227            return crate::parser::parse_coord_row_at(p.as_bytes(), 0, spec_version, default_id)
228                .map(|(row, _)| row);
229        }
230        // The failing row is this line even when the numeric scan returns Err
231        // before the cursor moves.
232        self.line_no = self.line_no.saturating_add(1);
233        let (row, pos) =
234            crate::parser::parse_coord_row_at(self.bytes, self.pos, spec_version, default_id)?;
235        self.pos = pos;
236        Ok(row)
237    }
238    #[inline]
239    fn next_vector_row(
240        &mut self,
241        spec_version: u32,
242    ) -> Result<([f64; 3], [bool; 3], u64), crate::error::ParseError> {
243        if let Some(p) = self.peeked.take() {
244            return crate::parser::parse_vector_row_at(p.as_bytes(), 0, spec_version)
245                .map(|(row, _)| row);
246        }
247        let (row, pos) = crate::parser::parse_vector_row_at(self.bytes, self.pos, spec_version)?;
248        self.pos = pos;
249        Ok(row)
250    }
251    fn remainder_is_whitespace(&mut self) -> bool {
252        if self.utf8_bad {
253            return false;
254        }
255        if let Some(line) = self.peek_line()
256            && !line.trim().is_empty()
257        {
258            return false;
259        }
260        // `peek_line` may have rejected a line. That is a decode error,
261        // including when every byte after it is ASCII whitespace.
262        if self.utf8_bad {
263            return false;
264        }
265        self.bytes[self.pos..]
266            .iter()
267            .all(|b| b.is_ascii_whitespace())
268    }
269    fn blank_line_opens_section(&mut self) -> bool {
270        // `peek_line` has already moved `pos` to the line after the peek.
271        let Some(peeked) = self.peeked else {
272            return false;
273        };
274        if !peeked.trim().is_empty() {
275            return false;
276        }
277        match probe_section(&self.bytes[self.pos..]) {
278            SectionProbe::Opens => true,
279            SectionProbe::Closed => false,
280            // Leave the rejected bytes on the cursor. `prefer_utf8` then
281            // reports the decode error instead of a missing section.
282            SectionProbe::InvalidUtf8 => {
283                self.utf8_bad = true;
284                false
285            }
286        }
287    }
288
289    fn blank_opens_velocity_block(&mut self) -> bool {
290        match self.peek_line() {
291            Some(line) if line.trim().is_empty() => self.blank_line_opens_section(),
292            _ => true,
293        }
294    }
295}
296
297fn split_raw_line(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
298    if bytes.is_empty() {
299        return None;
300    }
301    match memchr::memchr(b'\n', bytes) {
302        Some(i) => Some((&bytes[..i], &bytes[i + 1..])),
303        None => Some((bytes, &[])),
304    }
305}
306
307fn strip_cr(line: &[u8]) -> &[u8] {
308    if line.last() == Some(&b'\r') {
309        &line[..line.len() - 1]
310    } else {
311        line
312    }
313}
314
315enum SectionProbe {
316    Opens,
317    Closed,
318    InvalidUtf8,
319}
320
321/// A blank line opens a section when the next line is a symbol and the
322/// line after that is a component label (CON-file.5, CON-header.4).
323fn probe_section(after_blank: &[u8]) -> SectionProbe {
324    let Some((raw1, after1)) = split_raw_line(after_blank) else {
325        return SectionProbe::Closed;
326    };
327    let line1 = strip_cr(raw1);
328    if line1.iter().all(|b| matches!(b, b' ' | b'\t')) {
329        return SectionProbe::Closed;
330    }
331    let Ok(text1) = std::str::from_utf8(line1) else {
332        return SectionProbe::InvalidUtf8;
333    };
334    if text1.trim_start().starts_with('{') {
335        return SectionProbe::Closed;
336    }
337    let Some((raw2, _)) = split_raw_line(after1) else {
338        return SectionProbe::Closed;
339    };
340    let Ok(text2) = std::str::from_utf8(strip_cr(raw2)) else {
341        return SectionProbe::InvalidUtf8;
342    };
343    if crate::parser::line_has_component_label(text2) {
344        SectionProbe::Opens
345    } else {
346        SectionProbe::Closed
347    }
348}
349
350fn blank_opens_section(after_blank: &[u8]) -> bool {
351    matches!(probe_section(after_blank), SectionProbe::Opens)
352}
353
354/// An iterator that lazily parses simulation frames from a `.con` or `.convel`
355/// file's contents.
356///
357/// This struct wraps a memchr line cursor over the file buffer and, upon each
358/// iteration, attempts to parse a complete `ConFrame`. A blank line after the
359/// coordinates is a velocity section only when it begins a complete velocity
360/// block. Otherwise that line is line 1 of the next frame, and it may be empty.
361///
362/// The iterator yields items of type `Result<ConFrame, ParseError>`, allowing for
363/// robust error handling for each frame.
364pub struct ConFrameIterator<'a> {
365    pub(crate) lines: MemchrLines<'a>,
366    /// 0-based index of the frame the cursor is about to read.
367    /// Successful `next` and `forward_fast` advance it. A failure leaves
368    /// it on the frame that failed.
369    frame_index: u64,
370}
371
372/// Newlines seen and the byte index just past the `n`-th `\n`.
373///
374/// When fewer than `n` newlines exist, the count is how many were seen
375/// and the index is `hay.len()`.
376///
377/// # Safety
378/// The CPU supports AVX2.
379#[cfg(target_arch = "x86_64")]
380#[target_feature(enable = "avx2,popcnt")]
381#[inline]
382unsafe fn skip_newlines_avx2(hay: &[u8], n: usize) -> (usize, usize) {
383    use std::arch::x86_64::{
384        __m256i, _mm256_cmpeq_epi8, _mm256_loadu_si256, _mm256_movemask_epi8, _mm256_set1_epi8,
385        _popcnt32,
386    };
387    if n == 0 {
388        return (0, 0);
389    }
390    let mut need = n;
391    let mut offset = 0usize;
392    let ptr = hay.as_ptr();
393    let len = hay.len();
394    let nl = _mm256_set1_epi8(b'\n' as i8);
395    while offset + 32 <= len {
396        // SAFETY: `offset + 32 <= len`, so the 32-byte load stays inside `hay`.
397        let chunk = unsafe { _mm256_loadu_si256(ptr.add(offset) as *const __m256i) };
398        let eq = _mm256_cmpeq_epi8(chunk, nl);
399        let mask = _mm256_movemask_epi8(eq) as u32;
400        let count = _popcnt32(mask as i32) as usize;
401        if count >= need {
402            let mut bits = mask;
403            for _ in 1..need {
404                bits &= bits - 1;
405            }
406            let bit = bits.trailing_zeros() as usize;
407            return (n, offset + bit + 1);
408        }
409        need -= count;
410        offset += 32;
411    }
412    while offset < len {
413        if hay[offset] == b'\n' {
414            need -= 1;
415            if need == 0 {
416                return (n, offset + 1);
417            }
418        }
419        offset += 1;
420    }
421    (n - need, len)
422}
423
424impl<'a> ConFrameIterator<'a> {
425    /// Creates a new `ConFrameIterator` from a string slice of the entire file.
426    ///
427    /// # Arguments
428    ///
429    /// * `file_contents` - A string slice containing the text of one or more `.con` frames.
430    pub fn new(file_contents: &'a str) -> Self {
431        ConFrameIterator {
432            lines: MemchrLines::new(file_contents),
433            frame_index: 0,
434        }
435    }
436
437    /// Like [`Self::new`], but reading from byte `pos` of `file_contents`.
438    ///
439    /// `pos` must be 0 or follow a `\n`. The iterator keeps no state from
440    /// earlier frames, so skipping from `pos` sees what a scan sees there.
441    pub(crate) fn starting_at(file_contents: &'a str, pos: usize) -> Self {
442        debug_assert!(pos == 0 || file_contents.as_bytes()[pos - 1] == b'\n');
443        let mut it = Self::new(file_contents);
444        it.lines.pos = pos;
445        it
446    }
447
448    /// Like [`Self::new`] for a buffer that has not been UTF-8 validated.
449    ///
450    /// Text lines are checked as they are tokenized. Numeric rows reject a
451    /// non-ASCII byte during the fused parse, which is the UTF-8 check for
452    /// those lines.
453    pub fn from_bytes(bytes: &'a [u8]) -> Self {
454        ConFrameIterator {
455            lines: MemchrLines::from_bytes(bytes),
456            frame_index: 0,
457        }
458    }
459
460    /// 0-based index of the frame the cursor is on.
461    ///
462    /// This is the frame a following error belongs to. It is not the
463    /// number of frames still ahead.
464    pub(crate) fn frame_index(&self) -> u64 {
465        self.frame_index
466    }
467
468    fn note_skipped_frame(&mut self) -> Option<Result<(), error::ParseError>> {
469        self.frame_index = self.frame_index.saturating_add(1);
470        Some(Ok(()))
471    }
472
473    /// Bulk-skips `n` lines from the shared memchr cursor.
474    ///
475    /// A final line with no trailing newline still counts when it is the
476    /// last line this call needs and its bytes are present. The full
477    /// parser accepts that line. Further lines after it are missing.
478    ///
479    /// The header has already named the line count, so the skip counts
480    /// newlines instead of parsing the rows. On AVX2 that count is one
481    /// pass over the span.
482    #[inline(always)]
483    fn advance_lines(&mut self, n: usize) -> Result<(), error::ParseError> {
484        self.lines.clear_peek();
485        if self.lines.newlines.is_some() {
486            return self.advance_lines_indexed(n);
487        }
488        #[cfg(target_arch = "x86_64")]
489        {
490            if n > 1 && std::is_x86_feature_detected!("avx2") {
491                return self.advance_lines_avx2(n);
492            }
493        }
494        self.advance_lines_memchr(n)
495    }
496
497    fn advance_lines_memchr(&mut self, n: usize) -> Result<(), error::ParseError> {
498        for i in 0..n {
499            let rest = &self.lines.bytes[self.lines.pos..];
500            match memchr::memchr(b'\n', rest) {
501                Some(pos) => {
502                    self.lines.pos += pos + 1;
503                    self.lines.line_no = self.lines.line_no.saturating_add(1);
504                }
505                None => {
506                    if rest.is_empty() {
507                        return Err(error::ParseError::IncompleteFrame);
508                    }
509                    self.lines.pos = self.lines.bytes.len();
510                    // Count the unterminated line so a later located error
511                    // names it. It satisfies this skip only when it is the
512                    // last line still required; the full parser accepts it.
513                    self.lines.line_no = self.lines.line_no.saturating_add(1);
514                    if i + 1 == n {
515                        return Ok(());
516                    }
517                    return Err(error::ParseError::IncompleteFrame);
518                }
519            }
520        }
521        Ok(())
522    }
523
524    /// [`Self::advance_lines_memchr`] counting newlines in AVX2 chunks.
525    ///
526    /// The landed position and the incomplete-frame condition match the
527    /// per-line search. `n > 1` is the caller's guard: a one-line skip
528    /// is already a single `memchr`.
529    #[cfg(target_arch = "x86_64")]
530    #[inline]
531    fn advance_lines_avx2(&mut self, n: usize) -> Result<(), error::ParseError> {
532        let rest = &self.lines.bytes[self.lines.pos..];
533        let (found, end) = unsafe { skip_newlines_avx2(rest, n) };
534        if found == n {
535            self.lines.pos += end;
536            // The per-line walk counts each skipped line. The vectorized
537            // jump has to keep the same 1-based file line.
538            self.lines.line_no = self
539                .lines
540                .line_no
541                .saturating_add(u32::try_from(n).unwrap_or(u32::MAX));
542            return Ok(());
543        }
544        if rest.is_empty() {
545            return Err(error::ParseError::IncompleteFrame);
546        }
547        let has_tail = rest.last().copied() != Some(b'\n');
548        self.lines.pos = self.lines.bytes.len();
549        let counted = found + usize::from(has_tail);
550        self.lines.line_no = self
551            .lines
552            .line_no
553            .saturating_add(u32::try_from(counted).unwrap_or(u32::MAX));
554        if counted >= n {
555            Ok(())
556        } else {
557            Err(error::ParseError::IncompleteFrame)
558        }
559    }
560
561    /// [`Self::advance_lines`] for `n` lines expected to end at `end`.
562    ///
563    /// When `end` follows a `\n` and exactly `n` newlines lie before it,
564    /// the per-line search would stop at `end` too, so one vectorized
565    /// count replaces it. Any other case takes the per-line search.
566    fn advance_lines_to(&mut self, n: usize, end: Option<usize>) -> Result<(), error::ParseError> {
567        // The newline index jumps by line count. `end` does not change
568        // where that jump lands; it only shortcuts the per-line search.
569        if self.lines.newlines.is_some() {
570            return self.advance_lines(n);
571        }
572        self.lines.clear_peek();
573        let pos = self.lines.pos;
574        let bytes = self.lines.bytes;
575        if let Some(end) = end
576            && pos < end
577            && end <= bytes.len()
578            && bytes[end - 1] == b'\n'
579            && memchr::memchr_iter(b'\n', &bytes[pos..end]).count() == n
580        {
581            self.lines.pos = end;
582            // The per-line walk would count each of these lines. The
583            // vectorized jump has to keep the same 1-based line.
584            self.lines.line_no = self
585                .lines
586                .line_no
587                .saturating_add(u32::try_from(n).unwrap_or(u32::MAX));
588            return Ok(());
589        }
590        self.advance_lines(n)
591    }
592
593    /// [`Self::advance_lines`] using the stitched newline index.
594    ///
595    /// A missing line and a final line with no `\n` land on the same byte
596    /// as the `memchr` loop. The index is not consulted for a full parse.
597    fn advance_lines_indexed(&mut self, n: usize) -> Result<(), error::ParseError> {
598        let len = self.lines.bytes.len();
599        let step = {
600            let nl = self.lines.newlines.as_deref().unwrap();
601            let nlines = line_count(nl, len);
602            let line_no = if self.lines.pos == line_start_pos(nl, len, self.lines.index_line) {
603                self.lines.index_line
604            } else {
605                line_no_for_pos(nl, len, self.lines.pos)
606            };
607            if n == 0 {
608                IndexStep::Stay(line_no)
609            } else if line_no >= nlines {
610                IndexStep::Missing(line_no)
611            } else {
612                match line_no.checked_add(n) {
613                    Some(target) if target <= nlines => {
614                        let pos = if target == nlines {
615                            len
616                        } else {
617                            line_start_pos(nl, len, target)
618                        };
619                        IndexStep::Landed(target, pos)
620                    }
621                    _ => IndexStep::Short(nlines, len),
622                }
623            }
624        };
625        match step {
626            IndexStep::Stay(line_no) => {
627                self.lines.index_line = line_no;
628                self.lines.line_no = u32::try_from(line_no).unwrap_or(u32::MAX);
629                Ok(())
630            }
631            IndexStep::Missing(line_no) => {
632                self.lines.index_line = line_no;
633                self.lines.line_no = u32::try_from(line_no).unwrap_or(u32::MAX);
634                Err(error::ParseError::IncompleteFrame)
635            }
636            IndexStep::Landed(line_no, pos) => {
637                self.lines.index_line = line_no;
638                self.lines.line_no = u32::try_from(line_no).unwrap_or(u32::MAX);
639                self.lines.pos = pos;
640                Ok(())
641            }
642            IndexStep::Short(line_no, pos) => {
643                self.lines.index_line = line_no;
644                self.lines.line_no = u32::try_from(line_no).unwrap_or(u32::MAX);
645                self.lines.pos = pos;
646                Err(error::ParseError::IncompleteFrame)
647            }
648        }
649    }
650
651    /// One line from the shared cursor (same as full-parse path).
652    fn read_line_str(&mut self) -> Option<&'a str> {
653        self.lines.clear_peek();
654        self.lines.next_line()
655    }
656
657    /// memchr-backed equivalent of [`Self::forward`]. Skips the next
658    /// frame without fully parsing its atom data. Shares the same line
659    /// cursor as [`Iterator::next`], so skip and full parse interleave safely.
660    pub fn forward_fast(&mut self) -> Option<Result<(), error::ParseError>> {
661        self.forward_fast_to(None)
662    }
663
664    /// [`Self::forward_fast`] for a frame expected to end at byte `end`.
665    ///
666    /// The result and the new position are the same as without `end`.
667    /// Line blocks that end exactly at `end` are counted, not searched.
668    #[inline(always)]
669    pub(crate) fn forward_fast_to(
670        &mut self,
671        end: Option<usize>,
672    ) -> Option<Result<(), error::ParseError>> {
673        self.lines.clear_peek();
674        if self.lines.pos >= self.lines.bytes.len() {
675            return None;
676        }
677        // Lines 1..=6 of the header are skipped wholesale.
678        if let Err(e) = self.advance_lines(6) {
679            return Some(Err(e));
680        }
681        // Line 7: natm_types.
682        let natm_types: usize = match self.read_line_str() {
683            Some(line) => match crate::parser::parse_count_line(line, 1) {
684                Ok(v) => v[0],
685                Err(e) => return Some(Err(e)),
686            },
687            None => return Some(Err(error::ParseError::IncompleteHeader)),
688        };
689        if natm_types == 0 {
690            // Lines 8 and 9 are whitespace, or absent at EOF. Do not scan
691            // for coordinates or a legacy velocity block (CON-count.1).
692            return match crate::parser::read_zero_atom_tail(|| self.read_line_str()) {
693                Ok(()) => self.note_skipped_frame(),
694                Err(e) => Some(Err(e)),
695            };
696        }
697        // Line 8: natms_per_type.
698        let natms_per_type: Vec<usize> = match self.read_line_str() {
699            Some(line) => match crate::parser::parse_count_line(line, natm_types) {
700                Ok(v) => v,
701                Err(e) => return Some(Err(e)),
702            },
703            None => return Some(Err(error::ParseError::IncompleteHeader)),
704        };
705        if natms_per_type.contains(&0) {
706            return Some(Err(error::ParseError::InvalidCount(
707                "each per-type count must be >= 1".into(),
708            )));
709        }
710        // Line 9: masses_per_type, consumed.
711        if let Err(e) = self.advance_lines(1) {
712            return Some(Err(e));
713        }
714        let total_atoms: usize = natms_per_type.iter().sum();
715        let coord_block_lines = total_atoms + natm_types * 2;
716        if let Err(e) = self.advance_lines_to(coord_block_lines, end) {
717            return Some(Err(e));
718        }
719        // Optional sections: blank line + same-shape block, repeated.
720        self.lines.clear_peek();
721        loop {
722            let rest = &self.lines.bytes[self.lines.pos..];
723            if rest.is_empty() {
724                break;
725            }
726            let next_eol = memchr::memchr(b'\n', rest);
727            let line = match next_eol {
728                Some(pos) => &rest[..pos],
729                None => rest,
730            };
731            let is_blank = line.iter().all(|b| matches!(b, b' ' | b'\t' | b'\r'));
732            if !is_blank {
733                break;
734            }
735            let after = match next_eol {
736                Some(pos) => &rest[pos + 1..],
737                None => &[][..],
738            };
739            // A blank line and then only whitespace is the end of the file,
740            // not a section (CON-frame.5).
741            if after.iter().all(|b| b.is_ascii_whitespace()) {
742                self.lines.pos = self.lines.bytes.len();
743                break;
744            }
745            // A blank that does not begin a section is line 1 of the next
746            // frame, including an empty user line (CON-frame.7, CON-header.4).
747            if !blank_opens_section(after) {
748                break;
749            }
750            // Consume the blank separator and the section block.
751            // The separator is a file line. Skipping it without counting
752            // shifts every later frame's reported line.
753            self.lines.pos += next_eol.map(|p| p + 1).unwrap_or(rest.len());
754            // The separator is a file line. The section block is counted by
755            // the advance, including the vectorized jump.
756            self.lines.line_no = self.lines.line_no.saturating_add(1);
757            if let Err(e) = self.advance_lines_to(coord_block_lines, end) {
758                return Some(Err(e));
759            }
760        }
761        self.note_skipped_frame()
762    }
763
764    /// Skips the next frame without fully parsing its atomic data.
765    ///
766    /// This is more efficient than `next()` if you only need to advance the
767    /// iterator. It reads the frame's header to determine how many lines to skip,
768    /// including any velocity section if present.
769    ///
770    /// # Returns
771    ///
772    /// * `Some(Ok(()))` on a successful skip.
773    /// * `Some(Err(ParseError::...))` if there's an error parsing the header.
774    /// * `None` if the iterator is already at the end.
775    pub fn forward(&mut self) -> Option<Result<(), error::ParseError>> {
776        // Prefer the shared memchr skip path (same cursor as full parse).
777        self.forward_fast()
778    }
779
780    /// Byte offset of the next frame start in the buffer passed to [`Self::new`].
781    pub fn byte_pos(&self) -> usize {
782        self.lines.pos
783    }
784
785    /// Skip `n` frames without parsing atom data. Returns how many were skipped.
786    /// Named `skip_frames` so it does not collide with [`Iterator::skip`].
787    pub fn skip_frames(&mut self, n: usize) -> Result<usize, error::ParseError> {
788        self.skip_frames_counted(n).map_err(|(_, err)| err)
789    }
790
791    /// Like [`Self::skip_frames`]. The error's first field is how many frames
792    /// were skipped before the failure, which is the 0-based index of the
793    /// failing frame when the iterator started at frame 0.
794    pub(crate) fn skip_frames_counted(
795        &mut self,
796        n: usize,
797    ) -> Result<usize, (usize, error::ParseError)> {
798        let mut skipped = 0usize;
799        for _ in 0..n {
800            match self.forward() {
801                None => break,
802                Some(Err(e)) => return Err((skipped, e)),
803                Some(Ok(())) => skipped += 1,
804            }
805        }
806        Ok(skipped)
807    }
808
809    /// Next frame plus the exact substring of the buffer passed to [`Self::new`].
810    ///
811    /// **Corpus ingest contract:** successive successful spans from the same
812    /// `file_contents` are contiguous (`end` of frame *i* equals `start` of frame
813    /// *i+1*) and, for a buffer that is only multi-frame CON (no prefix garbage),
814    /// concatenating all spans reproduces the trajectory text. Campaign stores
815    /// (`readcon-db`) must persist these spans as authoritative blobs—do not
816    /// re-serialize on the hot ingest path unless the caller supplied in-memory
817    /// [`types::ConFrame`] values without source text.
818    ///
819    /// See also [`crate::index_proj::frame_byte_spans`] and
820    /// [`crate::index_proj::spans_cover_buffer`].
821    pub fn next_with_raw_span(
822        &mut self,
823        file_contents: &'a str,
824    ) -> Option<Result<(types::ConFrame, &'a str), error::ParseError>> {
825        let base = file_contents.as_ptr() as usize;
826        let start = {
827            let line = self.lines.peek_line()?;
828            line.as_ptr() as usize - base
829        };
830        let frame = match self.next()? {
831            Ok(f) => f,
832            Err(e) => return Some(Err(e)),
833        };
834        let end = match self.lines.peek_line() {
835            Some(line) => line.as_ptr() as usize - base,
836            None => file_contents.len(),
837        };
838        debug_assert!(end >= start && end <= file_contents.len());
839        Some(Ok((frame, &file_contents[start..end])))
840    }
841
842    /// [`Iterator::next`] plus the byte offset where the frame ends.
843    pub(crate) fn next_with_end(
844        &mut self,
845    ) -> Option<Result<(types::ConFrame, usize), error::ParseError>> {
846        let frame = match self.next()? {
847            Ok(f) => f,
848            Err(e) => return Some(Err(e)),
849        };
850        let end = match self.lines.peek_line() {
851            Some(line) => line.as_ptr() as usize - self.lines.bytes.as_ptr() as usize,
852            None => self.lines.pos,
853        };
854        Some(Ok((frame, end)))
855    }
856}
857
858impl<'a> Iterator for ConFrameIterator<'a> {
859    /// The type of item yielded by the iterator.
860    ///
861    /// Each item is a `Result` that contains a successfully parsed `ConFrame` or a
862    /// `ParseError` if the frame's data is malformed.
863    type Item = Result<types::ConFrame, error::ParseError>;
864
865    /// Advances the iterator and attempts to parse the next frame.
866    ///
867    /// This method will return `None` only when there are no more lines to consume.
868    /// If there are lines but they do not form a complete frame, it will return
869    /// `Some(Err(ParseError::...))`. Invalid UTF-8 is a validation error, not
870    /// the end of the stream.
871    fn next(&mut self) -> Option<Self::Item> {
872        // A line rejected earlier in this cursor (for example while probing
873        // for a velocity block) must not be reported as a clean EOF.
874        if let Some(err) = self.lines.utf8_error() {
875            return Some(Err(err));
876        }
877        // Trailing whitespace after the last complete frame is not a frame
878        // (CON-frame.1, D2). An empty line that is followed by more text is
879        // line 1 of the next frame (CON-frame.7).
880        if self.lines.remainder_is_whitespace() {
881            if let Some(err) = self.lines.utf8_error() {
882                return Some(Err(err));
883            }
884            while self.lines.next_line().is_some() {}
885            return None;
886        }
887        if let Some(err) = self.lines.utf8_error() {
888            return Some(Err(err));
889        }
890        // Otherwise, attempt to parse the next frame from the available lines.
891        let mut frame = match crate::parser::parse_single_frame_ls(&mut self.lines) {
892            Ok(f) => f,
893            Err(e) => return Some(Err(self.lines.prefer_utf8(e))),
894        };
895        // Optional sections mutate AoS; only re-sync section SoA when needed.
896        // Plain .con assembly already filled positions/ids/masses (no O(N)
897        // post-scan when no velocity/force sections were applied).
898        let sections =
899            match parse_declared_sections(&mut self.lines, &mut frame.header, &mut frame.atom_data)
900            {
901                Ok(n) => n,
902                // A section line that failed UTF-8 makes `next_line` return
903                // `None`. That must not surface as `IncompleteSection`.
904                Err(e) => return Some(Err(self.lines.prefer_utf8(e))),
905            };
906        if sections > 0 {
907            // One flat fill per section that was actually parsed. The full
908            // sync walks every absent section and writes each row through
909            // ArcArray uniqueness checks.
910            frame.install_declared_section_soa();
911        }
912        self.frame_index = self.frame_index.saturating_add(1);
913        Some(Ok(frame))
914    }
915}
916
917#[cfg(test)]
918mod aos_soa_agreement_tests {
919    use super::*;
920    use std::path::PathBuf;
921
922    #[test]
923    fn iterator_vel_forces_soa_matches_aos() {
924        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
925            .join("resources/test/tiny_cuh2_vel_forces.con");
926        let text = std::fs::read_to_string(&p).expect("fixture");
927        let fr = ConFrameIterator::new(&text)
928            .next()
929            .expect("frame")
930            .expect("parse");
931        let n = fr.atom_data.len();
932        assert!(n > 0);
933        assert_eq!(fr.positions.nrows(), n);
934        let has_vel = fr.atom_data.iter().any(|a| a.velocity().is_some());
935        let has_frc = fr.atom_data.iter().any(|a| a.force().is_some());
936        if has_vel {
937            assert_eq!(
938                fr.velocities.nrows(),
939                n,
940                "SoA velocities must match AoS after section parse"
941            );
942        }
943        if has_frc {
944            assert_eq!(fr.forces.nrows(), n, "SoA forces must match AoS");
945        }
946        for (i, a) in fr.atom_data.iter().enumerate() {
947            let p = fr.positions.as_f64_row(i);
948            assert_eq!([a.x, a.y, a.z], p);
949            if let Some(v) = a.velocity() {
950                assert_eq!(v, fr.velocities.as_f64_row(i));
951            }
952            if let Some(f) = a.force() {
953                assert_eq!(f, fr.forces.as_f64_row(i));
954            }
955        }
956    }
957
958    /// After SoA-primary parse, section sync must not require rewriting positions
959    /// (nrows already equals N); forces SoA still filled from AoS.
960    #[test]
961    fn sync_skips_pos_when_nrows_matches_keeps_force_soa() {
962        let p =
963            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2_forces.con");
964        let text = std::fs::read_to_string(&p).expect("fixture");
965        let fr = ConFrameIterator::new(&text)
966            .next()
967            .expect("frame")
968            .expect("parse");
969        let n = fr.atom_data.len();
970        assert_eq!(fr.positions.nrows(), n);
971        assert_eq!(fr.forces.nrows(), n);
972        // Snapshot first position SoA row then re-sync; coords must stay bit-identical
973        // (no needless rewrite would change nothing but we still require agreement).
974        let p0 = fr.positions.as_f64_row(0);
975        let mut fr2 = fr.clone();
976        fr2.sync_arrays_from_atom_data();
977        assert_eq!(fr2.positions.as_f64_row(0), p0);
978        assert_eq!(fr2.forces.nrows(), n);
979        assert_eq!(
980            fr2.forces.as_f64_row(0),
981            fr2.atom_data[0].force().expect("force")
982        );
983    }
984
985    /// Section SoA is filled from one flat buffer. Every value must match the
986    /// per-atom tail, including past a small-vector boundary.
987    #[test]
988    fn declared_sections_soa_matches_every_atom() {
989        let n = 400;
990        let mut text = String::new();
991        text.push_str("soa fill\n");
992        text.push_str(
993            "{\"con_spec_version\":2,\"sections\":[\"velocities\",\"forces\",\"energies\"]}\n",
994        );
995        text.push_str("10.0 10.0 10.0\n90.0 90.0 90.0\n0 0\n0 0 0\n1\n");
996        text.push_str(&format!("{n}\n63.546\nCu\nCoordinates of Component 1\n"));
997        for i in 0..n {
998            let x = (i as f64) * 0.125 - 3.5;
999            let y = (i as f64) * -0.05 + 1.0;
1000            let z = (i % 17) as f64 * 0.333;
1001            text.push_str(&format!("{x:.8} {y:.8} {z:.8} 0 {i}\n"));
1002        }
1003        text.push_str("\nCu\nVelocities of Component 1\n");
1004        for i in 0..n {
1005            let vx = (i as f64) * 1.0e-4 - 0.02;
1006            let vy = ((i * 3) % 11) as f64 * -1.0e-3;
1007            let vz = (i as f64).sin() * 0.01;
1008            text.push_str(&format!("{vx:.8} {vy:.8} {vz:.8} 0 {i}\n"));
1009        }
1010        text.push_str("\nCu\nForces of Component 1\n");
1011        for i in 0..n {
1012            let fx = (i as f64) * 0.01;
1013            let fy = -fx / 2.0;
1014            let fz = (i % 5) as f64;
1015            text.push_str(&format!("{fx:.8} {fy:.8} {fz:.8} 0 {i}\n"));
1016        }
1017        text.push_str("\nCu\nEnergies of Component 1\n");
1018        for i in 0..n {
1019            let e = (i as f64) * 0.02 - 4.0;
1020            text.push_str(&format!("{e:.8} 0 {i}\n"));
1021        }
1022        let frame = ConFrameIterator::new(&text)
1023            .next()
1024            .expect("frame")
1025            .expect("parse");
1026        assert_eq!(frame.atom_data.len(), n);
1027        assert_eq!(frame.velocities.nrows(), n);
1028        assert_eq!(frame.forces.nrows(), n);
1029        assert_eq!(frame.atom_energies.len(), n);
1030        assert_eq!(frame.positions.nrows(), n);
1031        assert!(frame.magmoms.nrows() == 0);
1032        for i in 0..n {
1033            let atom = &frame.atom_data[i];
1034            let pos = frame.positions.as_f64_row(i);
1035            assert_eq!(pos[0].to_bits(), atom.x.to_bits());
1036            assert_eq!(pos[1].to_bits(), atom.y.to_bits());
1037            assert_eq!(pos[2].to_bits(), atom.z.to_bits());
1038            let vel = atom.velocity().unwrap();
1039            let soa_v = frame.velocities.as_f64_row(i);
1040            assert_eq!(soa_v[0].to_bits(), vel[0].to_bits());
1041            assert_eq!(soa_v[1].to_bits(), vel[1].to_bits());
1042            assert_eq!(soa_v[2].to_bits(), vel[2].to_bits());
1043            let frc = atom.force().unwrap();
1044            let soa_f = frame.forces.as_f64_row(i);
1045            assert_eq!(soa_f[0].to_bits(), frc[0].to_bits());
1046            assert_eq!(soa_f[1].to_bits(), frc[1].to_bits());
1047            assert_eq!(soa_f[2].to_bits(), frc[2].to_bits());
1048            assert_eq!(
1049                frame.atom_energies.get_f64(i).to_bits(),
1050                atom.energy().unwrap().to_bits()
1051            );
1052            assert_eq!(frame.atom_ids[i], atom.atom_id);
1053        }
1054    }
1055}
1056
1057/// Reads all frames from a file.
1058///
1059/// The bytes are read into one owned buffer and parsed on the calling
1060/// thread. A worker pool runs only when [`read_all_frames_with_threads`]
1061/// is asked for two or more threads.
1062pub fn read_all_frames(path: &Path) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
1063    read_all_frames_with_threads(path, None)
1064}
1065
1066/// Collect every frame from `text`.
1067///
1068/// `None`, `Some(0)`, and `Some(1)` parse on the calling thread.
1069/// `Some(n)` with `n >= 2` uses a Rayon pool of that size when the
1070/// `parallel` feature is on. Builds without `parallel` always parse
1071/// sequentially.
1072pub fn frames_from_text(
1073    text: &str,
1074    num_threads: Option<usize>,
1075) -> Result<Vec<types::ConFrame>, error::ParseError> {
1076    #[cfg(feature = "parallel")]
1077    {
1078        if let Some(n) = num_threads.filter(|n| *n >= 2) {
1079            let parts = parse_frames_parallel_with_threads(text, Some(n));
1080            let mut frames = Vec::with_capacity(parts.len());
1081            for r in parts {
1082                frames.push(r?);
1083            }
1084            return Ok(frames);
1085        }
1086    }
1087    #[cfg(not(feature = "parallel"))]
1088    {
1089        let _ = num_threads;
1090    }
1091    ConFrameIterator::new(text).collect()
1092}
1093
1094/// Like [`read_all_frames`], with an explicit worker count.
1095///
1096/// `None`, `Some(0)`, and `Some(1)` parse on the calling thread.
1097/// `Some(n)` with `n >= 2` pins a Rayon pool when `parallel` is on.
1098///
1099/// A parse failure is the [`error::LocatedParseError`] from that parse.
1100/// Its line is the cursor that failed and its frame is the 0-based index.
1101/// The path is not opened again.
1102///
1103/// A plain file of 64 KiB or more stays mapped. Smaller files and
1104/// compressed input are read into one buffer.
1105pub fn read_all_frames_with_threads(
1106    path: &Path,
1107    num_threads: Option<usize>,
1108) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
1109    let bytes = crate::compression::read_bytes_owned(path)?;
1110    Ok(frames_from_bytes_located(bytes.as_slice(), num_threads)?)
1111}
1112
1113/// Positions of every frame, one row-major `f64` buffer.
1114///
1115/// A parse failure is the [`error::LocatedParseError`] from the boundary
1116/// scan or the span parse that failed. The path is not opened again.
1117///
1118/// The boundary walk records each frame start. Each worker then parses
1119/// only its span and writes that frame's coordinates at
1120/// `frame * n_atoms * 3`. The returned buffer is the only retained
1121/// coordinate storage. A file whose frames do not share one atom count
1122/// is rejected, because the buffer is rectangular.
1123pub struct PositionBlock {
1124    pub n_frames: usize,
1125    pub n_atoms: usize,
1126    pub values: Vec<f64>,
1127}
1128
1129pub fn read_positions_with_threads(
1130    path: &Path,
1131    num_threads: Option<usize>,
1132) -> Result<PositionBlock, Box<dyn std::error::Error>> {
1133    let contents = crate::compression::read_file_contents(path)?;
1134    let block = match &contents {
1135        crate::compression::FileContents::Owned(text) => {
1136            fill_position_block(text.as_bytes(), num_threads)
1137        }
1138        crate::compression::FileContents::Mapped(map) => fill_position_block(map, num_threads),
1139    }?;
1140    Ok(block)
1141}
1142
1143/// 1-based file line of the line that contains `byte`.
1144fn file_line_at_bytes(bytes: &[u8], byte: usize) -> u32 {
1145    let end = byte.min(bytes.len());
1146    1 + memchr::memchr_iter(b'\n', &bytes[..end]).count() as u32
1147}
1148
1149/// File line of a cursor that counted from the start of a share.
1150///
1151/// `local` is 1-based inside the share. A cursor that never moved still
1152/// names the share's first line.
1153fn share_cursor(local: u32, bytes: &[u8], share_start: usize) -> u32 {
1154    let base = file_line_at_bytes(bytes, share_start);
1155    if local == 0 {
1156        base
1157    } else {
1158        base.saturating_add(local.saturating_sub(1))
1159    }
1160}
1161
1162fn frame_starts(bytes: &[u8]) -> Result<Vec<usize>, error::LocatedParseError> {
1163    let mut scanner = ConFrameIterator::from_bytes(bytes);
1164    let mut boundaries = Vec::new();
1165    loop {
1166        scanner.lines.clear_peek();
1167        let start = scanner.lines.pos;
1168        if start >= bytes.len() || bytes[start..].iter().all(u8::is_ascii_whitespace) {
1169            break;
1170        }
1171        boundaries.push(start);
1172        match scanner.forward_fast() {
1173            Some(Ok(())) => {}
1174            Some(Err(err)) => {
1175                return Err(error::LocatedParseError::new(
1176                    err,
1177                    scanner.lines.line_no(),
1178                    scanner.frame_index(),
1179                ));
1180            }
1181            None => break,
1182        }
1183    }
1184    Ok(boundaries)
1185}
1186
1187/// Parse one span. The error keeps this frame's file line.
1188fn parse_span_located(
1189    bytes: &[u8],
1190    start: usize,
1191    end: usize,
1192    frame_index: u64,
1193) -> Result<types::ConFrame, error::LocatedParseError> {
1194    let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
1195    match iter.next() {
1196        Some(Ok(frame)) => Ok(frame),
1197        Some(Err(err)) => Err(error::LocatedParseError::new(
1198            err,
1199            share_cursor(iter.lines.line_no(), bytes, start),
1200            frame_index,
1201        )),
1202        None => Err(error::LocatedParseError::new(
1203            error::ParseError::IncompleteFrame,
1204            file_line_at_bytes(bytes, start),
1205            frame_index,
1206        )),
1207    }
1208}
1209
1210fn copy_positions(frame: &types::ConFrame, dest: &mut [f64]) -> Result<(), error::ParseError> {
1211    if frame.positions.nrows().saturating_mul(3) != dest.len() {
1212        return Err(error::ParseError::InvalidCount(
1213            "frame atom count does not match the first frame".into(),
1214        ));
1215    }
1216    if let Some(src) = frame.positions.f64_slice() {
1217        dest.copy_from_slice(src);
1218        return Ok(());
1219    }
1220    for (i, slot) in dest.chunks_exact_mut(3).enumerate() {
1221        slot.copy_from_slice(&frame.positions.as_f64_row(i));
1222    }
1223    Ok(())
1224}
1225
1226fn fill_position_block(
1227    bytes: &[u8],
1228    num_threads: Option<usize>,
1229) -> Result<PositionBlock, error::LocatedParseError> {
1230    let boundaries = frame_starts(bytes)?;
1231    if boundaries.is_empty() {
1232        return Ok(PositionBlock {
1233            n_frames: 0,
1234            n_atoms: 0,
1235            values: Vec::new(),
1236        });
1237    }
1238    let end0 = boundaries.get(1).copied().unwrap_or(bytes.len());
1239    let first = parse_span_located(bytes, boundaries[0], end0, 0)?;
1240    let n_atoms = first.positions.nrows();
1241    let n_frames = boundaries.len();
1242    let stride = n_atoms.saturating_mul(3);
1243    let mut values = vec![0.0_f64; n_frames.saturating_mul(stride)];
1244    if stride == 0 {
1245        return Ok(PositionBlock {
1246            n_frames,
1247            n_atoms,
1248            values,
1249        });
1250    }
1251    fill_position_spans(bytes, &boundaries, n_atoms, num_threads, &mut values)?;
1252    Ok(PositionBlock {
1253        n_frames,
1254        n_atoms,
1255        values,
1256    })
1257}
1258
1259fn fill_position_spans(
1260    bytes: &[u8],
1261    boundaries: &[usize],
1262    n_atoms: usize,
1263    num_threads: Option<usize>,
1264    values: &mut [f64],
1265) -> Result<(), error::LocatedParseError> {
1266    let stride = n_atoms * 3;
1267    let write_one = |i: usize, dest: &mut [f64]| {
1268        let start = boundaries[i];
1269        let end = boundaries.get(i + 1).copied().unwrap_or(bytes.len());
1270        let frame = parse_span_located(bytes, start, end, i as u64)?;
1271        copy_positions(&frame, dest).map_err(|error| {
1272            error::LocatedParseError::new(error, file_line_at_bytes(bytes, start), i as u64)
1273        })
1274    };
1275    #[cfg(feature = "parallel")]
1276    {
1277        let parallel = !matches!(num_threads, Some(n) if n <= 1);
1278        if parallel {
1279            use rayon::prelude::*;
1280            // The serial walk reports the earliest failing span. A pool
1281            // that stops on the first finished failure can hand back a
1282            // later span of the same file. `find_map_first` keeps the
1283            // earliest slot and still stops spans to its right.
1284            let mut run = || match values
1285                .par_chunks_mut(stride)
1286                .enumerate()
1287                .find_map_first(|(i, dest)| write_one(i, dest).err())
1288            {
1289                Some(err) => Err(err),
1290                None => Ok(()),
1291            };
1292            return match num_threads {
1293                Some(n) if n >= 2 => {
1294                    let pool = rayon::ThreadPoolBuilder::new()
1295                        .num_threads(n)
1296                        .build()
1297                        .map_err(|err| {
1298                            error::LocatedParseError::new(
1299                                error::ParseError::ValidationError(err.to_string()),
1300                                0,
1301                                0,
1302                            )
1303                        })?;
1304                    pool.install(run)
1305                }
1306                _ => run(),
1307            };
1308        }
1309    }
1310    #[cfg(not(feature = "parallel"))]
1311    {
1312        let _ = num_threads;
1313    }
1314    for (i, dest) in values.chunks_mut(stride).enumerate() {
1315        write_one(i, dest)?;
1316    }
1317    Ok(())
1318}
1319
1320/// [`frames_from_text`] for bytes that may not be UTF-8 yet.
1321pub fn frames_from_bytes(
1322    bytes: &[u8],
1323    num_threads: Option<usize>,
1324) -> Result<Vec<types::ConFrame>, error::ParseError> {
1325    #[cfg(feature = "parallel")]
1326    {
1327        if let Some(n) = num_threads.filter(|n| *n >= 2) {
1328            let parts = parse_frames_parallel_bytes(bytes, Some(n));
1329            let mut frames = Vec::with_capacity(parts.len());
1330            for r in parts {
1331                frames.push(r?);
1332            }
1333            return Ok(frames);
1334        }
1335    }
1336    #[cfg(not(feature = "parallel"))]
1337    {
1338        let _ = num_threads;
1339    }
1340    let mut frames = Vec::new();
1341    for r in ConFrameIterator::from_bytes(bytes) {
1342        frames.push(r?);
1343    }
1344    Ok(frames)
1345}
1346
1347/// [`frames_from_bytes`] that keeps the line and frame of the first error.
1348fn frames_from_bytes_located(
1349    bytes: &[u8],
1350    num_threads: Option<usize>,
1351) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
1352    #[cfg(feature = "parallel")]
1353    {
1354        if let Some(n) = num_threads.filter(|n| *n >= 2) {
1355            let timed = parse_bytes_timed(bytes, Some(n));
1356            return collect_located_slots(timed.slots, &timed.fail_lines);
1357        }
1358    }
1359    #[cfg(not(feature = "parallel"))]
1360    {
1361        let _ = num_threads;
1362    }
1363    collect_bytes_located(bytes)
1364}
1365
1366/// Serial byte parse. The cursor is the iterator's file line.
1367fn collect_bytes_located(bytes: &[u8]) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
1368    let mut frames = Vec::new();
1369    let mut iter = ConFrameIterator::from_bytes(bytes);
1370    while let Some(item) = iter.next() {
1371        match item {
1372            Ok(frame) => frames.push(frame),
1373            Err(error) => {
1374                return Err(error::LocatedParseError::new(
1375                    error,
1376                    iter.lines.line_no(),
1377                    iter.frame_index(),
1378                ));
1379            }
1380        }
1381    }
1382    Ok(frames)
1383}
1384
1385/// First failed slot, with the file line its worker stored.
1386#[cfg(feature = "parallel")]
1387fn collect_located_slots(
1388    slots: Vec<Result<types::ConFrame, error::ParseError>>,
1389    fail_lines: &[(usize, u32)],
1390) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
1391    let mut frames = Vec::with_capacity(slots.len());
1392    for (index, part) in slots.into_iter().enumerate() {
1393        match part {
1394            Ok(frame) => frames.push(frame),
1395            Err(error) => {
1396                let line = fail_lines
1397                    .iter()
1398                    .find(|(slot, _)| *slot == index)
1399                    .map(|(_, line)| *line)
1400                    .unwrap_or(0);
1401                return Err(error::LocatedParseError::new(error, line, index as u64));
1402            }
1403        }
1404    }
1405    Ok(frames)
1406}
1407
1408#[cfg(feature = "parallel")]
1409fn span_end(boundaries: &[usize], index: usize, len: usize) -> usize {
1410    if index + 1 < boundaries.len() {
1411        boundaries[index + 1]
1412    } else {
1413        len
1414    }
1415}
1416
1417/// Pointer to slot storage shared by the workers that claim chunks.
1418///
1419/// Each claimed index is written by one thread, so the alias is the
1420/// partition. The pointer stays valid until those workers join.
1421#[cfg(feature = "parallel")]
1422#[derive(Copy, Clone)]
1423struct SlotWritePtr(*mut std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>);
1424
1425#[cfg(feature = "parallel")]
1426impl SlotWritePtr {
1427    /// # Safety
1428    /// `index` is in range and this call is the only write of that slot.
1429    unsafe fn write_slot(self, index: usize, value: Result<types::ConFrame, error::ParseError>) {
1430        unsafe {
1431            (*self.0.add(index)).write(value);
1432        }
1433    }
1434}
1435
1436// SAFETY: `SlotWritePtr` is copied into the claim loop, and that loop
1437// writes each slot index from exactly one worker. The allocation
1438// outlives the join.
1439#[cfg(feature = "parallel")]
1440unsafe impl Send for SlotWritePtr {}
1441
1442// SAFETY: same partition as `Send`. Workers do not read a slot another
1443// worker is writing.
1444#[cfg(feature = "parallel")]
1445unsafe impl Sync for SlotWritePtr {}
1446
1447/// Minor faults of the calling thread.
1448///
1449/// `RUSAGE_THREAD` is Linux. Other hosts report 0; the counter is
1450/// diagnostic and the parse result does not depend on it.
1451#[cfg(feature = "parallel")]
1452fn thread_minflt() -> i64 {
1453    #[cfg(target_os = "linux")]
1454    {
1455        unsafe {
1456            let mut usage = std::mem::zeroed::<libc::rusage>();
1457            libc::getrusage(libc::RUSAGE_THREAD, &mut usage);
1458            usage.ru_minflt
1459        }
1460    }
1461    #[cfg(not(target_os = "linux"))]
1462    {
1463        0
1464    }
1465}
1466
1467/// Hardware LLC-miss counter for this process.
1468///
1469/// `-1` means the guest refused the event. This KVM guest returns
1470/// `EPERM` for `perf_event_open`, so the parse explanation uses the
1471/// per-worker minor faults instead.
1472#[cfg(feature = "parallel")]
1473fn sample_llc_misses() -> i64 {
1474    // `perf_event_open` is Linux. `-1` is the existing "counter refused" value.
1475    #[cfg(not(target_os = "linux"))]
1476    {
1477        return -1;
1478    }
1479    #[cfg(target_os = "linux")]
1480    {
1481        #[repr(C)]
1482        struct PerfEventAttr {
1483            typ: u32,
1484            size: u32,
1485            config: u64,
1486            rest: [u64; 14],
1487        }
1488        let attr = PerfEventAttr {
1489            typ: 3,
1490            size: std::mem::size_of::<PerfEventAttr>() as u32,
1491            // PERF_COUNT_HW_CACHE_LL, OP_READ, RESULT_MISS.
1492            config: 2 | (1 << 16),
1493            rest: [0; 14],
1494        };
1495        let fd = unsafe {
1496            libc::syscall(
1497                libc::SYS_perf_event_open,
1498                &attr as *const PerfEventAttr,
1499                0i32,
1500                -1i32,
1501                -1i32,
1502                0u64,
1503            )
1504        };
1505        if fd < 0 {
1506            return -1;
1507        }
1508        let fd = fd as libc::c_int;
1509        let mut value = 0u64;
1510        let n = unsafe { libc::read(fd, &mut value as *mut u64 as *mut libc::c_void, 8) };
1511        unsafe {
1512            libc::close(fd);
1513        }
1514        if n == 8 { value as i64 } else { -1 }
1515    }
1516}
1517
1518/// Pages that do not sit on node 0.
1519///
1520/// A guest with one memory node has no remote pages. The count is 0
1521/// in that case. A wider node list sums the pages `numa_maps` records
1522/// on every node other than 0.
1523#[cfg(feature = "parallel")]
1524fn numa_remote_pages() -> i64 {
1525    let online = std::fs::read_to_string("/sys/devices/system/node/online").unwrap_or_default();
1526    let online = online.trim();
1527    let nodes = if let Some((start, end)) = online.split_once('-') {
1528        let start: i64 = start.parse().unwrap_or(0);
1529        let end: i64 = end.parse().unwrap_or(0);
1530        end - start + 1
1531    } else {
1532        1
1533    };
1534    if nodes <= 1 {
1535        return 0;
1536    }
1537    let text = std::fs::read_to_string("/proc/self/numa_maps").unwrap_or_default();
1538    let mut remote = 0i64;
1539    for line in text.lines() {
1540        for token in line.split_whitespace() {
1541            if let Some(rest) = token.strip_prefix('N') {
1542                if let Some((node, pages)) = rest.split_once('=') {
1543                    if node != "0" {
1544                        remote += pages.parse::<i64>().unwrap_or(0);
1545                    }
1546                }
1547            }
1548        }
1549    }
1550    remote
1551}
1552
1553/// Contiguous frame ranges that cover `boundaries` and split the file
1554/// into similar byte lengths.
1555///
1556/// There are up to four ranges per worker, and never more ranges than
1557/// frames. Each range holds at least one frame. The cuts stay on frame
1558/// starts, so a worker parses a whole frame.
1559#[cfg(feature = "parallel")]
1560fn byte_balanced_ranges(boundaries: &[usize], len: usize, workers: usize) -> Vec<(usize, usize)> {
1561    let n = boundaries.len();
1562    if n == 0 {
1563        return Vec::new();
1564    }
1565    let workers = workers.max(1).min(n);
1566    let chunks = workers.saturating_mul(4).clamp(workers, n);
1567    let origin = boundaries[0];
1568    let total = len.saturating_sub(origin).max(1) as u128;
1569    let mut ranges = Vec::with_capacity(chunks);
1570    let mut start = 0usize;
1571    for chunk in 0..chunks {
1572        if start >= n {
1573            break;
1574        }
1575        let end = if chunk + 1 == chunks {
1576            n
1577        } else {
1578            let target = origin + ((total * (chunk as u128 + 1)) / chunks as u128) as usize;
1579            let mut idx = if start + 1 < n {
1580                boundaries[start + 1..].partition_point(|&b| b < target) + start + 1
1581            } else {
1582                n
1583            };
1584            let min_end = start + 1;
1585            let reserved = chunks - chunk - 1;
1586            let max_end = n.saturating_sub(reserved);
1587            if max_end >= min_end {
1588                idx = idx.clamp(min_end, max_end);
1589            } else {
1590                idx = min_end.min(n);
1591            }
1592            idx
1593        };
1594        if end > start {
1595            ranges.push((start, end));
1596            start = end;
1597        }
1598    }
1599    if start < n {
1600        match ranges.last_mut() {
1601            Some(last) => last.1 = n,
1602            None => ranges.push((start, n)),
1603        }
1604    }
1605    ranges
1606}
1607
1608/// Parse each frame into a slot allocated before any worker runs.
1609///
1610/// Workers claim chunk indexes from an atomic counter and write only
1611/// those slots. The returned vector is that slot storage, in boundary
1612/// order, which is the serial iterator order. `workers < 2` parses on
1613/// the calling thread.
1614#[cfg(feature = "parallel")]
1615fn parse_into_slots<F>(
1616    bytes: &[u8],
1617    len: usize,
1618    boundaries: &[usize],
1619    workers: usize,
1620    parse_span: F,
1621) -> Vec<Result<types::ConFrame, error::ParseError>>
1622where
1623    F: Fn(usize, usize) -> Result<types::ConFrame, error::ParseError> + Sync,
1624{
1625    parse_into_slots_timed(bytes, len, boundaries, workers, parse_span).slots
1626}
1627
1628/// Slot allocation and the worker parse, timed apart.
1629#[cfg(feature = "parallel")]
1630struct SlotParse {
1631    slots: Vec<Result<types::ConFrame, error::ParseError>>,
1632    alloc_s: f64,
1633    work_s: f64,
1634    sum_s: f64,
1635    max_s: f64,
1636    /// One entry per worker range, in worker order. This is CPU time.
1637    chunk_s: Vec<f64>,
1638    /// Minor faults on each worker during its range.
1639    worker_minflt: Vec<i64>,
1640    /// Frames in each worker's share.
1641    share_frames: Vec<u64>,
1642    /// File bytes in each worker's share.
1643    share_bytes: Vec<u64>,
1644    /// `(slot, file line)` for each parse that failed. The line is the
1645    /// cursor of that parse, not a later reread.
1646    fail_lines: Vec<(usize, u32)>,
1647}
1648
1649/// Slot allocation and the worker parse, timed apart.
1650///
1651/// `alloc_s` is the slot vector. `work_s` is the parse wall. `sum_s` is
1652/// the sum of per-chunk parse times and `max_s` is the slowest chunk.
1653/// `chunk_s` keeps every chunk. `workers < 2` still reserves the slot
1654/// vector before the calling thread fills it, and that one chunk's time
1655/// is the sum, the max, and the only entry.
1656#[cfg(feature = "parallel")]
1657fn parse_into_slots_timed<F>(
1658    bytes: &[u8],
1659    len: usize,
1660    boundaries: &[usize],
1661    workers: usize,
1662    parse_span: F,
1663) -> SlotParse
1664where
1665    F: Fn(usize, usize) -> Result<types::ConFrame, error::ParseError> + Sync,
1666{
1667    let n = boundaries.len();
1668    if n == 0 {
1669        return SlotParse {
1670            slots: Vec::new(),
1671            alloc_s: 0.0,
1672            work_s: 0.0,
1673            sum_s: 0.0,
1674            max_s: 0.0,
1675            chunk_s: Vec::new(),
1676            worker_minflt: Vec::new(),
1677            share_frames: Vec::new(),
1678            share_bytes: Vec::new(),
1679            fail_lines: Vec::new(),
1680        };
1681    }
1682    if workers < 2 {
1683        let t_alloc = std::time::Instant::now();
1684        let mut slots = Vec::with_capacity(n);
1685        let alloc_s = t_alloc.elapsed().as_secs_f64();
1686        let flt0 = thread_minflt();
1687        let t_work = std::time::Instant::now();
1688        for i in 0..n {
1689            slots.push(parse_span(boundaries[i], span_end(boundaries, i, len)));
1690        }
1691        let work_s = t_work.elapsed().as_secs_f64();
1692        let worker_minflt = vec![thread_minflt().saturating_sub(flt0)];
1693        return SlotParse {
1694            slots,
1695            alloc_s,
1696            work_s,
1697            sum_s: work_s,
1698            max_s: work_s,
1699            chunk_s: vec![work_s],
1700            worker_minflt,
1701            share_frames: vec![n as u64],
1702            share_bytes: vec![len as u64],
1703            fail_lines: Vec::new(),
1704        };
1705    }
1706    let t_alloc = std::time::Instant::now();
1707    let mut slots = take_slot_buf(n);
1708    let alloc_s = t_alloc.elapsed().as_secs_f64();
1709    // Several byte-balanced ranges per worker. A slow range can be
1710    // stolen. Each range is still one iterator, not one iterator per
1711    // frame: a fresh iterator per frame faulted a private heap.
1712    let ranges = byte_balanced_ranges(boundaries, len, workers);
1713    let chunk_ns: Vec<std::sync::atomic::AtomicU64> = (0..ranges.len())
1714        .map(|_| std::sync::atomic::AtomicU64::new(0))
1715        .collect();
1716    let chunk_flt: Vec<std::sync::atomic::AtomicI64> = (0..ranges.len())
1717        .map(|_| std::sync::atomic::AtomicI64::new(0))
1718        .collect();
1719    let slots_ptr = SlotWritePtr(slots.as_mut_ptr());
1720    let fail_lines = std::sync::Mutex::new(Vec::<(usize, u32)>::new());
1721    let t_work = std::time::Instant::now();
1722    {
1723        let parse_range = |range_index: usize, start: usize, end: usize| {
1724            if start >= end {
1725                return;
1726            }
1727            let flt0 = thread_minflt();
1728            let t_chunk = std::time::Instant::now();
1729            let byte_start = boundaries[start];
1730            let byte_end = span_end(boundaries, end - 1, len);
1731            let mut iter = ConFrameIterator::from_bytes(&bytes[byte_start..byte_end]);
1732            for i in start..end {
1733                let frame = match iter.next() {
1734                    Some(Ok(frame)) => Ok(frame),
1735                    Some(Err(error)) => {
1736                        let line = share_cursor(iter.lines.line_no(), bytes, byte_start);
1737                        fail_lines.lock().expect("fail lines").push((i, line));
1738                        Err(error)
1739                    }
1740                    None => {
1741                        let line = share_cursor(iter.lines.line_no(), bytes, byte_start);
1742                        fail_lines.lock().expect("fail lines").push((i, line));
1743                        Err(error::ParseError::IncompleteFrame)
1744                    }
1745                };
1746                // SAFETY: this range owns `start..end`, so `i` has one
1747                // writer. `slots` outlives the join.
1748                unsafe {
1749                    slots_ptr.write_slot(i, frame);
1750                }
1751            }
1752            chunk_ns[range_index].store(
1753                t_chunk.elapsed().as_nanos() as u64,
1754                std::sync::atomic::Ordering::Relaxed,
1755            );
1756            chunk_flt[range_index].store(
1757                thread_minflt().saturating_sub(flt0),
1758                std::sync::atomic::Ordering::Relaxed,
1759            );
1760        };
1761        let run_ranges = || {
1762            use rayon::prelude::*;
1763            ranges
1764                .par_iter()
1765                .enumerate()
1766                .for_each(|(range_index, &(start, end))| {
1767                    parse_range(range_index, start, end);
1768                });
1769        };
1770        if rayon::current_thread_index().is_some() {
1771            run_ranges();
1772        } else {
1773            match rayon::ThreadPoolBuilder::new().num_threads(workers).build() {
1774                Ok(pool) => pool.install(run_ranges),
1775                Err(_) => {
1776                    for (range_index, &(start, end)) in ranges.iter().enumerate() {
1777                        parse_range(range_index, start, end);
1778                    }
1779                }
1780            }
1781        }
1782    }
1783    let work_s = t_work.elapsed().as_secs_f64();
1784    let mut worker_sum_s = 0.0_f64;
1785    let mut worker_max_s = 0.0_f64;
1786    let mut chunk_s = Vec::new();
1787    let mut worker_minflt = Vec::new();
1788    let mut share_frames = Vec::new();
1789    let mut share_bytes = Vec::new();
1790    for (range_index, &(start, end)) in ranges.iter().enumerate() {
1791        if start >= end {
1792            continue;
1793        }
1794        let seconds =
1795            chunk_ns[range_index].load(std::sync::atomic::Ordering::Relaxed) as f64 / 1.0e9;
1796        chunk_s.push(seconds);
1797        worker_sum_s += seconds;
1798        worker_max_s = worker_max_s.max(seconds);
1799        worker_minflt.push(chunk_flt[range_index].load(std::sync::atomic::Ordering::Relaxed));
1800        share_frames.push((end - start) as u64);
1801        let nbytes = span_end(boundaries, end - 1, len).saturating_sub(boundaries[start]);
1802        share_bytes.push(nbytes as u64);
1803    }
1804    // Each index was written once. Reinterpreting the buffer avoids a
1805    // second serial walk of the frame shells after the workers join.
1806    let slots = unsafe {
1807        let mut raw = std::mem::ManuallyDrop::new(slots);
1808        Vec::from_raw_parts(
1809            raw.as_mut_ptr() as *mut Result<types::ConFrame, error::ParseError>,
1810            n,
1811            raw.capacity(),
1812        )
1813    };
1814    let fail_lines = fail_lines.into_inner().unwrap_or_default();
1815    SlotParse {
1816        slots,
1817        alloc_s,
1818        work_s,
1819        sum_s: worker_sum_s,
1820        max_s: worker_max_s,
1821        chunk_s,
1822        worker_minflt,
1823        share_frames,
1824        share_bytes,
1825        fail_lines,
1826    }
1827}
1828
1829#[cfg(feature = "parallel")]
1830fn parse_byte_span(
1831    bytes: &[u8],
1832    start: usize,
1833    end: usize,
1834) -> Result<types::ConFrame, error::ParseError> {
1835    let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
1836    match iter.next() {
1837        Some(result) => result,
1838        None => Err(error::ParseError::IncompleteFrame),
1839    }
1840}
1841
1842#[cfg(feature = "parallel")]
1843fn parse_text_span(
1844    text: &str,
1845    start: usize,
1846    end: usize,
1847) -> Result<types::ConFrame, error::ParseError> {
1848    let mut iter = ConFrameIterator::new(&text[start..end]);
1849    match iter.next() {
1850        Some(result) => result,
1851        None => Err(error::ParseError::IncompleteFrame),
1852    }
1853}
1854
1855#[cfg(feature = "parallel")]
1856struct TimedParse {
1857    slots: Vec<Result<types::ConFrame, error::ParseError>>,
1858    /// Still alive so later phases of this read use the same workers.
1859    /// `None` when the call stayed on the calling thread.
1860    pool: Option<std::sync::Arc<rayon::ThreadPool>>,
1861    pool_s: f64,
1862    /// Zero while `pool` is still alive. The owner times the join.
1863    pool_join_s: f64,
1864    scan_s: f64,
1865    scan_minflt: i64,
1866    slot_alloc_s: f64,
1867    worker_parse_s: f64,
1868    worker_sum_s: f64,
1869    worker_max_s: f64,
1870    chunk_s: Vec<f64>,
1871    worker_minflt: Vec<i64>,
1872    share_frames: Vec<u64>,
1873    share_bytes: Vec<u64>,
1874    /// `(slot, file line)` recorded by the worker that failed.
1875    fail_lines: Vec<(usize, u32)>,
1876    llc_misses: i64,
1877    numa_remote: i64,
1878    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`].
1879    scan_kind: u8,
1880}
1881
1882/// Keep freed parse pages in the heap.
1883///
1884/// Eight workers otherwise return those pages to the kernel, and the
1885/// next file faults them again. One worker already keeps a smaller set.
1886/// The same policy on every width leaves the parse fault count flat.
1887#[cfg(feature = "parallel")]
1888fn keep_parse_pages() {
1889    // glibc `M_MMAP_MAX` and `M_TRIM_THRESHOLD`. macOS and Windows have
1890    // no `mallopt`; leaving the pages to the system allocator is enough
1891    // for the parse to be correct.
1892    #[cfg(target_os = "linux")]
1893    unsafe {
1894        libc::mallopt(-4, 0);
1895        libc::mallopt(-1, i32::MAX);
1896    }
1897}
1898
1899/// Slot storage kept between parses.
1900///
1901/// Eight workers write one `ConFrame` shell per frame into this buffer.
1902/// A fresh buffer is a new mapping, and those writes are the repeating
1903/// minor faults. The next parse reuses the same pages.
1904#[cfg(feature = "parallel")]
1905fn take_slot_buf(
1906    n: usize,
1907) -> Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>> {
1908    let mut buf = slot_cache()
1909        .lock()
1910        .map(|mut slot| std::mem::take(&mut *slot))
1911        .unwrap_or_default();
1912    if buf.capacity() < n {
1913        buf = Vec::with_capacity(n);
1914    }
1915    unsafe { buf.set_len(n) };
1916    buf
1917}
1918
1919#[cfg(feature = "parallel")]
1920fn slot_cache()
1921-> &'static std::sync::Mutex<Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>>>
1922{
1923    static CACHE: std::sync::Mutex<
1924        Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>>,
1925    > = std::sync::Mutex::new(Vec::new());
1926    &CACHE
1927}
1928
1929/// One pool per worker count, kept for the life of the process.
1930///
1931/// A fresh pool on every file made each worker fault its frames again.
1932/// Reusing the workers lets the allocator hand back the pages just freed.
1933#[cfg(feature = "parallel")]
1934#[allow(clippy::type_complexity)]
1935fn shared_pool(workers: usize) -> Option<std::sync::Arc<rayon::ThreadPool>> {
1936    use std::sync::{Arc, Mutex, OnceLock};
1937    static CACHE: OnceLock<Mutex<Vec<(usize, Arc<rayon::ThreadPool>)>>> = OnceLock::new();
1938    let cache = CACHE.get_or_init(|| Mutex::new(Vec::new()));
1939    let mut guard = cache.lock().ok()?;
1940    if let Some((_, pool)) = guard.iter().find(|(count, _)| *count == workers) {
1941        return Some(Arc::clone(pool));
1942    }
1943    keep_parse_pages();
1944    let pool = Arc::new(
1945        rayon::ThreadPoolBuilder::new()
1946            .num_threads(workers)
1947            .build()
1948            .ok()?,
1949    );
1950    guard.push((workers, Arc::clone(&pool)));
1951    Some(pool)
1952}
1953
1954/// Minor faults of this process.
1955///
1956/// `getrusage` is Unix. Windows reports 0.
1957#[cfg(feature = "parallel")]
1958pub fn process_minflt() -> i64 {
1959    #[cfg(unix)]
1960    {
1961        unsafe {
1962            let mut usage = std::mem::zeroed::<libc::rusage>();
1963            libc::getrusage(libc::RUSAGE_SELF, &mut usage);
1964            usage.ru_minflt
1965        }
1966    }
1967    #[cfg(not(unix))]
1968    {
1969        0
1970    }
1971}
1972
1973/// Page size from `sysconf`, with the POSIX page as the fallback.
1974#[cfg(feature = "parallel")]
1975fn host_page_size() -> usize {
1976    #[cfg(unix)]
1977    {
1978        let raw = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
1979        if raw > 0 { raw as usize } else { 4096 }
1980    }
1981    #[cfg(not(unix))]
1982    {
1983        4096
1984    }
1985}
1986
1987/// Fault one word on each page of `values`.
1988///
1989/// The page count is split by the worker count. The step is the host
1990/// page, so the slice a worker faults is its own. No extra chunk size.
1991#[cfg(feature = "parallel")]
1992fn first_touch_f64(values: &mut [f64], workers: usize) {
1993    use rayon::prelude::*;
1994    if values.is_empty() || workers < 2 {
1995        return;
1996    }
1997    let page = host_page_size().max(std::mem::size_of::<f64>());
1998    let step = (page / std::mem::size_of::<f64>()).max(1);
1999    let pages = values.len().div_ceil(step);
2000    let share = pages.div_ceil(workers);
2001    if rayon::current_thread_index().is_some() {
2002        // One chunk per page. The installed pool hands each page to one
2003        // worker, so that worker faults its own slice.
2004        values.par_chunks_mut(step).for_each(|chunk| {
2005            if let Some(slot) = chunk.first_mut() {
2006                *slot = 0.0;
2007            }
2008        });
2009        return;
2010    }
2011    std::thread::scope(|scope| {
2012        let mut rest = values;
2013        for worker in 0..workers {
2014            if rest.is_empty() {
2015                break;
2016            }
2017            let pages_here = share.min(rest.len().div_ceil(step));
2018            let take = (pages_here * step).min(rest.len());
2019            let (head, tail) = rest.split_at_mut(take);
2020            rest = tail;
2021            let _ = worker;
2022            scope.spawn(move || {
2023                let mut at = 0;
2024                while at < head.len() {
2025                    head[at] = 0.0;
2026                    at += step;
2027                }
2028            });
2029        }
2030    });
2031}
2032
2033/// Move every `Ok` frame on the read's pool.
2034///
2035/// A serial walk of the slot vector grows with the frame count. The
2036/// pool that parsed the frames does this move. One thread still walks
2037/// the slots when that pool is absent.
2038#[cfg(feature = "parallel")]
2039fn finish_results(
2040    pool: Option<&rayon::ThreadPool>,
2041    slots: Vec<Result<types::ConFrame, error::ParseError>>,
2042) -> Result<Vec<types::ConFrame>, error::ParseError> {
2043    if slots.len() < 2 || pool.is_none() {
2044        let mut frames = Vec::with_capacity(slots.len());
2045        for part in slots {
2046            frames.push(part?);
2047        }
2048        return Ok(frames);
2049    }
2050    let pool = pool.expect("pool");
2051    pool.install(|| {
2052        use rayon::prelude::*;
2053        if let Some(index) = slots.par_iter().position_first(|item| item.is_err()) {
2054            return Err(match slots.into_iter().nth(index) {
2055                Some(Err(err)) => err,
2056                _ => error::ParseError::IncompleteFrame,
2057            });
2058        }
2059        Ok(slots
2060            .into_par_iter()
2061            .map(|item| item.expect("ok"))
2062            .collect())
2063    })
2064}
2065
2066/// Header walk on the calling thread.
2067#[cfg(feature = "parallel")]
2068pub const SCAN_SERIAL: u8 = 0;
2069/// Newline index in chunks, then a header walk on the calling thread.
2070///
2071/// Frames that do not share one byte length take this path. The newline
2072/// bytes are split across the pool. The header walk is not, because a
2073/// later frame start is the end of the skip, and that end is not known
2074/// until the skip runs.
2075#[cfg(feature = "parallel")]
2076pub const SCAN_INDEXED: u8 = 1;
2077/// Equal-length frames. Each worker boundary is checked.
2078#[cfg(feature = "parallel")]
2079pub const SCAN_EQUAL_SPAN: u8 = 2;
2080
2081/// Frame starts when every frame occupies the same number of bytes.
2082///
2083/// The first frame sets the span. Each worker boundary is skipped on
2084/// its own slice. A span that does not tile the
2085/// buffer, or a skip that does not land on the next span, returns
2086/// `None` and the caller keeps the serial walk. The starts are
2087/// `0, span, 2*span, ...` and match [`collect_frame_starts`].
2088#[cfg(feature = "parallel")]
2089#[cfg_attr(not(test), allow(dead_code))]
2090fn equal_span_boundaries(bytes: &[u8], workers: usize) -> Option<Vec<usize>> {
2091    if workers < 2 || bytes.len() < 2 {
2092        return None;
2093    }
2094    let mut first = ConFrameIterator::from_bytes(bytes);
2095    match first.forward_fast() {
2096        Some(Ok(())) => {}
2097        _ => return None,
2098    }
2099    let span = first.lines.pos;
2100    if span < 2 {
2101        return None;
2102    }
2103    let n = bytes.len() / span;
2104    if n < 2 {
2105        return None;
2106    }
2107    // Bytes after the last full span are a tail, not another frame.
2108    // The serial walk ignores a whitespace tail.
2109    let tiled = n * span;
2110    if bytes[tiled..].iter().any(|b| !b.is_ascii_whitespace()) {
2111        return None;
2112    }
2113    // Equal byte ranges. A cut that falls inside a frame resyncs to the
2114    // next frame start, and only that header is checked. The checks stay
2115    // on this worker: a nested pool wake was larger than the checks.
2116    let byte_share = tiled.div_ceil(workers);
2117    for worker in 0..workers {
2118        let byte_cut = worker.saturating_mul(byte_share).min(tiled);
2119        if byte_cut >= tiled {
2120            continue;
2121        }
2122        let index = if worker == 0 {
2123            0
2124        } else {
2125            byte_cut.div_ceil(span).min(n - 1)
2126        };
2127        let start = index.saturating_mul(span);
2128        if start >= tiled {
2129            continue;
2130        }
2131        let end = (start + span).min(tiled);
2132        let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
2133        match iter.forward_fast() {
2134            Some(Ok(())) if iter.lines.pos == end - start => {}
2135            _ => return None,
2136        }
2137    }
2138    Some(arithmetic_starts(span, n))
2139}
2140
2141/// Starts `0, span, 2*span, ...` from a buffer kept between files.
2142///
2143/// Building that buffer from a fresh allocation stalled for milliseconds
2144/// after a large free. The next file fills the same pages.
2145#[cfg(feature = "parallel")]
2146static START_CACHE: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
2147
2148#[cfg(feature = "parallel")]
2149#[cfg_attr(not(test), allow(dead_code))]
2150fn arithmetic_starts(span: usize, n: usize) -> Vec<usize> {
2151    let mut starts = START_CACHE
2152        .lock()
2153        .map(|mut slot| std::mem::take(&mut *slot))
2154        .unwrap_or_default();
2155    if starts.len() != n {
2156        starts.resize(n, 0);
2157    }
2158    for (index, slot) in starts.iter_mut().enumerate() {
2159        *slot = index * span;
2160    }
2161    starts
2162}
2163
2164#[cfg(feature = "parallel")]
2165fn recycle_starts(starts: Vec<usize>) {
2166    if let Ok(mut slot) = START_CACHE.lock() {
2167        if starts.capacity() >= slot.capacity() {
2168            *slot = starts;
2169        }
2170    }
2171}
2172
2173#[cfg(feature = "parallel")]
2174fn parse_frames_parallel_bytes(
2175    bytes: &[u8],
2176    num_threads: Option<usize>,
2177) -> Vec<Result<types::ConFrame, error::ParseError>> {
2178    parse_bytes_timed(bytes, num_threads).slots
2179}
2180
2181/// Scan, slot allocation, and worker parse of `bytes`.
2182///
2183/// `Some(n)` with `n >= 2` uses a pool of that size. Every other count
2184/// stays on the calling thread. `None` follows [`scan_thread_count`].
2185/// The pool build is `pool_s`. The pool join is `pool_join_s`.
2186/// Neither one is inside the scan or the parse.
2187#[cfg(feature = "parallel")]
2188fn parse_bytes_timed(bytes: &[u8], num_threads: Option<usize>) -> TimedParse {
2189    let parse_workers = match num_threads {
2190        Some(n) if n >= 2 => n,
2191        _ => 1,
2192    };
2193    let run = || {
2194        let fault0 = process_minflt();
2195        let t_scan = std::time::Instant::now();
2196        // Equal-length frames skip the serial header walk. Anything else
2197        // still walks headers on this thread. Workers then parse the
2198        // spans in file order.
2199        let (boundaries, scan_kind) =
2200            if let Some(bounds) = equal_span_boundaries(bytes, parse_workers) {
2201                (bounds, SCAN_EQUAL_SPAN)
2202            } else {
2203                (
2204                    collect_frame_starts(ConFrameIterator::from_bytes(bytes), true),
2205                    SCAN_SERIAL,
2206                )
2207            };
2208        let scan_s = t_scan.elapsed().as_secs_f64();
2209        let scan_minflt = process_minflt().saturating_sub(fault0);
2210        let parsed = parse_into_slots_timed(
2211            bytes,
2212            bytes.len(),
2213            &boundaries,
2214            parse_workers,
2215            |start, end| parse_byte_span(bytes, start, end),
2216        );
2217        recycle_starts(boundaries);
2218        (parsed, scan_s, scan_kind, scan_minflt)
2219    };
2220    let (pool_s, pool, packed) = match num_threads {
2221        None => (0.0, None, run()),
2222        Some(n) if n <= 1 => {
2223            let _ = n;
2224            keep_parse_pages();
2225            let fault0 = process_minflt();
2226            let t_scan = std::time::Instant::now();
2227            let boundaries = collect_frame_starts(ConFrameIterator::from_bytes(bytes), true);
2228            let scan_s = t_scan.elapsed().as_secs_f64();
2229            let scan_minflt = process_minflt().saturating_sub(fault0);
2230            let parsed =
2231                parse_into_slots_timed(bytes, bytes.len(), &boundaries, 1, |start, end| {
2232                    parse_byte_span(bytes, start, end)
2233                });
2234            (0.0, None, (parsed, scan_s, SCAN_SERIAL, scan_minflt))
2235        }
2236        Some(n) => {
2237            let t_pool = std::time::Instant::now();
2238            match shared_pool(n) {
2239                Some(pool) => {
2240                    let built = t_pool.elapsed().as_secs_f64();
2241                    let t_wake = std::time::Instant::now();
2242                    let (wake, body) = pool.install(|| {
2243                        let wake = t_wake.elapsed().as_secs_f64();
2244                        (wake, run())
2245                    });
2246                    // The same workers serve later files of this width.
2247                    // Their freed blocks stay warm for the next parse.
2248                    (built + wake, Some(pool), body)
2249                }
2250                None => (t_pool.elapsed().as_secs_f64(), None, run()),
2251            }
2252        }
2253    };
2254    let (parsed, scan_s, scan_kind, scan_minflt) = packed;
2255    TimedParse {
2256        slots: parsed.slots,
2257        pool,
2258        pool_s,
2259        pool_join_s: 0.0,
2260        scan_s,
2261        scan_minflt,
2262        slot_alloc_s: parsed.alloc_s,
2263        worker_parse_s: parsed.work_s,
2264        worker_sum_s: parsed.sum_s,
2265        worker_max_s: parsed.max_s,
2266        chunk_s: parsed.chunk_s,
2267        worker_minflt: parsed.worker_minflt,
2268        share_frames: parsed.share_frames,
2269        share_bytes: parsed.share_bytes,
2270        fail_lines: parsed.fail_lines,
2271        llc_misses: sample_llc_misses(),
2272        numa_remote: numa_remote_pages(),
2273        scan_kind,
2274    }
2275}
2276
2277/// Count frames without building atom payloads (uses [`ConFrameIterator::forward_fast`]
2278/// when possible, else [`ConFrameIterator::forward`]).
2279///
2280/// Prefer this over `read_all_frames(...).len()` when only the frame count is needed.
2281/// A rejected frame is [`LoadError::Parse`] with that frame's file line and
2282/// 0-based index, including when the failure is after the first frame.
2283pub fn count_frames(path: &Path) -> Result<usize, LoadError> {
2284    let contents = load_text(path)?;
2285    let text = contents.as_str().map_err(LoadError::Utf8)?;
2286    let mut n = 0usize;
2287    let mut iter = ConFrameIterator::new(text);
2288    loop {
2289        match iter.forward_fast() {
2290            Some(Ok(())) => n += 1,
2291            Some(Err(e)) => {
2292                return Err(LoadError::Parse(error::LocatedParseError::new(
2293                    e,
2294                    iter.lines.line_no(),
2295                    iter.frame_index(),
2296                )));
2297            }
2298            None => break,
2299        }
2300    }
2301    Ok(n)
2302}
2303
2304/// Reads only the first frame from a file.
2305///
2306/// More efficient than `read_all_frames` for single-frame access because it
2307/// stops parsing after the first frame rather than collecting all of them.
2308pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
2309    let contents = crate::compression::read_file_contents(path)?;
2310    let text = contents.as_str()?;
2311    let mut iter = ConFrameIterator::new(text);
2312    match iter.next() {
2313        Some(Ok(frame)) => Ok(frame),
2314        Some(Err(e)) => Err(Box::new(e)),
2315        None => Err("No frames found in file".into()),
2316    }
2317}
2318
2319/// Skip `index` frames (no atom parse), then parse the next one.
2320///
2321/// `index == 0` is [`read_first_frame`]. Out of range is an error.
2322pub fn read_nth_frame(
2323    path: &Path,
2324    index: usize,
2325) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
2326    let contents = crate::compression::read_file_contents(path)?;
2327    let text = contents.as_str()?;
2328    read_nth_frame_from_text(text, index)
2329}
2330
2331/// Same as [`read_nth_frame`] on an already-loaded buffer.
2332pub fn read_nth_frame_from_text(
2333    text: &str,
2334    index: usize,
2335) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
2336    let mut iter = ConFrameIterator::new(text);
2337    let skipped = iter.skip_frames(index)?;
2338    if skipped < index {
2339        return Err(format!("frame {index} out of range (only {skipped} frames)").into());
2340    }
2341    match iter.next() {
2342        Some(Ok(frame)) => Ok(frame),
2343        Some(Err(e)) => Err(Box::new(e)),
2344        None => Err(format!("frame {index} out of range").into()),
2345    }
2346}
2347
2348/// Reads all frames using the checked `.rcidx` sidecar next to `path`.
2349///
2350/// Uncompressed input uses (and refreshes) `path.rcidx`; compressed
2351/// input is scanned in memory each time. UTF-8 is validated in
2352/// newline-aligned slices.
2353#[cfg(feature = "parallel")]
2354pub fn read_all_frames_indexed(
2355    path: &Path,
2356    num_threads: Option<usize>,
2357) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
2358    let contents = crate::compression::read_file_contents(path)?;
2359    let text = contents.as_str_sliced()?;
2360    let idx = match contents {
2361        crate::compression::FileContents::Mapped(_) => {
2362            crate::frame_index::load_or_build(path, text, true)?
2363        }
2364        crate::compression::FileContents::Owned(_) => {
2365            let on_disk = std::fs::metadata(path)?.len();
2366            crate::frame_index::load_or_build(path, text, on_disk == text.len() as u64)?
2367        }
2368    };
2369    let offs: Vec<usize> = idx.offsets.iter().map(|&o| o as usize).collect();
2370    parse_frames_at_offsets(text, &offs, num_threads)
2371        .into_iter()
2372        .map(|r| r.map_err(|e| Box::new(e) as Box<dyn std::error::Error>))
2373        .collect()
2374}
2375
2376enum IndexStep {
2377    Stay(usize),
2378    Missing(usize),
2379    Landed(usize, usize),
2380    Short(usize, usize),
2381}
2382
2383struct IndexedSpan {
2384    start: usize,
2385    content_end: usize,
2386    next_pos: usize,
2387}
2388
2389/// Lines in `bytes`, counting a final line that has no `\n`.
2390fn line_count(nl: &[usize], len: usize) -> usize {
2391    if len == 0 {
2392        0
2393    } else if nl.last().copied() == Some(len - 1) {
2394        nl.len()
2395    } else {
2396        nl.len() + 1
2397    }
2398}
2399
2400fn line_start_pos(nl: &[usize], len: usize, line_no: usize) -> usize {
2401    if line_no == 0 {
2402        return 0;
2403    }
2404    if line_no >= line_count(nl, len) {
2405        return len;
2406    }
2407    nl[line_no - 1] + 1
2408}
2409
2410/// Line index of the line that contains `pos`, or the line count at `len`.
2411fn line_no_for_pos(nl: &[usize], len: usize, pos: usize) -> usize {
2412    if pos >= len {
2413        return line_count(nl, len);
2414    }
2415    nl.partition_point(|&p| p < pos)
2416}
2417
2418fn indexed_span(nl: &[usize], bytes: &[u8], line_no: usize) -> Option<IndexedSpan> {
2419    let len = bytes.len();
2420    if line_no >= line_count(nl, len) {
2421        return None;
2422    }
2423    let start = if line_no == 0 { 0 } else { nl[line_no - 1] + 1 };
2424    let (raw_end, next_pos) = if line_no < nl.len() {
2425        (nl[line_no], nl[line_no] + 1)
2426    } else {
2427        (len, len)
2428    };
2429    let mut content_end = raw_end;
2430    if content_end > start && bytes[content_end - 1] == b'\r' {
2431        content_end -= 1;
2432    }
2433    Some(IndexedSpan {
2434        start,
2435        content_end,
2436        next_pos,
2437    })
2438}
2439
2440/// `n` cuts covering `0..=len`. A cut may fall in the middle of a header line.
2441#[cfg(feature = "parallel")]
2442fn equal_cuts(len: usize, n: usize) -> Vec<usize> {
2443    let n = n.max(1);
2444    let mut cuts = Vec::with_capacity(n + 1);
2445    for i in 0..=n {
2446        let at = ((i as u128) * (len as u128) / (n as u128)) as usize;
2447        let at = at.min(len);
2448        if cuts.last().copied() != Some(at) {
2449            cuts.push(at);
2450        }
2451    }
2452    if cuts.last().copied() != Some(len) {
2453        cuts.push(len);
2454    }
2455    cuts
2456}
2457
2458/// Newline offsets in `bytes`.
2459///
2460/// `cuts` splits the buffer into `[cuts[i], cuts[i + 1])`. Each piece is
2461/// scanned on its own and the offsets are concatenated. A `\n` belongs to
2462/// the piece that contains that byte, so a header line whose body crosses
2463/// a cut still contributes one newline.
2464#[cfg(feature = "parallel")]
2465fn newline_positions_at_cuts(bytes: &[u8], cuts: &[usize]) -> Vec<usize> {
2466    let spans: Vec<(usize, usize)> = cuts
2467        .windows(2)
2468        .map(|w| (w[0].min(bytes.len()), w[1].min(bytes.len())))
2469        .filter(|(start, end)| start < end)
2470        .collect();
2471    if spans.len() <= 1 {
2472        let (start, end) = spans.first().copied().unwrap_or((0, 0));
2473        return memchr::memchr_iter(b'\n', &bytes[start..end])
2474            .map(|p| start + p)
2475            .collect();
2476    }
2477    // The current Rayon pool, or the global pool when the caller has not
2478    // installed one. Chunk count is the worker cap: two chunks do not
2479    // wake every core.
2480    use rayon::prelude::*;
2481    let parts: Vec<Vec<usize>> = spans
2482        .par_iter()
2483        .map(|&(start, end)| {
2484            memchr::memchr_iter(b'\n', &bytes[start..end])
2485                .map(|p| start + p)
2486                .collect()
2487        })
2488        .collect();
2489    let mut all = Vec::with_capacity(bytes.len() / 32);
2490    for part in parts {
2491        all.extend(part);
2492    }
2493    all
2494}
2495
2496#[cfg(feature = "parallel")]
2497fn newline_positions(bytes: &[u8], threads: usize) -> Vec<usize> {
2498    newline_positions_at_cuts(bytes, &equal_cuts(bytes.len(), threads))
2499}
2500
2501/// Worker count for a newline scan.
2502///
2503/// `Some(n)` with `n >= 2` uses that many workers at every file length.
2504/// A file shorter than two bytes has nothing to split, and [`equal_cuts`]
2505/// drops a cut that repeats the previous byte, so a short file does not
2506/// invent an empty chunk. `None`, `Some(0)`, and `Some(1)` stay on the
2507/// calling thread. An omitted count must not start the global pool:
2508/// that pool made a one-frame file slower. There is no byte-length
2509/// cutoff taken from one host.
2510#[cfg(feature = "parallel")]
2511fn scan_thread_count(num_threads: Option<usize>, len: usize) -> usize {
2512    let requested = match num_threads {
2513        Some(n) if n >= 2 => n,
2514        _ => return 1,
2515    };
2516    if len < 2 { 1 } else { requested }
2517}
2518
2519/// I/O, UTF-8, or a located parse failure while loading a path.
2520#[derive(Debug)]
2521pub enum LoadError {
2522    Io(std::io::Error),
2523    Utf8(std::str::Utf8Error),
2524    Parse(error::LocatedParseError),
2525    /// The path opened and decoded, and it held no frame.
2526    Empty,
2527}
2528
2529impl std::fmt::Display for LoadError {
2530    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2531        match self {
2532            LoadError::Io(e) => write!(f, "{e}"),
2533            LoadError::Utf8(e) => write!(f, "{e}"),
2534            LoadError::Parse(e) => write!(f, "{e}"),
2535            LoadError::Empty => write!(f, "no frames found in file"),
2536        }
2537    }
2538}
2539
2540impl std::error::Error for LoadError {}
2541
2542fn load_error_from_box(err: Box<dyn std::error::Error>) -> LoadError {
2543    if let Some(io) = err.downcast_ref::<std::io::Error>() {
2544        return LoadError::Io(std::io::Error::new(io.kind(), io.to_string()));
2545    }
2546    LoadError::Io(std::io::Error::other(err.to_string()))
2547}
2548
2549fn load_text(path: &Path) -> Result<crate::compression::FileContents, LoadError> {
2550    crate::compression::read_file_contents(path).map_err(load_error_from_box)
2551}
2552
2553/// 1-based line number of the line that contains `byte`.
2554#[cfg(feature = "parallel")]
2555fn file_line_at(text: &str, byte: usize) -> u32 {
2556    let end = byte.min(text.len());
2557    1 + text.as_bytes()[..end]
2558        .iter()
2559        .filter(|&&b| b == b'\n')
2560        .count() as u32
2561}
2562
2563/// Collect every frame, keeping the line and frame index of the first error.
2564pub fn frames_from_text_located(
2565    text: &str,
2566    num_threads: Option<usize>,
2567) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
2568    #[cfg(feature = "parallel")]
2569    {
2570        // A caller that does not ask for a pool stays on this thread.
2571        // Starting the global pool for a long file made a one-frame read slower.
2572        let use_parallel = matches!(num_threads, Some(n) if n >= 2);
2573        if use_parallel {
2574            let pool = num_threads.filter(|&n| n >= 2);
2575            return collect_parallel_located(text, pool);
2576        }
2577    }
2578    #[cfg(not(feature = "parallel"))]
2579    {
2580        let _ = num_threads;
2581    }
2582    let mut frames = Vec::new();
2583    let mut iter = ConFrameIterator::new(text);
2584    let mut index = 0u64;
2585    while let Some(item) = iter.next() {
2586        match item {
2587            Ok(frame) => {
2588                frames.push(frame);
2589                index += 1;
2590            }
2591            Err(e) => {
2592                return Err(error::LocatedParseError::new(
2593                    e,
2594                    iter.lines.line_no(),
2595                    index,
2596                ));
2597            }
2598        }
2599    }
2600    Ok(frames)
2601}
2602
2603/// Read every frame from `path`, with the line and frame index of the first error.
2604pub fn read_all_frames_located(
2605    path: &Path,
2606    num_threads: Option<usize>,
2607) -> Result<Vec<types::ConFrame>, LoadError> {
2608    let contents = load_text(path)?;
2609    let text = contents.as_str().map_err(LoadError::Utf8)?;
2610    frames_from_text_located(text, num_threads).map_err(LoadError::Parse)
2611}
2612
2613/// Read the first frame from `path`, with a line number on failure.
2614pub fn read_first_frame_located(path: &Path) -> Result<types::ConFrame, LoadError> {
2615    let contents = load_text(path)?;
2616    let text = contents.as_str().map_err(LoadError::Utf8)?;
2617    let mut iter = ConFrameIterator::new(text);
2618    match iter.next() {
2619        Some(Ok(frame)) => Ok(frame),
2620        Some(Err(e)) => Err(LoadError::Parse(error::LocatedParseError::new(
2621            e,
2622            iter.lines.line_no(),
2623            0,
2624        ))),
2625        None => Err(LoadError::Empty),
2626    }
2627}
2628
2629/// Skip `index` frames, then parse one, with a line number on failure.
2630pub fn read_nth_frame_located(path: &Path, index: usize) -> Result<types::ConFrame, LoadError> {
2631    let contents = load_text(path)?;
2632    let text = contents.as_str().map_err(LoadError::Utf8)?;
2633    let mut iter = ConFrameIterator::new(text);
2634    match iter.skip_frames_counted(index) {
2635        Ok(skipped) if skipped == index => {}
2636        Ok(skipped) => {
2637            return Err(LoadError::Parse(error::LocatedParseError::new(
2638                error::ParseError::IncompleteFrame,
2639                iter.lines.line_no(),
2640                skipped as u64,
2641            )));
2642        }
2643        Err((failed_at, e)) => {
2644            return Err(LoadError::Parse(error::LocatedParseError::new(
2645                e,
2646                iter.lines.line_no(),
2647                failed_at as u64,
2648            )));
2649        }
2650    }
2651    match iter.next() {
2652        Some(Ok(frame)) => Ok(frame),
2653        Some(Err(e)) => Err(LoadError::Parse(error::LocatedParseError::new(
2654            e,
2655            iter.lines.line_no(),
2656            index as u64,
2657        ))),
2658        None => Err(LoadError::Parse(error::LocatedParseError::new(
2659            error::ParseError::IncompleteFrame,
2660            iter.lines.line_no(),
2661            index as u64,
2662        ))),
2663    }
2664}
2665
2666fn collect_frame_starts(scanner: ConFrameIterator<'_>, skip_whitespace_tail: bool) -> Vec<usize> {
2667    #[cfg(target_arch = "x86_64")]
2668    {
2669        if std::is_x86_feature_detected!("avx2") {
2670            // One AVX2 entry so the line skip stays in this function.
2671            // A separate call per block pays for an AVX/SSE transition.
2672            // SAFETY: the runtime check above is the AVX2 and POPCNT contract.
2673            return unsafe { collect_frame_starts_avx2(scanner, skip_whitespace_tail) };
2674        }
2675    }
2676    collect_frame_starts_inner(scanner, skip_whitespace_tail)
2677}
2678
2679#[cfg(target_arch = "x86_64")]
2680#[target_feature(enable = "avx2,popcnt")]
2681unsafe fn collect_frame_starts_avx2(
2682    scanner: ConFrameIterator<'_>,
2683    skip_whitespace_tail: bool,
2684) -> Vec<usize> {
2685    collect_frame_starts_inner(scanner, skip_whitespace_tail)
2686}
2687
2688#[inline(always)]
2689fn collect_frame_starts_inner(
2690    mut scanner: ConFrameIterator<'_>,
2691    skip_whitespace_tail: bool,
2692) -> Vec<usize> {
2693    let mut boundaries = Vec::new();
2694    loop {
2695        scanner.lines.clear_peek();
2696        let start = scanner.lines.pos;
2697        if start >= scanner.lines.bytes.len() {
2698            break;
2699        }
2700        if skip_whitespace_tail {
2701            let rest = &scanner.lines.bytes[start..];
2702            if rest.iter().all(u8::is_ascii_whitespace) {
2703                break;
2704            }
2705        }
2706        boundaries.push(start);
2707        match scanner.forward_fast_to(None) {
2708            Some(Ok(())) => {}
2709            Some(Err(_)) | None => break,
2710        }
2711    }
2712    boundaries
2713}
2714
2715/// Serial header walk. This is the reference the chunked scan must match.
2716pub fn frame_start_offsets_serial(file_contents: &str) -> Vec<usize> {
2717    collect_frame_starts(ConFrameIterator::new(file_contents), false)
2718}
2719
2720#[cfg(feature = "parallel")]
2721fn scanner_with_newlines<'a>(text: &'a str, newlines: Vec<usize>) -> ConFrameIterator<'a> {
2722    let mut scanner = ConFrameIterator::new(text);
2723    scanner.lines.index_line = 0;
2724    scanner.lines.newlines = Some(newlines);
2725    scanner
2726}
2727
2728#[cfg(feature = "parallel")]
2729fn frame_start_offsets_indexed(text: &str, newlines: Vec<usize>) -> Vec<usize> {
2730    collect_frame_starts(scanner_with_newlines(text, newlines), false)
2731}
2732
2733/// Frame starts for `text` when the newline scan is split at `cuts`.
2734///
2735/// `cuts` is the chunk edges, including 0 and `text.len()`. A cut inside a
2736/// header line is the straddling case: the stitched offsets are still the
2737/// serial offsets.
2738#[cfg(all(feature = "parallel", test))]
2739fn frame_start_offsets_at_cuts(text: &str, cuts: &[usize]) -> Vec<usize> {
2740    let newlines = newline_positions_at_cuts(text.as_bytes(), cuts);
2741    frame_start_offsets_indexed(text, newlines)
2742}
2743
2744/// Frame start byte offsets via [`ConFrameIterator::forward_fast`] (no atom parse).
2745///
2746/// Stops at the first skip error (that start is still included).
2747///
2748/// With the `parallel` feature and more than one worker, the buffer is
2749/// split into chunks and the newline bytes are scanned together. The
2750/// length of the buffer does not choose the worker count. The chunks
2751/// are not aligned to lines. A header
2752/// that straddles a cut is one line, because each `\n` is recorded by the
2753/// chunk that holds that byte and the lists are stitched in order. The
2754/// returned offsets match the serial walk.
2755pub fn frame_start_offsets(file_contents: &str) -> Vec<usize> {
2756    frame_start_offsets_with_threads(file_contents, None)
2757}
2758
2759/// [`frame_start_offsets`] using `num_threads` chunks.
2760///
2761/// `None` uses the machine's parallelism. `Some(0)` and `Some(1)` stay on
2762/// the calling thread. The offsets do not depend on the thread count.
2763///
2764/// The timed work includes the cut setup, the per-chunk scans, the
2765/// stitch, and the frame walk that reads the stitched newline index.
2766pub fn frame_start_offsets_with_threads(
2767    file_contents: &str,
2768    num_threads: Option<usize>,
2769) -> Vec<usize> {
2770    #[cfg(feature = "parallel")]
2771    {
2772        let threads = scan_thread_count(num_threads, file_contents.len());
2773        if threads >= 2 {
2774            let newlines = newline_positions(file_contents.as_bytes(), threads);
2775            return frame_start_offsets_indexed(file_contents, newlines);
2776        }
2777    }
2778    #[cfg(not(feature = "parallel"))]
2779    {
2780        let _ = num_threads;
2781    }
2782    frame_start_offsets_serial(file_contents)
2783}
2784
2785/// Parses frames in parallel using rayon, splitting on frame boundaries.
2786///
2787/// Phase 1: frame starts from a serial [`ConFrameIterator::forward_fast`]
2788/// walk. The header names each block's line count, so the walk does not
2789/// parse atom rows. Phase 2 parses those spans on a Rayon pool and
2790/// stores the frames in file order. See [`parse_frames_parallel_with_threads`].
2791///
2792/// Requires the `parallel` feature.
2793#[cfg(feature = "parallel")]
2794pub fn parse_frames_parallel(
2795    file_contents: &str,
2796) -> Vec<Result<types::ConFrame, error::ParseError>> {
2797    parse_frames_parallel_with_threads(file_contents, None)
2798}
2799
2800/// Like [`parse_frames_parallel`], but `Some(n)` with `n >= 2` parses on
2801/// a Rayon pool of that size. `None`, `Some(0)`, and `Some(1)` stay on
2802/// the calling thread.
2803///
2804/// The boundary walk is serial. The parse ranges are byte-balanced and
2805/// the frames come back in file order, field for field with a serial
2806/// iterator.
2807///
2808/// Requires the `parallel` feature.
2809#[cfg(feature = "parallel")]
2810pub fn parse_frames_parallel_with_threads(
2811    file_contents: &str,
2812    num_threads: Option<usize>,
2813) -> Vec<Result<types::ConFrame, error::ParseError>> {
2814    let parse_workers = match num_threads {
2815        Some(n) if n >= 2 => n,
2816        _ => 1,
2817    };
2818    let run = || {
2819        // A whitespace tail is not a frame. The serial iterator stops
2820        // there. The offset scan used by callers still records that byte
2821        // when a skip error includes it.
2822        let boundaries = collect_frame_starts(ConFrameIterator::new(file_contents), true);
2823        parse_into_slots(
2824            file_contents.as_bytes(),
2825            file_contents.len(),
2826            &boundaries,
2827            parse_workers,
2828            |start, end| parse_text_span(file_contents, start, end),
2829        )
2830    };
2831    match num_threads {
2832        Some(n) if n >= 2 => match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
2833            Ok(pool) => pool.install(run),
2834            Err(_) => run(),
2835        },
2836        _ => run(),
2837    }
2838}
2839
2840/// Phase 2 of [`parse_frames_parallel_with_threads`]: parse the frames
2841/// that start at `boundaries` (strictly increasing byte offsets into
2842/// `file_contents`; frame `i` ends where frame `i + 1` starts).
2843///
2844/// Offsets may come from [`frame_start_offsets`] or from a checked
2845/// [`crate::frame_index::FrameIndex`]. The output slots are allocated
2846/// first. Each worker then fills one contiguous index range. Results
2847/// stay in frame order.
2848///
2849/// The timer for this function starts after `boundaries` exist. It
2850/// includes slot allocation and the worker parse. It does not include
2851/// the newline scan or the stitch.
2852///
2853/// Requires the `parallel` feature.
2854#[cfg(feature = "parallel")]
2855pub fn parse_frames_at_offsets(
2856    file_contents: &str,
2857    boundaries: &[usize],
2858    num_threads: Option<usize>,
2859) -> Vec<Result<types::ConFrame, error::ParseError>> {
2860    let workers = match num_threads {
2861        Some(n) if n <= 1 => 1,
2862        Some(n) => n,
2863        None => rayon::current_num_threads().max(1),
2864    };
2865    let run = || {
2866        parse_into_slots(
2867            file_contents.as_bytes(),
2868            file_contents.len(),
2869            boundaries,
2870            workers,
2871            |start, end| parse_text_span(file_contents, start, end),
2872        )
2873    };
2874    match num_threads {
2875        Some(n) if n >= 2 => match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
2876            Ok(pool) => pool.install(run),
2877            Err(_) => run(),
2878        },
2879        _ => run(),
2880    }
2881}
2882
2883/// Seconds for one phased read. The open time is the file read or the
2884/// map. Scan, slot allocation, and the worker parse are separate.
2885#[derive(Clone, Copy, Debug, Default)]
2886pub struct ReadPhaseTimes {
2887    pub open_mmap_s: f64,
2888    /// Rayon pool construction. Zero when the call stays on one thread.
2889    pub pool_s: f64,
2890    /// Joining the local pool after the scan and the parse. Zero when
2891    /// the call stays on one thread.
2892    pub pool_join_s: f64,
2893    pub scan_s: f64,
2894    pub slot_alloc_s: f64,
2895    pub worker_parse_s: f64,
2896    /// Moving each parsed `Result` into the frame vector.
2897    pub finish_s: f64,
2898    /// Sum of per-chunk parse times. This is CPU time, not wall time.
2899    pub worker_sum_s: f64,
2900    /// Slowest parse chunk. This is inside `worker_parse_s`.
2901    pub worker_max_s: f64,
2902    /// Drop of the mapped or owned file buffer after the parse.
2903    pub close_s: f64,
2904    /// Wake of the worker pool and the other gaps inside the read.
2905    pub schedule_s: f64,
2906    /// Minor faults during the scan.
2907    pub scan_minflt: i64,
2908    /// Sum of per-worker minor faults during the parse.
2909    pub parse_minflt: i64,
2910    /// LLC read misses, or `-1` when the guest refuses the counter.
2911    pub llc_misses: i64,
2912    /// Pages on a node other than node 0. Zero on a one-node guest.
2913    pub numa_remote: i64,
2914    /// Minor faults while the coordinate slab is filled.
2915    pub slab_minflt: i64,
2916    /// Minor faults during the touch checksum.
2917    pub touch_minflt: i64,
2918    /// True when the bytes came from a map rather than an owned buffer.
2919    pub mapped: bool,
2920}
2921
2922/// Frames from one phased read, plus the byte length of the buffer.
2923pub struct PhasedFrames {
2924    pub frames: Vec<types::ConFrame>,
2925    pub phases: ReadPhaseTimes,
2926    pub n_bytes: usize,
2927    /// Per-chunk parse seconds, in claim order.
2928    pub chunk_s: Vec<f64>,
2929    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`] when
2930    /// `parallel` is on. Zero otherwise.
2931    pub scan_kind: u8,
2932    /// Minor faults on each parse worker.
2933    pub worker_minflt: Vec<i64>,
2934    /// Frames in each worker share.
2935    pub share_frames: Vec<u64>,
2936    /// File bytes in each worker share.
2937    pub share_bytes: Vec<u64>,
2938    /// Pool kept for the slab, the touch, and frame teardown.
2939    #[cfg(feature = "parallel")]
2940    pub pool: Option<std::sync::Arc<rayon::ThreadPool>>,
2941}
2942
2943/// Read `path` and time open, scan, slot allocation, and the worker parse.
2944///
2945/// Plain text at or above the map threshold is mapped. Smaller files and
2946/// compressed files are owned buffers. `Some(n)` with `n >= 2` parses on
2947/// that many workers. Every other count parses on the calling thread.
2948/// The frame list matches [`frames_from_bytes`].
2949///
2950/// A parse failure is the [`error::LocatedParseError`] from the parse
2951/// that failed. The path is not opened again.
2952pub fn read_all_frames_phased(
2953    path: &Path,
2954    num_threads: Option<usize>,
2955) -> Result<PhasedFrames, Box<dyn std::error::Error>> {
2956    let t_all = std::time::Instant::now();
2957    let t_open = std::time::Instant::now();
2958    let contents = crate::compression::read_file_contents(path)?;
2959    let open_mmap_s = t_open.elapsed().as_secs_f64();
2960    let mapped = matches!(contents, crate::compression::FileContents::Mapped(_));
2961    let n_bytes = match &contents {
2962        crate::compression::FileContents::Owned(text) => text.len(),
2963        crate::compression::FileContents::Mapped(map) => map.len(),
2964    };
2965    let bytes: &[u8] = match &contents {
2966        crate::compression::FileContents::Owned(text) => text.as_bytes(),
2967        crate::compression::FileContents::Mapped(map) => map,
2968    };
2969    let phased = frames_from_bytes_phased_located(bytes, num_threads)?;
2970    let workers = match num_threads {
2971        Some(n) if n >= 2 => n,
2972        _ => 1,
2973    };
2974    let t_close = std::time::Instant::now();
2975    if let crate::compression::FileContents::Mapped(map) = &contents {
2976        // The serial unmap of a fully resident file is the multi-worker
2977        // tail. Advise the pages away on the read's pool first.
2978        // One worker unmaps directly, so a serial read does not walk
2979        // the pages twice.
2980        if workers >= 2 {
2981            #[cfg(feature = "parallel")]
2982            {
2983                if let Some(pool) = phased.pool.as_ref() {
2984                    pool.install(|| {
2985                        crate::compression::advise_dontneed_parallel(map, workers);
2986                    });
2987                }
2988            }
2989            #[cfg(not(feature = "parallel"))]
2990            {
2991                let _ = (map, workers);
2992            }
2993        }
2994    }
2995    drop(contents);
2996    let close_s = t_close.elapsed().as_secs_f64();
2997    let elapsed = t_all.elapsed().as_secs_f64();
2998    let named = open_mmap_s
2999        + phased.pool_s
3000        + phased.pool_join_s
3001        + phased.scan_s
3002        + phased.slot_alloc_s
3003        + phased.worker_parse_s
3004        + phased.finish_s
3005        + close_s;
3006    let schedule_s = (elapsed - named).max(0.0);
3007    let parse_minflt = phased.worker_minflt.iter().sum();
3008    Ok(PhasedFrames {
3009        frames: phased.frames,
3010        n_bytes,
3011        chunk_s: phased.chunk_s,
3012        scan_kind: phased.scan_kind,
3013        worker_minflt: phased.worker_minflt,
3014        share_frames: phased.share_frames,
3015        share_bytes: phased.share_bytes,
3016        phases: ReadPhaseTimes {
3017            open_mmap_s,
3018            pool_s: phased.pool_s,
3019            pool_join_s: phased.pool_join_s,
3020            scan_s: phased.scan_s,
3021            slot_alloc_s: phased.slot_alloc_s,
3022            worker_parse_s: phased.worker_parse_s,
3023            finish_s: phased.finish_s,
3024            worker_sum_s: phased.worker_sum_s,
3025            worker_max_s: phased.worker_max_s,
3026            close_s,
3027            schedule_s,
3028            scan_minflt: phased.scan_minflt,
3029            parse_minflt,
3030            llc_misses: phased.llc_misses,
3031            numa_remote: phased.numa_remote,
3032            mapped,
3033            ..ReadPhaseTimes::default()
3034        },
3035        #[cfg(feature = "parallel")]
3036        pool: phased.pool,
3037    })
3038}
3039
3040/// [`frames_from_bytes`] with scan, slot allocation, and worker-parse times.
3041///
3042/// `Some(n)` with `n >= 2` uses that pool. Every other count, including
3043/// `None`, stays on the calling thread. That is the `read_con` policy:
3044/// an omitted count does not start a pool.
3045pub struct PhasedByteTimes {
3046    pub frames: Vec<types::ConFrame>,
3047    pub pool_s: f64,
3048    pub pool_join_s: f64,
3049    pub scan_s: f64,
3050    pub slot_alloc_s: f64,
3051    pub worker_parse_s: f64,
3052    pub finish_s: f64,
3053    pub worker_sum_s: f64,
3054    pub worker_max_s: f64,
3055    /// Per-chunk parse seconds, in claim order.
3056    pub chunk_s: Vec<f64>,
3057    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`] when
3058    /// `parallel` is on. Zero otherwise.
3059    pub scan_kind: u8,
3060    /// Minor faults during the scan.
3061    pub scan_minflt: i64,
3062    /// Minor faults on each parse worker, in worker order.
3063    pub worker_minflt: Vec<i64>,
3064    pub share_frames: Vec<u64>,
3065    pub share_bytes: Vec<u64>,
3066    /// LLC read misses, or `-1` when the guest refuses the counter.
3067    pub llc_misses: i64,
3068    /// Pages on a node other than node 0.
3069    pub numa_remote: i64,
3070    /// Pool kept for the slab, the touch, and frame teardown.
3071    #[cfg(feature = "parallel")]
3072    pub pool: Option<std::sync::Arc<rayon::ThreadPool>>,
3073}
3074
3075pub fn frames_from_bytes_phased(
3076    bytes: &[u8],
3077    num_threads: Option<usize>,
3078) -> Result<PhasedByteTimes, error::ParseError> {
3079    frames_from_bytes_phased_located(bytes, num_threads).map_err(|err| err.error)
3080}
3081
3082fn frames_from_bytes_phased_located(
3083    bytes: &[u8],
3084    num_threads: Option<usize>,
3085) -> Result<PhasedByteTimes, error::LocatedParseError> {
3086    #[cfg(feature = "parallel")]
3087    {
3088        // One thread walks the file once. A scan plus a second parse made
3089        // a short file slower than the plain iterator.
3090        if !matches!(num_threads, Some(n) if n >= 2) {
3091            let flt0 = thread_minflt();
3092            let t_work = std::time::Instant::now();
3093            let frames = collect_bytes_located(bytes)?;
3094            let worker_parse_s = t_work.elapsed().as_secs_f64();
3095            let worker_flt = thread_minflt().saturating_sub(flt0);
3096            let n_frames = frames.len() as u64;
3097            return Ok(PhasedByteTimes {
3098                frames,
3099                pool_s: 0.0,
3100                pool_join_s: 0.0,
3101                scan_s: 0.0,
3102                slot_alloc_s: 0.0,
3103                worker_parse_s,
3104                finish_s: 0.0,
3105                worker_sum_s: worker_parse_s,
3106                worker_max_s: worker_parse_s,
3107                chunk_s: vec![worker_parse_s],
3108                scan_kind: SCAN_SERIAL,
3109                scan_minflt: 0,
3110                worker_minflt: vec![worker_flt],
3111                share_frames: vec![n_frames],
3112                share_bytes: vec![bytes.len() as u64],
3113                llc_misses: sample_llc_misses(),
3114                numa_remote: numa_remote_pages(),
3115                pool: None,
3116            });
3117        }
3118        let requested = Some(num_threads.expect("workers"));
3119        let timed = parse_bytes_timed(bytes, requested);
3120        let t_finish = std::time::Instant::now();
3121        let frames = if timed.slots.iter().any(|item| item.is_err()) {
3122            collect_located_slots(timed.slots, &timed.fail_lines)?
3123        } else {
3124            finish_results(timed.pool.as_deref(), timed.slots)
3125                .map_err(|error| error::LocatedParseError::new(error, 0, 0))?
3126        };
3127        let finish_s = t_finish.elapsed().as_secs_f64();
3128        Ok(PhasedByteTimes {
3129            frames,
3130            pool_s: timed.pool_s,
3131            pool_join_s: timed.pool_join_s,
3132            scan_s: timed.scan_s,
3133            slot_alloc_s: timed.slot_alloc_s,
3134            worker_parse_s: timed.worker_parse_s,
3135            finish_s,
3136            worker_sum_s: timed.worker_sum_s,
3137            worker_max_s: timed.worker_max_s,
3138            chunk_s: timed.chunk_s,
3139            scan_kind: timed.scan_kind,
3140            scan_minflt: timed.scan_minflt,
3141            worker_minflt: timed.worker_minflt,
3142            share_frames: timed.share_frames,
3143            share_bytes: timed.share_bytes,
3144            llc_misses: timed.llc_misses,
3145            numa_remote: timed.numa_remote,
3146            pool: timed.pool,
3147        })
3148    }
3149    #[cfg(not(feature = "parallel"))]
3150    {
3151        let _ = num_threads;
3152        let t_work = std::time::Instant::now();
3153        let frames = collect_bytes_located(bytes)?;
3154        let worker_parse_s = t_work.elapsed().as_secs_f64();
3155        Ok(PhasedByteTimes {
3156            frames,
3157            pool_s: 0.0,
3158            pool_join_s: 0.0,
3159            scan_s: 0.0,
3160            slot_alloc_s: 0.0,
3161            worker_parse_s,
3162            finish_s: 0.0,
3163            worker_sum_s: worker_parse_s,
3164            worker_max_s: worker_parse_s,
3165            chunk_s: vec![worker_parse_s],
3166            scan_kind: 0,
3167            scan_minflt: 0,
3168            worker_minflt: Vec::new(),
3169            share_frames: Vec::new(),
3170            share_bytes: Vec::new(),
3171            llc_misses: -1,
3172            numa_remote: 0,
3173        })
3174    }
3175}
3176
3177/// Copy every frame's coordinates into one row-major buffer.
3178///
3179/// `None` means the frames do not share one atom count. `workers >= 2`
3180/// writes disjoint frame runs. The copy is bit-identical to reading
3181/// each frame's `f64` positions in order.
3182pub fn pack_position_slab(frames: &[types::ConFrame], workers: usize) -> Option<Vec<f64>> {
3183    if frames.is_empty() {
3184        return None;
3185    }
3186    let n_atoms = frames[0].positions.nrows();
3187    if frames
3188        .iter()
3189        .any(|frame| frame.positions.nrows() != n_atoms)
3190    {
3191        return None;
3192    }
3193    let stride = n_atoms.saturating_mul(3);
3194    let total = frames.len().saturating_mul(stride);
3195    let mut values = Vec::<f64>::with_capacity(total);
3196    if total > 0 {
3197        // The parallel writers are the first touch of these pages.
3198        // A zeroed allocation would fault the whole slab on this thread.
3199        // SAFETY: every index is overwritten before the buffer is read.
3200        unsafe {
3201            values.set_len(total);
3202        }
3203    }
3204    if stride == 0 {
3205        return Some(values);
3206    }
3207    let write = |frame: &types::ConFrame, dest: &mut [f64]| {
3208        if let Some(src) = frame.positions.f64_slice() {
3209            dest.copy_from_slice(src);
3210        } else {
3211            for (row, slot) in dest.chunks_exact_mut(3).enumerate() {
3212                slot.copy_from_slice(&frame.positions.as_f64_row(row));
3213            }
3214        }
3215    };
3216    #[cfg(feature = "parallel")]
3217    {
3218        if workers >= 2 {
3219            let chunk_frames = frames.len().div_ceil(workers).max(1);
3220            let chunk_vals = chunk_frames.saturating_mul(stride);
3221            let copy_chunks = |dest_all: &mut [f64]| {
3222                use rayon::prelude::*;
3223                dest_all
3224                    .par_chunks_mut(chunk_vals)
3225                    .enumerate()
3226                    .for_each(|(chunk_index, dest)| {
3227                        let base = chunk_index * chunk_frames;
3228                        for (offset, slot) in dest.chunks_exact_mut(stride).enumerate() {
3229                            let index = base + offset;
3230                            if index < frames.len() {
3231                                write(&frames[index], slot);
3232                            }
3233                        }
3234                    });
3235            };
3236            // `par_chunks_mut` follows the installed pool. Outside one,
3237            // a private scope of `workers` threads keeps the global pool
3238            // out of this phase.
3239            if rayon::current_thread_index().is_some() {
3240                first_touch_f64(&mut values, workers);
3241                copy_chunks(&mut values);
3242            } else {
3243                first_touch_f64(&mut values, workers);
3244                std::thread::scope(|scope| {
3245                    let mut rest = &mut values[..];
3246                    let mut base = 0usize;
3247                    let mut handles = Vec::new();
3248                    while base < frames.len() {
3249                        let take_frames = chunk_frames.min(frames.len() - base);
3250                        let take_vals = take_frames * stride;
3251                        let (dest, tail) = rest.split_at_mut(take_vals);
3252                        rest = tail;
3253                        let frame_slice = &frames[base..base + take_frames];
3254                        handles.push(scope.spawn(move || {
3255                            for (frame, slot) in
3256                                frame_slice.iter().zip(dest.chunks_exact_mut(stride))
3257                            {
3258                                write(frame, slot);
3259                            }
3260                        }));
3261                        base += take_frames;
3262                    }
3263                    for handle in handles {
3264                        handle.join().expect("slab worker");
3265                    }
3266                });
3267            }
3268            return Some(values);
3269        }
3270    }
3271    #[cfg(not(feature = "parallel"))]
3272    {
3273        let _ = workers;
3274    }
3275    for (frame, dest) in frames.iter().zip(values.chunks_exact_mut(stride)) {
3276        write(frame, dest);
3277    }
3278    Some(values)
3279}
3280
3281fn mix_touch(hash: u64, value: u64) -> u64 {
3282    hash.wrapping_mul(0x9E3779B185EBCA87) ^ value
3283}
3284
3285fn one_frame_touch(frame: &types::ConFrame, coords: &[f64], index: u64) -> u64 {
3286    let mut hash = index;
3287    for value in frame.header.boxl {
3288        hash = mix_touch(hash, value.to_bits());
3289    }
3290    for atom in &frame.atom_data {
3291        hash = mix_touch(hash, atom.symbol.len() as u64);
3292        for byte in atom.symbol.as_bytes() {
3293            hash = mix_touch(hash, u64::from(*byte));
3294        }
3295    }
3296    for value in coords {
3297        hash = mix_touch(hash, value.to_bits());
3298    }
3299    hash
3300}
3301
3302/// XOR of positions, cell lengths, and atom-type symbols.
3303///
3304/// `positions` is frame-major `[frames, atoms, 3]`. `None` means the
3305/// slice does not match the frames. `workers >= 2` folds disjoint
3306/// frame runs on that many threads. Each frame's hash includes its
3307/// index, and the returned word is the XOR of those hashes.
3308pub fn touch_frame_checksum(
3309    frames: &[types::ConFrame],
3310    positions: &[f64],
3311    workers: usize,
3312) -> Option<u64> {
3313    if frames.is_empty() {
3314        return Some(0);
3315    }
3316    let n_atoms = frames[0].atom_data.len();
3317    let stride = n_atoms.saturating_mul(3);
3318    if stride == 0 || positions.len() != frames.len().saturating_mul(stride) {
3319        return None;
3320    }
3321    if frames.iter().any(|frame| frame.atom_data.len() != n_atoms) {
3322        return None;
3323    }
3324    let one = |index: usize| {
3325        let start = index * stride;
3326        one_frame_touch(
3327            &frames[index],
3328            &positions[start..start + stride],
3329            index as u64,
3330        )
3331    };
3332    Some(reduce_touch(frames.len(), workers, one))
3333}
3334
3335/// [`touch_frame_checksum`] for frames still sitting in optional slots.
3336///
3337/// `None` means a slot is empty or the position slice does not match.
3338pub fn touch_option_checksum(
3339    frames: &[Option<types::ConFrame>],
3340    positions: &[f64],
3341    workers: usize,
3342) -> Option<u64> {
3343    if frames.is_empty() {
3344        return Some(0);
3345    }
3346    if frames.iter().any(Option::is_none) {
3347        return None;
3348    }
3349    let n_atoms = frames[0].as_ref()?.atom_data.len();
3350    let stride = n_atoms.saturating_mul(3);
3351    if stride == 0 || positions.len() != frames.len().saturating_mul(stride) {
3352        return None;
3353    }
3354    if frames
3355        .iter()
3356        .any(|frame| frame.as_ref().map(|item| item.atom_data.len()) != Some(n_atoms))
3357    {
3358        return None;
3359    }
3360    let one = |index: usize| {
3361        let start = index * stride;
3362        one_frame_touch(
3363            frames[index].as_ref().expect("slot"),
3364            &positions[start..start + stride],
3365            index as u64,
3366        )
3367    };
3368    Some(reduce_touch(frames.len(), workers, one))
3369}
3370
3371fn reduce_touch(n: usize, workers: usize, one: impl Fn(usize) -> u64 + Sync) -> u64 {
3372    #[cfg(feature = "parallel")]
3373    if workers >= 2 {
3374        let fold_range = |first: usize, last: usize| {
3375            let mut acc = 0u64;
3376            for index in first..last {
3377                acc ^= one(index);
3378            }
3379            acc
3380        };
3381        let chunk = n.div_ceil(workers).max(1);
3382        // Reuse the installed read pool. A fresh pool per touch is the
3383        // build this phase used to pay on every call.
3384        if rayon::current_thread_index().is_some() {
3385            use rayon::prelude::*;
3386            return (0..workers)
3387                .into_par_iter()
3388                .map(|worker| {
3389                    let first = worker * chunk;
3390                    let last = (first + chunk).min(n);
3391                    fold_range(first, last)
3392                })
3393                .reduce(|| 0u64, |left, right| left ^ right);
3394        }
3395        let mut acc = 0u64;
3396        std::thread::scope(|scope| {
3397            let mut handles = Vec::with_capacity(workers);
3398            for worker in 0..workers {
3399                handles.push(scope.spawn(move || {
3400                    let first = worker * chunk;
3401                    let last = (first + chunk).min(n);
3402                    fold_range(first, last)
3403                }));
3404            }
3405            for handle in handles {
3406                acc ^= handle.join().unwrap_or(0);
3407            }
3408        });
3409        return acc;
3410    }
3411    #[cfg(not(feature = "parallel"))]
3412    {
3413        let _ = workers;
3414    }
3415    (0..n).map(one).fold(0, |acc, item| acc ^ item)
3416}
3417
3418/// Time a newline scan of `text`.
3419///
3420/// `chunked` with `threads >= 2` splits the buffer into that many
3421/// chunks, stitches the newline index, and walks frame starts. The
3422/// pool is built before the timer starts. The serial walk is
3423/// [`frame_start_offsets_serial`]. The returned offsets are the frame
3424/// starts. The caller chooses the worker count. File length does not.
3425pub fn measure_newline_scan(text: &str, threads: usize, chunked: bool) -> (Vec<usize>, f64) {
3426    #[cfg(feature = "parallel")]
3427    {
3428        if chunked && threads >= 2 {
3429            let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build();
3430            let t_scan = std::time::Instant::now();
3431            let offsets = match pool {
3432                Ok(pool) => pool.install(|| {
3433                    let newlines = newline_positions(text.as_bytes(), threads);
3434                    frame_start_offsets_indexed(text, newlines)
3435                }),
3436                Err(_) => {
3437                    let newlines = newline_positions(text.as_bytes(), threads);
3438                    frame_start_offsets_indexed(text, newlines)
3439                }
3440            };
3441            return (offsets, t_scan.elapsed().as_secs_f64());
3442        }
3443    }
3444    #[cfg(not(feature = "parallel"))]
3445    {
3446        let _ = (threads, chunked);
3447    }
3448    let t_scan = std::time::Instant::now();
3449    let offsets = frame_start_offsets_serial(text);
3450    (offsets, t_scan.elapsed().as_secs_f64())
3451}
3452
3453/// Parallel parse that keeps the file line of the first error.
3454/// Workers return the error. They do not write thread-local error state.
3455#[cfg(feature = "parallel")]
3456fn collect_parallel_located(
3457    file_contents: &str,
3458    num_threads: Option<usize>,
3459) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
3460    use rayon::prelude::*;
3461
3462    let boundaries = frame_start_offsets(file_contents);
3463    let parse_chunks = || -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
3464        let num_frames = boundaries.len();
3465        let parts: Vec<Result<types::ConFrame, error::LocatedParseError>> = (0..num_frames)
3466            .into_par_iter()
3467            .map(|i| {
3468                let start = boundaries[i];
3469                let end = if i + 1 < num_frames {
3470                    boundaries[i + 1]
3471                } else {
3472                    file_contents.len()
3473                };
3474                let chunk = &file_contents[start..end];
3475                let mut iter = ConFrameIterator::new(chunk);
3476                match iter.next() {
3477                    Some(Ok(frame)) => Ok(frame),
3478                    Some(Err(e)) => {
3479                        let local = iter.lines.line_no();
3480                        let base = file_line_at(file_contents, start);
3481                        let cursor = if local == 0 { base } else { base + local - 1 };
3482                        Err(error::LocatedParseError::new(e, cursor, i as u64))
3483                    }
3484                    None => Err(error::LocatedParseError::new(
3485                        error::ParseError::IncompleteFrame,
3486                        file_line_at(file_contents, start),
3487                        i as u64,
3488                    )),
3489                }
3490            })
3491            .collect();
3492        let mut frames = Vec::with_capacity(parts.len());
3493        for part in parts {
3494            frames.push(part?);
3495        }
3496        Ok(frames)
3497    };
3498    match num_threads {
3499        None => parse_chunks(),
3500        Some(n) => {
3501            let n = n.max(1);
3502            match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
3503                Ok(pool) => pool.install(parse_chunks),
3504                Err(_) => parse_chunks(),
3505            }
3506        }
3507    }
3508}
3509
3510#[cfg(all(test, feature = "parallel"))]
3511mod parallel_strong_scale_tests {
3512    use super::*;
3513    use std::path::PathBuf;
3514
3515    fn multi_frame_fixture() -> String {
3516        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con");
3517        let one = std::fs::read_to_string(p).expect("fixture");
3518        // Enough frames for >1 worker to exercise the pool.
3519        one.repeat(8)
3520    }
3521
3522    fn sequential_frames(text: &str) -> Vec<types::ConFrame> {
3523        ConFrameIterator::new(text)
3524            .map(|r| r.expect("seq frame"))
3525            .collect()
3526    }
3527
3528    fn frames_payload_key(f: &types::ConFrame) -> (usize, Vec<(String, f64, f64, f64)>) {
3529        let atoms: Vec<_> = f
3530            .atom_data
3531            .iter()
3532            .map(|a| (a.symbol.to_string(), a.x, a.y, a.z))
3533            .collect();
3534        (f.atom_data.len(), atoms)
3535    }
3536
3537    #[test]
3538    fn parallel_workers_match_sequential_payloads() {
3539        let text = multi_frame_fixture();
3540        let seq = sequential_frames(&text);
3541        assert!(seq.len() >= 8);
3542        let seq_keys: Vec<_> = seq.iter().map(frames_payload_key).collect();
3543
3544        for workers in [1usize, 2, 4] {
3545            let par = parse_frames_parallel_with_threads(&text, Some(workers));
3546            assert_eq!(par.len(), seq.len(), "workers={workers}");
3547            let par_keys: Vec<_> = par
3548                .into_iter()
3549                .map(|r| frames_payload_key(&r.expect("par frame")))
3550                .collect();
3551            assert_eq!(par_keys, seq_keys, "workers={workers} frame payloads");
3552        }
3553
3554        // Global pool path agrees too.
3555        let par_default = parse_frames_parallel(&text);
3556        let def_keys: Vec<_> = par_default
3557            .into_iter()
3558            .map(|r| frames_payload_key(&r.expect("par")))
3559            .collect();
3560        assert_eq!(def_keys, seq_keys);
3561    }
3562
3563    fn live_threads() -> usize {
3564        std::fs::read_to_string("/proc/self/status")
3565            .unwrap()
3566            .lines()
3567            .find(|line| line.starts_with("Threads:"))
3568            .unwrap()
3569            .split_whitespace()
3570            .nth(1)
3571            .unwrap()
3572            .parse()
3573            .unwrap()
3574    }
3575
3576    /// A file past the old map and pool gates stays on this thread.
3577    #[test]
3578    fn default_read_of_a_wide_file_stays_on_the_calling_thread() {
3579        const CHILD: &str = "READCON_DEFAULT_THREAD_CHILD";
3580        const NAME: &str = "iterators::parallel_strong_scale_tests::default_read_of_a_wide_file_stays_on_the_calling_thread";
3581        if std::env::var_os(CHILD).is_none() {
3582            let out = std::process::Command::new(std::env::current_exe().unwrap())
3583                .args([NAME, "--exact", "--test-threads=1"])
3584                .env(CHILD, "1")
3585                .output()
3586                .unwrap();
3587            let stdout = String::from_utf8_lossy(&out.stdout);
3588            assert!(
3589                out.status.success() && stdout.contains("test result: ok. 1 passed;"),
3590                "{stdout}{}",
3591                String::from_utf8_lossy(&out.stderr)
3592            );
3593            return;
3594        }
3595        let dir = tempfile::tempdir().unwrap();
3596        let path = dir.path().join("wide.con");
3597        let one = std::fs::read_to_string(
3598            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con"),
3599        )
3600        .unwrap();
3601        std::fs::write(&path, one.repeat(200)).unwrap();
3602        assert!(std::fs::metadata(&path).unwrap().len() > 64 * 1024);
3603        let before = live_threads();
3604        let frames = super::read_all_frames(&path).unwrap();
3605        let after = live_threads();
3606        assert_eq!(before, after, "default read started worker threads");
3607        assert!(frames.len() > 1);
3608        let text = std::fs::read_to_string(&path).unwrap();
3609        let sequential = super::frames_from_text(&text, None).unwrap();
3610        assert_eq!(sequential.len(), frames.len());
3611        assert_eq!(
3612            sequential[0].atom_data[0].x, frames[0].atom_data[0].x,
3613            "default read changed a coordinate"
3614        );
3615    }
3616}
3617
3618#[cfg(all(test, feature = "parallel"))]
3619mod parallel_header_scan_tests {
3620    use super::*;
3621    use std::path::PathBuf;
3622
3623    fn fixture(name: &str) -> String {
3624        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3625            .join("resources/test")
3626            .join(name);
3627        std::fs::read_to_string(path).expect(name)
3628    }
3629
3630    /// A cut on the byte between `\r` and `\n`. The `\n` is in the next chunk.
3631    fn assert_crlf_gap_cut(text: &str) {
3632        let bytes = text.as_bytes();
3633        let mut found = 0usize;
3634        let mut i = 0usize;
3635        while i + 1 < bytes.len() && found < 6 {
3636            if bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
3637                let cut = i + 1;
3638                assert_eq!(bytes[cut - 1], b'\r');
3639                assert_eq!(bytes[cut], b'\n');
3640                assert_cuts_match(text, &[0, cut, bytes.len()]);
3641                found += 1;
3642                i += 2;
3643                continue;
3644            }
3645            i += 1;
3646        }
3647        assert!(found > 0, "fixture has no CRLF pair");
3648    }
3649
3650    fn assert_cuts_match(text: &str, cuts: &[usize]) {
3651        let serial_nl: Vec<usize> = memchr::memchr_iter(b'\n', text.as_bytes()).collect();
3652        let stitched = newline_positions_at_cuts(text.as_bytes(), cuts);
3653        assert_eq!(stitched, serial_nl, "newline stitch at {cuts:?}");
3654        let serial = frame_start_offsets_serial(text);
3655        let parallel = frame_start_offsets_at_cuts(text, cuts);
3656        assert_eq!(parallel, serial, "frame offsets at {cuts:?}");
3657    }
3658
3659    fn header_straddle_cuts(text: &str) -> Vec<Vec<usize>> {
3660        let serial = frame_start_offsets_serial(text);
3661        let bytes = text.as_bytes();
3662        let mut out = Vec::new();
3663        // Equal splits, plus a cut forced into a header line of every frame.
3664        for n in [2usize, 3, 4, 8] {
3665            out.push(equal_cuts(bytes.len(), n));
3666        }
3667        for (index, &start) in serial.iter().enumerate() {
3668            let next = serial.get(index + 1).copied().unwrap_or(bytes.len());
3669            // The header ends at the coordinate label. The window is that
3670            // header, not a fixed count of bytes past the frame start.
3671            let window = super::header_window_end(bytes, start, next);
3672            let mut line = start;
3673            while line < window {
3674                let Some(rel) = memchr::memchr(b'\n', &bytes[line..window]) else {
3675                    break;
3676                };
3677                let end = line + rel;
3678                if end - line > 1 {
3679                    let mid = line + (end - line) / 2;
3680                    // mid is inside the line, so the header byte sits across two chunks.
3681                    assert!(mid > line && mid < end);
3682                    out.push(vec![0, mid, bytes.len()]);
3683                }
3684                line = end + 1;
3685            }
3686        }
3687        out
3688    }
3689
3690    fn assert_slab(text: &str) {
3691        assert!(!text.is_empty());
3692        for cuts in header_straddle_cuts(text) {
3693            assert_cuts_match(text, &cuts);
3694        }
3695        let bare = text.trim_end_matches(['\n', '\r']);
3696        if !bare.is_empty() {
3697            assert_cuts_match(bare, &equal_cuts(bare.len(), 4));
3698            let mid = bare.len() / 2;
3699            let line_start = bare[..mid].rfind('\n').map(|i| i + 1).unwrap_or(0);
3700            let line_end = bare[mid..]
3701                .find('\n')
3702                .map(|i| mid + i)
3703                .unwrap_or(bare.len());
3704            if line_end - line_start > 1 {
3705                let cut = line_start + (line_end - line_start) / 2;
3706                assert_cuts_match(bare, &[0, cut, bare.len()]);
3707            }
3708        }
3709        let tailed = format!("{text}\n   \n\t\n");
3710        assert_cuts_match(
3711            &tailed,
3712            &[0, 3, tailed.len() / 2, tailed.len() - 1, tailed.len()],
3713        );
3714        let crlf = text.replace('\n', "\r\n");
3715        assert_cuts_match(&crlf, &equal_cuts(crlf.len(), 5));
3716        assert_crlf_gap_cut(&crlf);
3717        let serial = frame_start_offsets_serial(text);
3718        if serial.len() >= 2 {
3719            let second = serial[1];
3720            let next = serial.get(2).copied().unwrap_or(text.len());
3721            let broken_at = second + (next - second) / 2;
3722            let broken = &text[..broken_at.max(1)];
3723            assert_cuts_match(broken, &[0, 1.max(broken.len() / 3), broken.len()]);
3724        }
3725    }
3726
3727    #[test]
3728    fn chunked_header_scan_matches_serial_offsets() {
3729        assert_eq!(frame_start_offsets_serial(""), frame_start_offsets(""));
3730        assert_cuts_match("\n", &[0, 1]);
3731        assert_cuts_match("   \n\n", &[0, 2, 5]);
3732        for name in [
3733            "tiny_cuh2.con",
3734            "tiny_cuh2.convel",
3735            "tiny_cuh2_forces.con",
3736            "tiny_cuh2_vel_forces.con",
3737            "cuh2.con",
3738        ] {
3739            let one = fixture(name);
3740            assert_slab(&one);
3741            assert_slab(&one.repeat(3));
3742        }
3743        // The bytes parser (the read_con path) uses the same stitch.
3744        // The single frame is the short file. The repeat is a wider file.
3745        // Both use the requested worker count. There is no length cutoff.
3746        let one = fixture("cuh2.con");
3747        for wide in [one.clone(), one.repeat(8)] {
3748            let serial = frame_start_offsets_serial(&wide);
3749            for workers in [2usize, 3, 4, 8] {
3750                let parallel = frame_start_offsets_with_threads(&wide, Some(workers));
3751                assert_eq!(parallel, serial, "workers={workers} bytes={}", wide.len());
3752                let parsed =
3753                    frames_from_bytes(wide.as_bytes(), Some(workers)).expect("bytes parse");
3754                assert_eq!(parsed.len(), serial.len());
3755                let seq = frames_from_text(&wide, Some(1)).expect("serial parse");
3756                assert_eq!(parsed.len(), seq.len());
3757                assert_eq!(parsed[0].atom_data[0].x, seq[0].atom_data[0].x);
3758                assert_eq!(
3759                    parsed.last().unwrap().atom_data.last().unwrap().z,
3760                    seq.last().unwrap().atom_data.last().unwrap().z
3761                );
3762            }
3763        }
3764    }
3765}
3766
3767/// End of the header window used by straddle cuts.
3768///
3769/// The header runs up to the line that starts the coordinate block.
3770/// When that label is absent the window is the next frame start.
3771#[cfg(all(test, feature = "parallel"))]
3772fn header_window_end(bytes: &[u8], start: usize, limit: usize) -> usize {
3773    let limit = limit.min(bytes.len());
3774    let marker = b"Coordinates";
3775    let mut line = start;
3776    while line < limit {
3777        let rest = &bytes[line..limit];
3778        let rel = memchr::memchr(b'\n', rest).unwrap_or(rest.len());
3779        let content_end = line + rel;
3780        let content = &bytes[line..content_end];
3781        let trimmed = match content.iter().position(|byte| !byte.is_ascii_whitespace()) {
3782            Some(index) => &content[index..],
3783            None => b"",
3784        };
3785        if trimmed.starts_with(marker) {
3786            return line;
3787        }
3788        if content_end >= limit {
3789            break;
3790        }
3791        line = content_end + 1;
3792    }
3793    limit
3794}
3795
3796#[cfg(all(test, feature = "parallel"))]
3797mod parallel_frame_parse_tests {
3798    use super::*;
3799    use std::path::PathBuf;
3800
3801    const ZERO_ATOM: &str = "\
3802zero-atom frame
3803{\"con_spec_version\":3,\"con_spec_minor\":1,\"units\":{\"length\":\"angstrom\",\"energy\":\"eV\"}}
380410.0 10.0 10.0
380590.0 90.0 90.0
3806
3807
38080
3809
3810
3811";
3812
3813    fn fixture(name: &str) -> String {
3814        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3815            .join("resources/test")
3816            .join(name);
3817        std::fs::read_to_string(path).expect(name)
3818    }
3819
3820    fn serial_results(text: &str) -> Vec<Result<types::ConFrame, error::ParseError>> {
3821        ConFrameIterator::new(text).collect()
3822    }
3823
3824    fn written(frame: &types::ConFrame) -> Vec<u8> {
3825        let mut out = Vec::new();
3826        {
3827            let mut writer = crate::writer::ConFrameWriter::new(&mut out);
3828            writer.write_frame(frame).expect("write");
3829            writer.flush().expect("flush");
3830        }
3831        out
3832    }
3833
3834    fn assert_same_results(
3835        label: &str,
3836        serial: Vec<Result<types::ConFrame, error::ParseError>>,
3837        parallel: Vec<Result<types::ConFrame, error::ParseError>>,
3838    ) {
3839        assert_eq!(serial.len(), parallel.len(), "{label} count");
3840        for (i, (left, right)) in serial.into_iter().zip(parallel).enumerate() {
3841            match (left, right) {
3842                (Ok(a), Ok(b)) => {
3843                    assert_eq!(written(&a), written(&b), "{label} bytes frame {i}");
3844                    assert_eq!(a, b, "{label} fields frame {i}");
3845                }
3846                (Err(a), Err(b)) => {
3847                    assert_eq!(
3848                        format!("{a:?}"),
3849                        format!("{b:?}"),
3850                        "{label} error frame {i}"
3851                    );
3852                }
3853                (Ok(_), Err(err)) => panic!("{label} frame {i} serial ok parallel {err:?}"),
3854                (Err(err), Ok(_)) => panic!("{label} frame {i} serial {err:?} parallel ok"),
3855            }
3856        }
3857    }
3858
3859    fn assert_workers(label: &str, text: &str) {
3860        let serial_ok = serial_results(text).iter().all(|item| item.is_ok());
3861        for workers in [1usize, 2, 3, 4, 8] {
3862            let parallel = parse_frames_parallel_with_threads(text, Some(workers));
3863            assert_same_results(
3864                &format!("{label} workers {workers}"),
3865                serial_results(text),
3866                parallel,
3867            );
3868            let bounds = collect_frame_starts(ConFrameIterator::new(text), true);
3869            let at = parse_frames_at_offsets(text, &bounds, Some(workers));
3870            assert_same_results(
3871                &format!("{label} offsets workers {workers}"),
3872                serial_results(text),
3873                at,
3874            );
3875        }
3876        if serial_ok {
3877            for workers in [2usize, 3, 8] {
3878                let bytes = frames_from_bytes(text.as_bytes(), Some(workers)).expect(label);
3879                let seq = frames_from_text(text, Some(1)).expect(label);
3880                assert_eq!(bytes.len(), seq.len(), "{label} byte count");
3881                for (i, (a, b)) in bytes.iter().zip(seq.iter()).enumerate() {
3882                    assert_eq!(written(a), written(b), "{label} byte path frame {i}");
3883                    assert_eq!(a, b, "{label} byte path fields frame {i}");
3884                }
3885            }
3886        }
3887    }
3888
3889    fn crlf_gap_offsets(text: &str) -> Vec<usize> {
3890        let bytes = text.as_bytes();
3891        let mut cut = None;
3892        let mut i = 0usize;
3893        while i + 1 < bytes.len() {
3894            if bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
3895                cut = Some(i + 1);
3896                break;
3897            }
3898            i += 1;
3899        }
3900        let cut = cut.expect("crlf");
3901        frame_start_offsets_at_cuts(text, &[0, cut, bytes.len()])
3902    }
3903
3904    #[test]
3905    fn parallel_slots_match_serial_frames() {
3906        assert_workers("empty", "");
3907        assert_workers("blank", "\n");
3908        assert_workers("space", "   \n\n");
3909        assert_workers("zero", ZERO_ATOM);
3910        assert_workers("zero-repeat", &ZERO_ATOM.repeat(5));
3911        for name in [
3912            "tiny_cuh2.con",
3913            "tiny_cuh2.convel",
3914            "tiny_cuh2_forces.con",
3915            "tiny_cuh2_vel_forces.con",
3916            "tiny_cuh2_charges_spins_magmoms.con",
3917            "tiny_cuh2_displacements.con",
3918            "tiny_cuh2_spreads.con",
3919            "cuh2.con",
3920        ] {
3921            let one = fixture(name);
3922            assert_workers(name, &one);
3923            assert_workers(&format!("{name} x5"), &one.repeat(5));
3924            let bare = one.trim_end_matches(['\n', '\r']);
3925            assert_workers(&format!("{name} bare"), bare);
3926            let tailed = format!("{one}\n   \n\t\n");
3927            assert_workers(&format!("{name} tail"), &tailed);
3928            let crlf = one.replace('\n', "\r\n");
3929            assert_workers(&format!("{name} crlf"), &crlf);
3930            let gap = crlf_gap_offsets(&crlf);
3931            assert_eq!(
3932                gap,
3933                frame_start_offsets_serial(&crlf),
3934                "{name} crlf gap offsets"
3935            );
3936            let parsed = parse_frames_at_offsets(&crlf, &gap, Some(4));
3937            assert_same_results(
3938                &format!("{name} crlf gap parse"),
3939                serial_results(&crlf),
3940                parsed,
3941            );
3942            let serial_offs = frame_start_offsets_serial(&one);
3943            let broken: &str = if serial_offs.len() >= 2 {
3944                let second = serial_offs[1];
3945                let next = serial_offs.get(2).copied().unwrap_or(one.len());
3946                &one[..second + (next - second) / 2]
3947            } else if one.len() > 8 {
3948                &one[..one.len() / 2]
3949            } else {
3950                ""
3951            };
3952            if !broken.is_empty() {
3953                let serial_err = frames_from_text(broken, Some(1))
3954                    .err()
3955                    .map(|e| format!("{e:?}"));
3956                assert!(serial_err.is_some(), "{name} chopped still parsed");
3957                for workers in [1usize, 2, 4, 8] {
3958                    let parallel_err = frames_from_text(broken, Some(workers))
3959                        .err()
3960                        .map(|e| format!("{e:?}"));
3961                    assert_eq!(parallel_err, serial_err, "{name} chopped workers {workers}");
3962                    let byte_err = frames_from_bytes(broken.as_bytes(), Some(workers))
3963                        .err()
3964                        .map(|e| format!("{e:?}"));
3965                    assert_eq!(
3966                        byte_err, serial_err,
3967                        "{name} chopped bytes workers {workers}"
3968                    );
3969                }
3970            }
3971        }
3972    }
3973}
3974
3975#[cfg(all(test, feature = "parallel"))]
3976mod phased_read_tests {
3977    use super::*;
3978    use std::path::PathBuf;
3979
3980    fn fixture(name: &str) -> String {
3981        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3982            .join("resources/test")
3983            .join(name);
3984        std::fs::read_to_string(path).expect(name)
3985    }
3986
3987    #[test]
3988    fn phased_bytes_match_serial_frames() {
3989        let text = fixture("cuh2.con").repeat(4);
3990        let serial = frames_from_text(&text, Some(1)).expect("serial");
3991        for workers in [1usize, 2, 4, 8] {
3992            let phased = frames_from_bytes_phased(text.as_bytes(), Some(workers)).expect("phased");
3993            let parallel = phased.frames;
3994            assert_eq!(parallel.len(), serial.len(), "workers={workers}");
3995            assert!(
3996                phased.scan_s >= 0.0 && phased.slot_alloc_s >= 0.0 && phased.worker_parse_s >= 0.0
3997            );
3998            assert_eq!(parallel[0], serial[0], "workers={workers} first");
3999            assert_eq!(
4000                parallel.last().unwrap(),
4001                serial.last().unwrap(),
4002                "workers={workers} last"
4003            );
4004            let slab = pack_position_slab(&serial, workers).expect("slab");
4005            let stride = serial[0].positions.nrows() * 3;
4006            let src = serial[0].positions.f64_slice().expect("f64");
4007            assert_eq!(&slab[..stride], src);
4008            let (chunked, _) = measure_newline_scan(&text, workers.max(2), true);
4009            let (plain, _) = measure_newline_scan(&text, 1, false);
4010            if workers >= 2 {
4011                assert_eq!(chunked, plain);
4012            }
4013        }
4014    }
4015
4016    #[test]
4017    fn phased_file_matches_serial_and_maps_a_wide_file() {
4018        let text = fixture("cuh2.con").repeat(5);
4019        let dir = std::env::temp_dir();
4020        let path = dir.join("readcon-phased-wide.con");
4021        std::fs::write(&path, &text).expect("write");
4022        let phased = read_all_frames_phased(&path, Some(4)).expect("phased");
4023        let serial = frames_from_text(&text, Some(1)).expect("serial");
4024        assert_eq!(phased.frames, serial);
4025        assert!(phased.phases.mapped, "a file past 64 KiB is mapped");
4026        assert!(phased.phases.worker_parse_s >= 0.0);
4027        assert_eq!(phased.n_bytes, text.len());
4028        let _ = std::fs::remove_file(&path);
4029    }
4030
4031    #[test]
4032    fn equal_span_boundaries_match_the_serial_walk() {
4033        let text = fixture("cuh2.con").repeat(8);
4034        let bytes = text.as_bytes();
4035        let got = equal_span_boundaries(bytes, 4).expect("equal span");
4036        let expect = collect_frame_starts(ConFrameIterator::from_bytes(bytes), true);
4037        assert_eq!(got, expect);
4038        assert_eq!(got.len(), 8);
4039        let phased = frames_from_bytes_phased(bytes, Some(4)).expect("phased");
4040        assert_eq!(phased.scan_kind, SCAN_EQUAL_SPAN);
4041        assert_eq!(
4042            phased.frames,
4043            frames_from_text(&text, Some(1)).expect("serial")
4044        );
4045        let tailed = format!("{text}\n\n  \n");
4046        let tail_phased = frames_from_bytes_phased(tailed.as_bytes(), Some(4)).expect("tail");
4047        assert_eq!(tail_phased.scan_kind, SCAN_EQUAL_SPAN);
4048        assert_eq!(
4049            tail_phased.frames,
4050            frames_from_text(&tailed, Some(1)).expect("tail serial")
4051        );
4052    }
4053
4054    #[test]
4055    fn a_whitespace_tail_uses_the_equal_span_walk() {
4056        let mut text = fixture("cuh2.con").repeat(3);
4057        text.push('\n');
4058        assert!(equal_span_boundaries(text.as_bytes(), 4).is_some());
4059        let serial = frames_from_text(&text, Some(1)).expect("serial");
4060        let parallel = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("phased");
4061        assert_eq!(parallel.scan_kind, SCAN_EQUAL_SPAN);
4062        assert_eq!(parallel.frames, serial);
4063    }
4064
4065    #[test]
4066    fn touch_checksum_matches_across_worker_counts() {
4067        let text = fixture("cuh2.con").repeat(4);
4068        let serial = frames_from_bytes_phased(text.as_bytes(), Some(1)).expect("serial");
4069        let parallel = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("parallel");
4070        let slab_s = pack_position_slab(&serial.frames, 1).expect("slab");
4071        let slab_p = pack_position_slab(&parallel.frames, 4).expect("slab");
4072        let left = touch_frame_checksum(&serial.frames, &slab_s, 1).expect("touch");
4073        let right = touch_frame_checksum(&parallel.frames, &slab_p, 4).expect("touch");
4074        assert_eq!(left, right);
4075        assert_ne!(left, 0);
4076    }
4077}
4078
4079#[cfg(test)]
4080mod later_frame_location_tests {
4081    use super::*;
4082    use std::fs;
4083
4084    fn one_atom(coord: &str) -> String {
4085        format!(
4086            "seed\n{{\"con_spec_version\":2}}\n10 10 10\n90 90 90\n0 0\n0 0 0\n1\n1\n1.0\nH\nCoordinates of Component 1\n{coord}\n"
4087        )
4088    }
4089
4090    fn bad_count_frame() -> String {
4091        "seed\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\n0 0\n0 0 0\n1\n-1\n1.0\nH\nCoordinates of Component 1\n0 0 0 0 0\n".into()
4092    }
4093
4094    fn two_frames() -> String {
4095        format!("{}{}", one_atom("0 0 0 0 0"), bad_count_frame())
4096    }
4097
4098    fn assert_later_count(
4099        err: &error::ParseError,
4100        line: u32,
4101        frame: u64,
4102        iter: &ConFrameIterator<'_>,
4103    ) {
4104        assert_eq!(err.class_name(), "InvalidCount", "{err}");
4105        assert_eq!(iter.lines.line_no(), line, "{err}");
4106        assert_eq!(iter.frame_index(), frame, "{err}");
4107    }
4108
4109    fn assert_first_pass(err: Box<dyn std::error::Error>) {
4110        let located = err
4111            .downcast_ref::<error::LocatedParseError>()
4112            .unwrap_or_else(|| panic!("first pass lost the location: {err}"));
4113        assert_eq!(located.error.class_name(), "InvalidCount", "{located:?}");
4114        assert_eq!(located.line, 20, "{located:?}");
4115        assert_eq!(located.frame_index, 1, "{located:?}");
4116    }
4117
4118    fn take_err<T>(result: Result<T, Box<dyn std::error::Error>>) -> Box<dyn std::error::Error> {
4119        match result {
4120            Err(err) => err,
4121            Ok(_) => panic!("expected a parse error"),
4122        }
4123    }
4124
4125    /// The batch readers report the later frame from the parse that failed.
4126    /// A second open of the path is not required.
4127    #[test]
4128    fn batch_readers_keep_the_later_frame_on_the_first_pass() {
4129        let path = std::env::temp_dir().join(format!(
4130            "readcon-later-frame-first-{}.con",
4131            std::process::id()
4132        ));
4133        fs::write(&path, two_frames()).unwrap();
4134        for threads in [None, Some(1), Some(2)] {
4135            assert_first_pass(take_err(read_all_frames_with_threads(&path, threads)));
4136            assert_first_pass(take_err(read_all_frames_phased(&path, threads)));
4137            assert_first_pass(take_err(read_positions_with_threads(&path, threads)));
4138        }
4139        let _ = fs::remove_file(&path);
4140    }
4141
4142    /// An error in the second frame keeps that frame's index and file line.
4143    #[test]
4144    fn next_forward_and_skip_locate_the_later_frame() {
4145        let text = two_frames();
4146
4147        let mut iter = ConFrameIterator::new(&text);
4148        assert!(iter.next().unwrap().is_ok());
4149        let err = iter.next().unwrap().unwrap_err();
4150        assert_later_count(&err, 20, 1, &iter);
4151
4152        let mut iter = ConFrameIterator::new(&text);
4153        assert!(iter.forward().unwrap().is_ok());
4154        let err = iter.forward().unwrap().unwrap_err();
4155        assert_later_count(&err, 20, 1, &iter);
4156
4157        let mut iter = ConFrameIterator::new(&text);
4158        let err = iter.skip_frames(3).unwrap_err();
4159        assert_later_count(&err, 20, 1, &iter);
4160    }
4161
4162    /// The blank line that opens a velocity block is a file line. A later
4163    /// frame's error must include it. 12 coordinate lines, the separator,
4164    /// and 3 velocity lines put the next frame's line 8 at file line 24.
4165    #[test]
4166    fn skip_after_a_velocity_frame_keeps_the_separator_line() {
4167        let text = format!(
4168            "{}\nH\nVelocities of Component 1\n0 0 0 0 0\n{}",
4169            one_atom("0 0 0 0 0"),
4170            bad_count_frame()
4171        );
4172        let mut iter = ConFrameIterator::new(&text);
4173        assert!(iter.forward().unwrap().is_ok());
4174        let err = iter.forward().unwrap().unwrap_err();
4175        assert_later_count(&err, 24, 1, &iter);
4176    }
4177
4178    #[test]
4179    fn count_frames_locates_the_later_frame() {
4180        let path = std::env::temp_dir().join(format!(
4181            "readcon-later-frame-count-{}.con",
4182            std::process::id()
4183        ));
4184        fs::write(&path, two_frames()).unwrap();
4185        let err = count_frames(&path).unwrap_err();
4186        let _ = fs::remove_file(&path);
4187        match err {
4188            LoadError::Parse(located) => {
4189                assert_eq!(located.error.class_name(), "InvalidCount");
4190                assert_eq!(located.line, 20, "{located}");
4191                assert_eq!(located.frame_index, 1, "{located}");
4192            }
4193            other => panic!("expected a located parse error, got {other}"),
4194        }
4195    }
4196
4197    /// Metadata on line 2 of the second frame is file line 14, not line 2.
4198    /// This is the serial contract the collective reader matches.
4199    #[test]
4200    fn metadata_in_a_later_frame_reports_that_frames_line() {
4201        let bad = "seed\n{not-json\n10 10 10\n90 90 90\n0 0\n0 0 0\n1\n1\n1.0\nH\nCoordinates of Component 1\n0 0 0 0 0\n";
4202        let text = format!("{}{}", one_atom("0 0 0 0 0"), bad);
4203        for threads in [Some(1usize), Some(2)] {
4204            let err = frames_from_text_located(&text, threads).unwrap_err();
4205            assert_eq!(
4206                err.error.class_name(),
4207                "InvalidMetadataJson",
4208                "{threads:?}: {err}"
4209            );
4210            assert_eq!(err.frame_index, 1, "{threads:?}");
4211            assert_eq!(err.line, 14, "{threads:?}: {err}");
4212        }
4213    }
4214
4215    /// Several spans after the first frame fail. Serial collection keeps
4216    /// the earliest slot. The parallel position reader must report that
4217    /// same line and frame index.
4218    ///
4219    /// The first frame is large. A later worker can fail before the
4220    /// calling thread reaches the earliest bad span, which is the case
4221    /// that used to return the later slot.
4222    #[test]
4223    fn parallel_positions_report_the_earliest_failing_slot() {
4224        const SLOW_ATOMS: usize = 20_000;
4225        let slow = counted_frame(SLOW_ATOMS);
4226        let bad = counted_frame(1);
4227        let text = format!("{slow}{bad}{bad}{bad}");
4228        let earliest_line = 1 + slow.bytes().filter(|byte| *byte == b'\n').count() as u32;
4229        let path =
4230            std::env::temp_dir().join(format!("readcon-earliest-slot-{}.con", std::process::id()));
4231        fs::write(&path, &text).unwrap();
4232        assert_earliest_slot(
4233            take_err(read_positions_with_threads(&path, Some(1))),
4234            earliest_line,
4235        );
4236        assert_earliest_slot(
4237            take_err(read_positions_with_threads(&path, Some(2))),
4238            earliest_line,
4239        );
4240        let _ = fs::remove_file(&path);
4241    }
4242
4243    fn counted_frame(natoms: usize) -> String {
4244        let mut body = String::with_capacity(natoms * 10 + 96);
4245        body.push_str("seed\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\n0 0\n0 0 0\n1\n");
4246        body.push_str(&natoms.to_string());
4247        body.push_str("\n1.0\nH\nCoordinates of Component 1\n");
4248        for _ in 0..natoms {
4249            body.push_str("0 0 0 0 0\n");
4250        }
4251        body
4252    }
4253
4254    fn assert_earliest_slot(err: Box<dyn std::error::Error>, line: u32) {
4255        let located = err
4256            .downcast_ref::<error::LocatedParseError>()
4257            .unwrap_or_else(|| panic!("position read lost the location: {err}"));
4258        assert_eq!(located.error.class_name(), "InvalidCount", "{located:?}");
4259        assert_eq!(located.frame_index, 1, "{located:?}");
4260        assert_eq!(located.line, line, "{located:?}");
4261    }
4262}
4263
4264#[cfg(test)]
4265mod frame_equality_tests {
4266    use super::ConFrameIterator;
4267
4268    fn sample_con(frames: usize, long: bool) -> String {
4269        let mut out = String::new();
4270        for f in 0..frames {
4271            let shift = (f as f64) * 1.0e-4;
4272            out.push_str("Generated by benchmark\n");
4273            out.push_str("{\"con_spec_version\":2}\n");
4274            out.push_str("15.3456 21.702 100.0\n");
4275            out.push_str("90.0 90.0 90.0\n");
4276            out.push_str("0 0\n");
4277            out.push_str(&format!("{f} 0 1\n"));
4278            out.push_str("2\n6 2\n63.546 1.00793\n");
4279            out.push_str("Cu\nCoordinates of Component 1\n");
4280            for i in 0..6 {
4281                let x = (i as f64) * 2.557 + 0.6394 + shift;
4282                let y = 0.9045 + shift * 0.5;
4283                let z = (f as f64) * 1.0e-6 + if i == 3 { -1.25 } else { 0.0 };
4284                if long {
4285                    out.push_str(&format!("   {x:.17}    {y:.17}    {z:.17} 0    {i}\n"));
4286                } else {
4287                    out.push_str(&format!("   {x:.6}    {y:.6}    {z:.6} 0    {i}\n"));
4288                }
4289            }
4290            out.push_str("H\nCoordinates of Component 2\n");
4291            out.push_str("   8.6823    9.947   11.733 0  6\n");
4292            out.push_str(&format!("  -7.9421    9.947   {shift:.6} 0  7\n"));
4293        }
4294        out
4295    }
4296
4297    fn sample_convel() -> String {
4298        let mut out = String::new();
4299        for f in 0..3 {
4300            let shift = (f as f64) * 1.0e-4;
4301            out.push_str("Generated by benchmark\n");
4302            out.push_str("{\"con_spec_version\":2}\n");
4303            out.push_str("15.3456 21.702 100.0\n");
4304            out.push_str("90.0 90.0 90.0\n");
4305            out.push_str("0 0\n");
4306            out.push_str(&format!("{f} 0 1\n"));
4307            out.push_str("2\n4 1\n63.546 1.00793\n");
4308            out.push_str("Cu\nCoordinates of Component 1\n");
4309            for i in 0..4 {
4310                let x = (i as f64) * 2.557 + shift;
4311                out.push_str(&format!("   {x:.17}    0.9045    1.0 0    {i}\n"));
4312            }
4313            out.push_str("H\nCoordinates of Component 2\n");
4314            out.push_str("   1.5    2.5    3.5 0  4\n");
4315            out.push_str("\nCu\nVelocities of Component 1\n");
4316            for i in 0..4 {
4317                let vx = (i as f64) * 1.0e-4 - shift;
4318                out.push_str(&format!("   {vx:.17}    -0.5    0.25 0    {i}\n"));
4319            }
4320            out.push_str("H\nVelocities of Component 2\n");
4321            out.push_str("   0.1    -0.2    0.3 0  4\n");
4322        }
4323        out
4324    }
4325
4326    fn reference_f64(token: &str) -> f64 {
4327        let (val, n) = fast_float2::parse_partial::<f64, _>(token).expect(token);
4328        assert_eq!(n, token.len(), "{token}");
4329        val
4330    }
4331
4332    fn assert_rows_match(text: &str, positions: bool) {
4333        let frames: Vec<_> = ConFrameIterator::new(text)
4334            .map(|frame| frame.expect("parse"))
4335            .collect();
4336        assert!(frames.len() >= 2);
4337        let mut frame_i = 0usize;
4338        let mut row = 0usize;
4339        let mut in_section = false;
4340        for line in text.lines() {
4341            let token = line.trim();
4342            if token.is_empty() {
4343                in_section = true;
4344                row = 0;
4345                continue;
4346            }
4347            if token.starts_with("Generated") {
4348                if row > 0 || in_section {
4349                    frame_i += 1;
4350                }
4351                row = 0;
4352                in_section = false;
4353                continue;
4354            }
4355            let cols: Vec<&str> = token.split_whitespace().collect();
4356            if cols.len() < 4 || reference_ok(cols[0]).is_none() {
4357                continue;
4358            }
4359            if cols[3].parse::<u64>().is_err() {
4360                continue;
4361            }
4362            let got = if in_section {
4363                frames[frame_i].atom_data[row].velocity().expect("velocity")
4364            } else if positions {
4365                let p = frames[frame_i].positions.as_f64_row(row);
4366                [p[0], p[1], p[2]]
4367            } else {
4368                continue;
4369            };
4370            for (k, token) in cols.iter().take(3).enumerate() {
4371                assert_eq!(
4372                    got[k].to_bits(),
4373                    reference_f64(token).to_bits(),
4374                    "frame {frame_i} row {row} col {k} token {token} section {in_section}"
4375                );
4376            }
4377            let aos = if in_section {
4378                frames[frame_i].atom_data[row].velocity().unwrap()
4379            } else {
4380                let atom = &frames[frame_i].atom_data[row];
4381                [atom.x, atom.y, atom.z]
4382            };
4383            for k in 0..3 {
4384                assert_eq!(aos[k].to_bits(), got[k].to_bits());
4385            }
4386            row += 1;
4387        }
4388    }
4389
4390    fn reference_ok(token: &str) -> Option<f64> {
4391        let (val, n) = fast_float2::parse_partial::<f64, _>(token).ok()?;
4392        (n == token.len()).then_some(val)
4393    }
4394
4395    #[test]
4396    fn frames_match_general_decimal_parser() {
4397        assert_rows_match(&sample_con(4, true), true);
4398        assert_rows_match(&sample_con(4, false), true);
4399        let text = sample_convel();
4400        let frames: Vec<_> = ConFrameIterator::new(&text)
4401            .map(|frame| frame.expect("convel"))
4402            .collect();
4403        assert_eq!(frames.len(), 3);
4404        assert!(
4405            frames
4406                .iter()
4407                .all(|frame| frame.atom_data[0].velocity().is_some())
4408        );
4409        assert_eq!(frames[0].positions.nrows(), 5);
4410        // Every stored coordinate and velocity bit matches the general parser.
4411        let mut frame_i = 0usize;
4412        let mut coord_row = 0usize;
4413        let mut vel_row = 0usize;
4414        let mut velocities = false;
4415        for line in text.lines() {
4416            let token = line.trim();
4417            if token.starts_with("Generated") && (coord_row > 0 || velocities) {
4418                frame_i += 1;
4419                coord_row = 0;
4420                vel_row = 0;
4421                velocities = false;
4422            }
4423            if token.starts_with("Velocities") {
4424                velocities = true;
4425                continue;
4426            }
4427            let cols: Vec<&str> = token.split_whitespace().collect();
4428            if cols.len() < 5 {
4429                continue;
4430            }
4431            if reference_ok(cols[0]).is_none() || cols[3].parse::<u64>().is_err() {
4432                continue;
4433            }
4434            let row = if velocities { vel_row } else { coord_row };
4435            let got = if velocities {
4436                frames[frame_i].atom_data[row].velocity().unwrap()
4437            } else {
4438                let p = frames[frame_i].positions.as_f64_row(row);
4439                [p[0], p[1], p[2]]
4440            };
4441            for (k, col) in cols.iter().take(3).enumerate() {
4442                assert_eq!(got[k].to_bits(), reference_f64(col).to_bits(), "{token}");
4443            }
4444            if velocities {
4445                let soa = frames[frame_i].velocities.as_f64_row(row);
4446                for k in 0..3 {
4447                    assert_eq!(soa[k].to_bits(), got[k].to_bits());
4448                }
4449                vel_row += 1;
4450            } else {
4451                coord_row += 1;
4452            }
4453        }
4454        assert_eq!(frame_i, 2);
4455        assert_eq!(vel_row, 5);
4456    }
4457}
4458
4459#[cfg(test)]
4460mod newline_skip_tests {
4461    use super::ConFrameIterator;
4462
4463    /// The per-line search [`ConFrameIterator::advance_lines`] used before
4464    /// the counted skip. Positions and errors are the oracle.
4465    fn naive(bytes: &[u8], n: usize) -> Result<usize, ()> {
4466        let mut pos = 0usize;
4467        for i in 0..n {
4468            let rest = &bytes[pos..];
4469            match memchr::memchr(b'\n', rest) {
4470                Some(at) => pos += at + 1,
4471                None => {
4472                    if rest.is_empty() {
4473                        return Err(());
4474                    }
4475                    pos = bytes.len();
4476                    if i + 1 == n {
4477                        return Ok(pos);
4478                    }
4479                    return Err(());
4480                }
4481            }
4482        }
4483        Ok(pos)
4484    }
4485
4486    #[test]
4487    fn counted_skip_matches_per_line_memchr() {
4488        let mut samples = vec![
4489            String::new(),
4490            "a".into(),
4491            "a\n".into(),
4492            "a\nb".into(),
4493            "\n".into(),
4494            "\n\n".into(),
4495            "abc\ndef\nghi".into(),
4496            "a\r\nb\r\n".into(),
4497            "x".repeat(100) + "\n" + &"y".repeat(40),
4498        ];
4499        let path =
4500            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/cuh2.con");
4501        let one = std::fs::read_to_string(&path).unwrap();
4502        samples.push(one.repeat(2));
4503        let bare = one.trim_end_matches(['\n', '\r']).to_string();
4504        samples.push(bare);
4505        for text in samples {
4506            let bytes = text.as_bytes();
4507            for n in [0usize, 1, 2, 3, 6, 7, 9, 40, 200, 450, 1000, 5000] {
4508                let expect = naive(bytes, n);
4509                let mut it = ConFrameIterator::from_bytes(bytes);
4510                let got = it.advance_lines(n).map(|_| it.byte_pos()).map_err(|_| ());
4511                assert_eq!(got, expect, "n={n} len={}", bytes.len());
4512            }
4513        }
4514    }
4515}
4516
4517#[cfg(test)]
4518mod cached_unit_table_read_paths {
4519    use super::*;
4520
4521    const FRAME: &str = "\
4522v3
4523{\"con_spec_version\":3,\"con_spec_minor\":1,\"units\":{\"length\":\"angstrom\",\"energy\":\"eV\"}}
452410 10 10
452590 90 90
4526
4527
45281
45291
45301.0
4531H
4532Coordinates of Component 1
45330.5 0.25 0.125 0 7
4534";
4535
4536    fn coords(frame: &types::ConFrame) -> [f64; 3] {
4537        frame.positions.as_f64_row(0)
4538    }
4539
4540    /// The serial iterator, the byte reader behind the C API, and the phased
4541    /// reader behind `read_con` all validate units through the one table.
4542    #[test]
4543    fn read_paths_share_the_cached_unit_table() {
4544        let addr = crate::units::cached_unit_table_addr();
4545        let serial = ConFrameIterator::new(FRAME)
4546            .next()
4547            .expect("frame")
4548            .expect("parse");
4549        let from_bytes = frames_from_bytes(FRAME.as_bytes(), Some(1)).expect("bytes");
4550        let phased = frames_from_bytes_phased(FRAME.as_bytes(), Some(1)).expect("phased");
4551        assert_eq!(from_bytes.len(), 1);
4552        assert_eq!(phased.frames.len(), 1);
4553        assert_eq!(coords(&from_bytes[0]), coords(&serial));
4554        assert_eq!(coords(&phased.frames[0]), coords(&serial));
4555        assert_eq!(crate::units::cached_unit_table_addr(), addr);
4556        let bad = FRAME.replace("angstrom", "not-a-unit");
4557        assert!(ConFrameIterator::new(&bad).next().unwrap().is_err());
4558        assert!(frames_from_bytes(bad.as_bytes(), Some(1)).is_err());
4559        assert!(frames_from_bytes_phased(bad.as_bytes(), Some(1)).is_err());
4560        assert_eq!(crate::units::cached_unit_table_addr(), addr);
4561    }
4562}