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.
1072///
1073/// ```
1074/// use readcon_core::iterators::frames_from_text;
1075/// let text = include_str!("../resources/test/tiny_multi_cuh2.con");
1076/// let one = frames_from_text(text, Some(1)).unwrap();
1077/// let pool = frames_from_text(text, Some(2)).unwrap();
1078/// assert_eq!(one.len(), pool.len());
1079/// assert!(one.len() >= 2);
1080/// ```
1081pub fn frames_from_text(
1082    text: &str,
1083    num_threads: Option<usize>,
1084) -> Result<Vec<types::ConFrame>, error::ParseError> {
1085    #[cfg(feature = "parallel")]
1086    {
1087        if let Some(n) = num_threads.filter(|n| *n >= 2) {
1088            let parts = parse_frames_parallel_with_threads(text, Some(n));
1089            let mut frames = Vec::with_capacity(parts.len());
1090            for r in parts {
1091                frames.push(r?);
1092            }
1093            return Ok(frames);
1094        }
1095    }
1096    #[cfg(not(feature = "parallel"))]
1097    {
1098        let _ = num_threads;
1099    }
1100    ConFrameIterator::new(text).collect()
1101}
1102
1103/// Like [`read_all_frames`], with an explicit worker count.
1104///
1105/// `None`, `Some(0)`, and `Some(1)` parse on the calling thread.
1106/// `Some(n)` with `n >= 2` pins a Rayon pool when `parallel` is on.
1107///
1108/// A parse failure is the [`error::LocatedParseError`] from that parse.
1109/// Its line is the cursor that failed and its frame is the 0-based index.
1110/// The path is not opened again.
1111///
1112/// A plain file of 64 KiB or more stays mapped. Smaller files and
1113/// compressed input are read into one buffer.
1114pub fn read_all_frames_with_threads(
1115    path: &Path,
1116    num_threads: Option<usize>,
1117) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
1118    let bytes = crate::compression::read_bytes_owned(path)?;
1119    Ok(frames_from_bytes_located(bytes.as_slice(), num_threads)?)
1120}
1121
1122/// Positions of every frame, one row-major `f64` buffer.
1123#[derive(Debug)]
1124///
1125/// A parse failure is the [`error::LocatedParseError`] from the boundary
1126/// scan or the span parse that failed. The path is not opened again.
1127///
1128/// The boundary walk records each frame start. Each worker then parses
1129/// only its span and writes that frame's coordinates at
1130/// `frame * n_atoms * 3`. The returned buffer is the only retained
1131/// coordinate storage. A file whose frames do not share one atom count
1132/// is rejected, because the buffer is rectangular.
1133pub struct PositionBlock {
1134    pub n_frames: usize,
1135    pub n_atoms: usize,
1136    pub values: Vec<f64>,
1137}
1138
1139/// Read every frame's coordinates through the column walker.
1140///
1141/// `num_threads` of `None`, `Some(0)`, and `Some(1)` stay on the calling
1142/// thread. `Some(n)` with `n >= 2` uses that many workers when `parallel`
1143/// is enabled. The buffer is rectangular: frames that do not share one
1144/// atom count are an error. Python `read_positions` returns this buffer.
1145pub fn read_positions_with_threads(
1146    path: &Path,
1147    num_threads: Option<usize>,
1148) -> Result<PositionBlock, Box<dyn std::error::Error>> {
1149    let contents = crate::compression::read_file_contents(path)?;
1150    let block = match &contents {
1151        crate::compression::FileContents::Owned(text) => {
1152            fill_position_block(text.as_bytes(), num_threads)
1153        }
1154        crate::compression::FileContents::Mapped(map) => fill_position_block(map, num_threads),
1155    }?;
1156    Ok(block)
1157}
1158
1159/// Masses and cell that belong with [`read_positions_with_threads`].
1160///
1161/// The column walker fills these when it certifies the file. Otherwise they
1162/// come from the frame parser, which is the same source as before.
1163pub struct CoordColumns {
1164    pub block: PositionBlock,
1165    pub masses: Vec<f64>,
1166    pub cells: Vec<f64>,
1167    /// Column 5, file order, length `n_frames * n_atoms` when identity was read.
1168    pub atom_ids: Vec<u64>,
1169    /// Decoded column-4 bitmask, same length as `atom_ids` when identity was read.
1170    pub fixed: Vec<u8>,
1171    /// One run-length element list when every frame matches, otherwise one per frame.
1172    ///
1173    /// Each run is `(symbol, count)` in file order. An empty list means identity
1174    /// was not collected.
1175    pub species: Vec<Vec<(String, usize)>>,
1176    pub has_section: bool,
1177    /// True when any atom is held on some axes and free on others.
1178    pub partial_fixed: bool,
1179}
1180
1181/// Positions, masses, and cells for a stock CON or convel file.
1182///
1183/// A certified file is one coordinate pass. Anything else uses the frame
1184/// reader. Python `read_coords` returns these columns.
1185pub fn read_coord_columns(
1186    path: &Path,
1187    num_threads: Option<usize>,
1188) -> Result<CoordColumns, Box<dyn std::error::Error>> {
1189    let contents = crate::compression::read_file_contents(path)?;
1190    let bytes = contents.as_bytes();
1191    if let Ok(Some(columns)) = certified_columns(bytes, num_threads, true) {
1192        return Ok(columns);
1193    }
1194    let frames = frames_from_bytes_located(bytes, num_threads)?;
1195    Ok(columns_from_frames(&frames))
1196}
1197
1198fn certified_columns(
1199    bytes: &[u8],
1200    num_threads: Option<usize>,
1201    identity: bool,
1202) -> Result<Option<CoordColumns>, error::LocatedParseError> {
1203    let mut parsed = match if identity {
1204        crate::columns::positions_with_identity(bytes)
1205    } else {
1206        crate::columns::positions_from_con_bytes(bytes)
1207    } {
1208        Ok(parsed) if parsed.stock => parsed,
1209        _ => return Ok(None),
1210    };
1211    if parsed.n_frames == 0 {
1212        return Ok(Some(CoordColumns {
1213            block: PositionBlock {
1214                n_frames: 0,
1215                n_atoms: 0,
1216                values: Vec::new(),
1217            },
1218            masses: Vec::new(),
1219            cells: Vec::new(),
1220            atom_ids: Vec::new(),
1221            fixed: Vec::new(),
1222            species: Vec::new(),
1223            has_section: false,
1224            partial_fixed: false,
1225        }));
1226    }
1227    let Some(n_atoms) = parsed.rectangular_atoms() else {
1228        let first = parsed.counts[0];
1229        let index = parsed
1230            .counts
1231            .iter()
1232            .position(|count| *count != first)
1233            .unwrap_or(0);
1234        let line = file_line_at_bytes(
1235            bytes,
1236            parsed.spans.get(index).map(|span| span.start).unwrap_or(0),
1237        );
1238        return Err(error::LocatedParseError::new(
1239            error::ParseError::InvalidCount(
1240                "frame atom count does not match the first frame".into(),
1241            ),
1242            line,
1243            index as u64,
1244        ));
1245    };
1246    let ColumnIdentity {
1247        atom_ids,
1248        fixed,
1249        species,
1250        partial_fixed,
1251    } = if identity {
1252        match frame_identity(&parsed, n_atoms) {
1253            Some(identity) => identity,
1254            None => return Ok(None),
1255        }
1256    } else {
1257        ColumnIdentity::default()
1258    };
1259    let has_section = parsed.has_section;
1260    let values = if parallel_column_threads(num_threads) {
1261        parallel_column_values(bytes, &parsed, n_atoms, num_threads)
1262            .map_err(|err| error::LocatedParseError::new(err, 0, 0))?
1263    } else {
1264        std::mem::take(&mut parsed.positions)
1265    };
1266    Ok(Some(CoordColumns {
1267        block: PositionBlock {
1268            n_frames: parsed.n_frames,
1269            n_atoms,
1270            values,
1271        },
1272        masses: parsed.masses,
1273        cells: parsed.cells,
1274        atom_ids,
1275        fixed,
1276        species,
1277        has_section,
1278        partial_fixed,
1279    }))
1280}
1281
1282/// Column 5, the fixed mask, and the element runs for one coordinate read.
1283#[derive(Default)]
1284struct ColumnIdentity {
1285    atom_ids: Vec<u64>,
1286    fixed: Vec<u8>,
1287    species: Vec<Vec<(String, usize)>>,
1288    partial_fixed: bool,
1289}
1290
1291/// Column 5, the fixed mask, and element runs. `None` when the rich columns
1292/// do not partition the frames, so the caller uses the frame parser.
1293fn frame_identity(parsed: &crate::columns::ConPositions, n_atoms: usize) -> Option<ColumnIdentity> {
1294    let n = parsed.n_frames.saturating_mul(n_atoms);
1295    if parsed.ids.len() != n || parsed.flags.len() != n {
1296        return None;
1297    }
1298    let mut runs = Vec::with_capacity(parsed.n_frames);
1299    let mut components = parsed.components.iter();
1300    for &count in &parsed.counts {
1301        let mut left = count;
1302        let mut frame_runs = Vec::new();
1303        while left > 0 {
1304            let (symbol, width) = components.next()?;
1305            if *width == 0 || *width > left {
1306                return None;
1307            }
1308            frame_runs.push((symbol.to_string(), *width));
1309            left -= *width;
1310        }
1311        runs.push(frame_runs);
1312    }
1313    if components.next().is_some() {
1314        return None;
1315    }
1316    let partial_fixed = parsed.flags.iter().any(|flag| *flag != 0 && *flag != 7);
1317    let species = if runs.windows(2).all(|pair| pair[0] == pair[1]) {
1318        runs.into_iter().take(1).collect()
1319    } else {
1320        runs
1321    };
1322    Some(ColumnIdentity {
1323        atom_ids: parsed.ids.clone(),
1324        fixed: parsed.flags.clone(),
1325        species,
1326        partial_fixed,
1327    })
1328}
1329
1330fn parallel_column_threads(num_threads: Option<usize>) -> bool {
1331    // `None`, `Some(0)`, and `Some(1)` stay on the calling thread. A pool
1332    // runs only for an explicit count of two or more.
1333    #[cfg(feature = "parallel")]
1334    {
1335        matches!(num_threads, Some(n) if n >= 2)
1336    }
1337    #[cfg(not(feature = "parallel"))]
1338    {
1339        let _ = num_threads;
1340        false
1341    }
1342}
1343
1344fn parallel_column_values(
1345    bytes: &[u8],
1346    parsed: &crate::columns::ConPositions,
1347    n_atoms: usize,
1348    num_threads: Option<usize>,
1349) -> Result<Vec<f64>, error::ParseError> {
1350    let stride = n_atoms * 3;
1351    if stride == 0 {
1352        return Ok(Vec::new());
1353    }
1354    #[cfg(not(feature = "parallel"))]
1355    {
1356        let _ = (bytes, num_threads);
1357        Ok(parsed.positions.clone())
1358    }
1359    #[cfg(feature = "parallel")]
1360    {
1361        let mut values = vec![0.0_f64; parsed.n_frames * stride];
1362        let fill_one = |index: usize, dest: &mut [f64]| {
1363            let span = &parsed.spans[index];
1364            let slice = crate::columns::positions_from_con_bytes(&bytes[span.start..span.end])?;
1365            if slice.counts.first().copied() != Some(n_atoms) || slice.positions.len() != stride {
1366                return Err(error::ParseError::InvalidCount(
1367                    "frame atom count does not match the first frame".into(),
1368                ));
1369            }
1370            dest.copy_from_slice(&slice.positions);
1371            Ok(())
1372        };
1373        use rayon::prelude::*;
1374        let mut run = || match values
1375            .par_chunks_mut(stride)
1376            .enumerate()
1377            .find_map_first(|(index, dest)| fill_one(index, dest).err())
1378        {
1379            Some(err) => Err(err),
1380            None => Ok(()),
1381        };
1382        match num_threads {
1383            Some(n) if n >= 2 => {
1384                let pool = rayon::ThreadPoolBuilder::new()
1385                    .num_threads(n)
1386                    .build()
1387                    .map_err(|err| error::ParseError::ValidationError(err.to_string()))?;
1388                pool.install(run)?;
1389            }
1390            _ => run()?,
1391        }
1392        Ok(values)
1393    }
1394}
1395
1396fn columns_from_frames(frames: &[types::ConFrame]) -> CoordColumns {
1397    let n_frames = frames.len();
1398    let n_atoms = frames
1399        .first()
1400        .map(|frame| frame.positions.nrows())
1401        .unwrap_or(0);
1402    let mut values = Vec::with_capacity(n_frames * n_atoms * 3);
1403    let mut masses = Vec::with_capacity(n_frames * n_atoms);
1404    let mut cells = Vec::with_capacity(n_frames * 6);
1405    let mut atom_ids = Vec::with_capacity(n_frames * n_atoms);
1406    let mut fixed = Vec::with_capacity(n_frames * n_atoms);
1407    let mut runs = Vec::with_capacity(n_frames);
1408    let mut has_section = false;
1409    let mut partial_fixed = false;
1410    for frame in frames {
1411        cells.extend_from_slice(&frame.header.boxl);
1412        cells.extend_from_slice(&frame.header.angles);
1413        if let Some(src) = frame.positions.f64_slice() {
1414            values.extend_from_slice(src);
1415        } else {
1416            for row in 0..frame.positions.nrows() {
1417                values.extend_from_slice(&frame.positions.as_f64_row(row));
1418            }
1419        }
1420        for (type_index, &count) in frame.header.natms_per_type.iter().enumerate() {
1421            let mass = frame
1422                .header
1423                .masses_per_type
1424                .get(type_index)
1425                .copied()
1426                .unwrap_or(0.0);
1427            masses.extend(std::iter::repeat_n(mass, count));
1428        }
1429        has_section |= frame.has_velocities()
1430            || frame.has_forces()
1431            || frame.has_energies()
1432            || frame.has_charges()
1433            || frame.has_spins()
1434            || frame.has_magmoms()
1435            || frame.has_displacements()
1436            || frame.has_spreads();
1437        let mut frame_runs: Vec<(String, usize)> = Vec::new();
1438        for atom in &frame.atom_data {
1439            atom_ids.push(atom.atom_id);
1440            let mask = crate::types::encode_fixed_bitmask(atom.fixed);
1441            if mask != 0 && mask != 7 {
1442                partial_fixed = true;
1443            }
1444            fixed.push(mask);
1445            if let Some((symbol, count)) = frame_runs.last_mut()
1446                && symbol == atom.symbol.as_ref()
1447            {
1448                *count += 1;
1449                continue;
1450            }
1451            frame_runs.push((atom.symbol.to_string(), 1));
1452        }
1453        runs.push(frame_runs);
1454    }
1455    let species = if runs.windows(2).all(|pair| pair[0] == pair[1]) {
1456        runs.into_iter().take(1).collect()
1457    } else {
1458        runs
1459    };
1460    CoordColumns {
1461        block: PositionBlock {
1462            n_frames,
1463            n_atoms,
1464            values,
1465        },
1466        masses,
1467        cells,
1468        atom_ids,
1469        fixed,
1470        species,
1471        has_section,
1472        partial_fixed,
1473    }
1474}
1475
1476/// 1-based file line of the line that contains `byte`.
1477fn file_line_at_bytes(bytes: &[u8], byte: usize) -> u32 {
1478    let end = byte.min(bytes.len());
1479    1 + memchr::memchr_iter(b'\n', &bytes[..end]).count() as u32
1480}
1481
1482/// File line of a cursor that counted from the start of a share.
1483///
1484/// `local` is 1-based inside the share. A cursor that never moved still
1485/// names the share's first line.
1486fn share_cursor(local: u32, bytes: &[u8], share_start: usize) -> u32 {
1487    let base = file_line_at_bytes(bytes, share_start);
1488    if local == 0 {
1489        base
1490    } else {
1491        base.saturating_add(local.saturating_sub(1))
1492    }
1493}
1494
1495fn frame_starts(bytes: &[u8]) -> Result<Vec<usize>, error::LocatedParseError> {
1496    let mut scanner = ConFrameIterator::from_bytes(bytes);
1497    let mut boundaries = Vec::new();
1498    loop {
1499        scanner.lines.clear_peek();
1500        let start = scanner.lines.pos;
1501        if start >= bytes.len() || bytes[start..].iter().all(u8::is_ascii_whitespace) {
1502            break;
1503        }
1504        boundaries.push(start);
1505        match scanner.forward_fast() {
1506            Some(Ok(())) => {}
1507            Some(Err(err)) => {
1508                return Err(error::LocatedParseError::new(
1509                    err,
1510                    scanner.lines.line_no(),
1511                    scanner.frame_index(),
1512                ));
1513            }
1514            None => break,
1515        }
1516    }
1517    Ok(boundaries)
1518}
1519
1520/// Parse one span. The error keeps this frame's file line.
1521fn parse_span_located(
1522    bytes: &[u8],
1523    start: usize,
1524    end: usize,
1525    frame_index: u64,
1526) -> Result<types::ConFrame, error::LocatedParseError> {
1527    let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
1528    match iter.next() {
1529        Some(Ok(frame)) => Ok(frame),
1530        Some(Err(err)) => Err(error::LocatedParseError::new(
1531            err,
1532            share_cursor(iter.lines.line_no(), bytes, start),
1533            frame_index,
1534        )),
1535        None => Err(error::LocatedParseError::new(
1536            error::ParseError::IncompleteFrame,
1537            file_line_at_bytes(bytes, start),
1538            frame_index,
1539        )),
1540    }
1541}
1542
1543fn copy_positions(frame: &types::ConFrame, dest: &mut [f64]) -> Result<(), error::ParseError> {
1544    if frame.positions.nrows().saturating_mul(3) != dest.len() {
1545        return Err(error::ParseError::InvalidCount(
1546            "frame atom count does not match the first frame".into(),
1547        ));
1548    }
1549    if let Some(src) = frame.positions.f64_slice() {
1550        dest.copy_from_slice(src);
1551        return Ok(());
1552    }
1553    for (i, slot) in dest.as_chunks_mut::<3>().0.iter_mut().enumerate() {
1554        slot.copy_from_slice(&frame.positions.as_f64_row(i));
1555    }
1556    Ok(())
1557}
1558
1559fn fill_position_block(
1560    bytes: &[u8],
1561    num_threads: Option<usize>,
1562) -> Result<PositionBlock, error::LocatedParseError> {
1563    match certified_columns(bytes, num_threads, false) {
1564        Ok(Some(columns)) => return Ok(columns.block),
1565        Ok(None) => {}
1566        Err(err) => return Err(err),
1567    }
1568    let boundaries = frame_starts(bytes)?;
1569    if boundaries.is_empty() {
1570        return Ok(PositionBlock {
1571            n_frames: 0,
1572            n_atoms: 0,
1573            values: Vec::new(),
1574        });
1575    }
1576    let end0 = boundaries.get(1).copied().unwrap_or(bytes.len());
1577    let first = parse_span_located(bytes, boundaries[0], end0, 0)?;
1578    let n_atoms = first.positions.nrows();
1579    let n_frames = boundaries.len();
1580    let stride = n_atoms.saturating_mul(3);
1581    let mut values = vec![0.0_f64; n_frames.saturating_mul(stride)];
1582    if stride == 0 {
1583        return Ok(PositionBlock {
1584            n_frames,
1585            n_atoms,
1586            values,
1587        });
1588    }
1589    fill_position_spans(bytes, &boundaries, n_atoms, num_threads, &mut values)?;
1590    Ok(PositionBlock {
1591        n_frames,
1592        n_atoms,
1593        values,
1594    })
1595}
1596
1597fn fill_position_spans(
1598    bytes: &[u8],
1599    boundaries: &[usize],
1600    n_atoms: usize,
1601    num_threads: Option<usize>,
1602    values: &mut [f64],
1603) -> Result<(), error::LocatedParseError> {
1604    let stride = n_atoms * 3;
1605    let write_one = |i: usize, dest: &mut [f64]| {
1606        let start = boundaries[i];
1607        let end = boundaries.get(i + 1).copied().unwrap_or(bytes.len());
1608        let frame = parse_span_located(bytes, start, end, i as u64)?;
1609        copy_positions(&frame, dest).map_err(|error| {
1610            error::LocatedParseError::new(error, file_line_at_bytes(bytes, start), i as u64)
1611        })
1612    };
1613    #[cfg(feature = "parallel")]
1614    {
1615        let parallel = !matches!(num_threads, Some(n) if n <= 1);
1616        if parallel {
1617            use rayon::prelude::*;
1618            // The serial walk reports the earliest failing span. A pool
1619            // that stops on the first finished failure can hand back a
1620            // later span of the same file. `find_map_first` keeps the
1621            // earliest slot and still stops spans to its right.
1622            let mut run = || match values
1623                .par_chunks_mut(stride)
1624                .enumerate()
1625                .find_map_first(|(i, dest)| write_one(i, dest).err())
1626            {
1627                Some(err) => Err(err),
1628                None => Ok(()),
1629            };
1630            return match num_threads {
1631                Some(n) if n >= 2 => {
1632                    let pool = rayon::ThreadPoolBuilder::new()
1633                        .num_threads(n)
1634                        .build()
1635                        .map_err(|err| {
1636                            error::LocatedParseError::new(
1637                                error::ParseError::ValidationError(err.to_string()),
1638                                0,
1639                                0,
1640                            )
1641                        })?;
1642                    pool.install(run)
1643                }
1644                _ => run(),
1645            };
1646        }
1647    }
1648    #[cfg(not(feature = "parallel"))]
1649    {
1650        let _ = num_threads;
1651    }
1652    for (i, dest) in values.chunks_mut(stride).enumerate() {
1653        write_one(i, dest)?;
1654    }
1655    Ok(())
1656}
1657
1658/// [`frames_from_text`] for bytes that may not be UTF-8 yet.
1659pub fn frames_from_bytes(
1660    bytes: &[u8],
1661    num_threads: Option<usize>,
1662) -> Result<Vec<types::ConFrame>, error::ParseError> {
1663    #[cfg(feature = "parallel")]
1664    {
1665        if let Some(n) = num_threads.filter(|n| *n >= 2) {
1666            let parts = parse_frames_parallel_bytes(bytes, Some(n));
1667            let mut frames = Vec::with_capacity(parts.len());
1668            for r in parts {
1669                frames.push(r?);
1670            }
1671            return Ok(frames);
1672        }
1673    }
1674    #[cfg(not(feature = "parallel"))]
1675    {
1676        let _ = num_threads;
1677    }
1678    let mut frames = Vec::new();
1679    for r in ConFrameIterator::from_bytes(bytes) {
1680        frames.push(r?);
1681    }
1682    Ok(frames)
1683}
1684
1685/// [`frames_from_bytes`] that keeps the line and frame of the first error.
1686fn frames_from_bytes_located(
1687    bytes: &[u8],
1688    num_threads: Option<usize>,
1689) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
1690    #[cfg(feature = "parallel")]
1691    {
1692        if let Some(n) = num_threads.filter(|n| *n >= 2) {
1693            let timed = parse_bytes_timed(bytes, Some(n));
1694            return collect_located_slots(timed.slots, &timed.fail_lines);
1695        }
1696    }
1697    #[cfg(not(feature = "parallel"))]
1698    {
1699        let _ = num_threads;
1700    }
1701    collect_bytes_located(bytes)
1702}
1703
1704/// Serial byte parse. The cursor is the iterator's file line.
1705fn collect_bytes_located(bytes: &[u8]) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
1706    let mut frames = Vec::new();
1707    let mut iter = ConFrameIterator::from_bytes(bytes);
1708    while let Some(item) = iter.next() {
1709        match item {
1710            Ok(frame) => frames.push(frame),
1711            Err(error) => {
1712                return Err(error::LocatedParseError::new(
1713                    error,
1714                    iter.lines.line_no(),
1715                    iter.frame_index(),
1716                ));
1717            }
1718        }
1719    }
1720    Ok(frames)
1721}
1722
1723/// First failed slot, with the file line its worker stored.
1724#[cfg(feature = "parallel")]
1725fn collect_located_slots(
1726    slots: Vec<Result<types::ConFrame, error::ParseError>>,
1727    fail_lines: &[(usize, u32)],
1728) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
1729    let mut frames = Vec::with_capacity(slots.len());
1730    for (index, part) in slots.into_iter().enumerate() {
1731        match part {
1732            Ok(frame) => frames.push(frame),
1733            Err(error) => {
1734                let line = fail_lines
1735                    .iter()
1736                    .find(|(slot, _)| *slot == index)
1737                    .map(|(_, line)| *line)
1738                    .unwrap_or(0);
1739                return Err(error::LocatedParseError::new(error, line, index as u64));
1740            }
1741        }
1742    }
1743    Ok(frames)
1744}
1745
1746#[cfg(feature = "parallel")]
1747fn span_end(boundaries: &[usize], index: usize, len: usize) -> usize {
1748    if index + 1 < boundaries.len() {
1749        boundaries[index + 1]
1750    } else {
1751        len
1752    }
1753}
1754
1755/// Pointer to slot storage shared by the workers that claim chunks.
1756///
1757/// Each claimed index is written by one thread, so the alias is the
1758/// partition. The pointer stays valid until those workers join.
1759#[cfg(feature = "parallel")]
1760#[derive(Copy, Clone)]
1761struct SlotWritePtr(*mut std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>);
1762
1763#[cfg(feature = "parallel")]
1764impl SlotWritePtr {
1765    /// # Safety
1766    /// `index` is in range and this call is the only write of that slot.
1767    unsafe fn write_slot(self, index: usize, value: Result<types::ConFrame, error::ParseError>) {
1768        unsafe {
1769            (*self.0.add(index)).write(value);
1770        }
1771    }
1772}
1773
1774// SAFETY: `SlotWritePtr` is copied into the claim loop, and that loop
1775// writes each slot index from exactly one worker. The allocation
1776// outlives the join.
1777#[cfg(feature = "parallel")]
1778unsafe impl Send for SlotWritePtr {}
1779
1780// SAFETY: same partition as `Send`. Workers do not read a slot another
1781// worker is writing.
1782#[cfg(feature = "parallel")]
1783unsafe impl Sync for SlotWritePtr {}
1784
1785/// Minor faults of the calling thread.
1786///
1787/// `RUSAGE_THREAD` is Linux. Other hosts report 0; the counter is
1788/// diagnostic and the parse result does not depend on it.
1789#[cfg(feature = "parallel")]
1790fn thread_minflt() -> i64 {
1791    #[cfg(target_os = "linux")]
1792    {
1793        unsafe {
1794            let mut usage = std::mem::zeroed::<libc::rusage>();
1795            libc::getrusage(libc::RUSAGE_THREAD, &mut usage);
1796            usage.ru_minflt
1797        }
1798    }
1799    #[cfg(not(target_os = "linux"))]
1800    {
1801        0
1802    }
1803}
1804
1805/// Hardware LLC-miss counter for this process.
1806///
1807/// `-1` means the guest refused the event. This KVM guest returns
1808/// `EPERM` for `perf_event_open`, so the parse explanation uses the
1809/// per-worker minor faults instead.
1810#[cfg(feature = "parallel")]
1811fn sample_llc_misses() -> i64 {
1812    // `perf_event_open` is Linux. `-1` is the existing "counter refused" value.
1813    #[cfg(not(target_os = "linux"))]
1814    {
1815        return -1;
1816    }
1817    #[cfg(target_os = "linux")]
1818    {
1819        #[repr(C)]
1820        struct PerfEventAttr {
1821            typ: u32,
1822            size: u32,
1823            config: u64,
1824            rest: [u64; 14],
1825        }
1826        let attr = PerfEventAttr {
1827            typ: 3,
1828            size: std::mem::size_of::<PerfEventAttr>() as u32,
1829            // PERF_COUNT_HW_CACHE_LL, OP_READ, RESULT_MISS.
1830            config: 2 | (1 << 16),
1831            rest: [0; 14],
1832        };
1833        let fd = unsafe {
1834            libc::syscall(
1835                libc::SYS_perf_event_open,
1836                &attr as *const PerfEventAttr,
1837                0i32,
1838                -1i32,
1839                -1i32,
1840                0u64,
1841            )
1842        };
1843        if fd < 0 {
1844            return -1;
1845        }
1846        let fd = fd as libc::c_int;
1847        let mut value = 0u64;
1848        let n = unsafe { libc::read(fd, &mut value as *mut u64 as *mut libc::c_void, 8) };
1849        unsafe {
1850            libc::close(fd);
1851        }
1852        if n == 8 { value as i64 } else { -1 }
1853    }
1854}
1855
1856/// Pages that do not sit on node 0.
1857///
1858/// A guest with one memory node has no remote pages. The count is 0
1859/// in that case. A wider node list sums the pages `numa_maps` records
1860/// on every node other than 0.
1861#[cfg(feature = "parallel")]
1862fn numa_remote_pages() -> i64 {
1863    let online = std::fs::read_to_string("/sys/devices/system/node/online").unwrap_or_default();
1864    let online = online.trim();
1865    let nodes = if let Some((start, end)) = online.split_once('-') {
1866        let start: i64 = start.parse().unwrap_or(0);
1867        let end: i64 = end.parse().unwrap_or(0);
1868        end - start + 1
1869    } else {
1870        1
1871    };
1872    if nodes <= 1 {
1873        return 0;
1874    }
1875    let text = std::fs::read_to_string("/proc/self/numa_maps").unwrap_or_default();
1876    let mut remote = 0i64;
1877    for line in text.lines() {
1878        for token in line.split_whitespace() {
1879            if let Some(rest) = token.strip_prefix('N')
1880                && let Some((node, pages)) = rest.split_once('=')
1881                && node != "0"
1882            {
1883                remote += pages.parse::<i64>().unwrap_or(0);
1884            }
1885        }
1886    }
1887    remote
1888}
1889
1890/// Contiguous frame ranges that cover `boundaries` and split the file
1891/// into similar byte lengths.
1892///
1893/// There are up to four ranges per worker, and never more ranges than
1894/// frames. Each range holds at least one frame. The cuts stay on frame
1895/// starts, so a worker parses a whole frame.
1896#[cfg(feature = "parallel")]
1897fn byte_balanced_ranges(boundaries: &[usize], len: usize, workers: usize) -> Vec<(usize, usize)> {
1898    let n = boundaries.len();
1899    if n == 0 {
1900        return Vec::new();
1901    }
1902    let workers = workers.max(1).min(n);
1903    let chunks = workers.saturating_mul(4).clamp(workers, n);
1904    let origin = boundaries[0];
1905    let total = len.saturating_sub(origin).max(1) as u128;
1906    let mut ranges = Vec::with_capacity(chunks);
1907    let mut start = 0usize;
1908    for chunk in 0..chunks {
1909        if start >= n {
1910            break;
1911        }
1912        let end = if chunk + 1 == chunks {
1913            n
1914        } else {
1915            let target = origin + ((total * (chunk as u128 + 1)) / chunks as u128) as usize;
1916            let mut idx = if start + 1 < n {
1917                boundaries[start + 1..].partition_point(|&b| b < target) + start + 1
1918            } else {
1919                n
1920            };
1921            let min_end = start + 1;
1922            let reserved = chunks - chunk - 1;
1923            let max_end = n.saturating_sub(reserved);
1924            if max_end >= min_end {
1925                idx = idx.clamp(min_end, max_end);
1926            } else {
1927                idx = min_end.min(n);
1928            }
1929            idx
1930        };
1931        if end > start {
1932            ranges.push((start, end));
1933            start = end;
1934        }
1935    }
1936    if start < n {
1937        match ranges.last_mut() {
1938            Some(last) => last.1 = n,
1939            None => ranges.push((start, n)),
1940        }
1941    }
1942    ranges
1943}
1944
1945/// Parse each frame into a slot allocated before any worker runs.
1946///
1947/// Workers claim chunk indexes from an atomic counter and write only
1948/// those slots. The returned vector is that slot storage, in boundary
1949/// order, which is the serial iterator order. `workers < 2` parses on
1950/// the calling thread.
1951#[cfg(feature = "parallel")]
1952fn parse_into_slots<F>(
1953    bytes: &[u8],
1954    len: usize,
1955    boundaries: &[usize],
1956    workers: usize,
1957    parse_span: F,
1958) -> Vec<Result<types::ConFrame, error::ParseError>>
1959where
1960    F: Fn(usize, usize) -> Result<types::ConFrame, error::ParseError> + Sync,
1961{
1962    parse_into_slots_timed(bytes, len, boundaries, workers, parse_span).slots
1963}
1964
1965/// Slot allocation and the worker parse, timed apart.
1966#[cfg(feature = "parallel")]
1967struct SlotParse {
1968    slots: Vec<Result<types::ConFrame, error::ParseError>>,
1969    alloc_s: f64,
1970    work_s: f64,
1971    sum_s: f64,
1972    max_s: f64,
1973    /// One entry per worker range, in worker order. This is CPU time.
1974    chunk_s: Vec<f64>,
1975    /// Minor faults on each worker during its range.
1976    worker_minflt: Vec<i64>,
1977    /// Frames in each worker's share.
1978    share_frames: Vec<u64>,
1979    /// File bytes in each worker's share.
1980    share_bytes: Vec<u64>,
1981    /// `(slot, file line)` for each parse that failed. The line is the
1982    /// cursor of that parse, not a later reread.
1983    fail_lines: Vec<(usize, u32)>,
1984}
1985
1986/// Slot allocation and the worker parse, timed apart.
1987///
1988/// `alloc_s` is the slot vector. `work_s` is the parse wall. `sum_s` is
1989/// the sum of per-chunk parse times and `max_s` is the slowest chunk.
1990/// `chunk_s` keeps every chunk. `workers < 2` still reserves the slot
1991/// vector before the calling thread fills it, and that one chunk's time
1992/// is the sum, the max, and the only entry.
1993#[cfg(feature = "parallel")]
1994fn parse_into_slots_timed<F>(
1995    bytes: &[u8],
1996    len: usize,
1997    boundaries: &[usize],
1998    workers: usize,
1999    parse_span: F,
2000) -> SlotParse
2001where
2002    F: Fn(usize, usize) -> Result<types::ConFrame, error::ParseError> + Sync,
2003{
2004    let n = boundaries.len();
2005    if n == 0 {
2006        return SlotParse {
2007            slots: Vec::new(),
2008            alloc_s: 0.0,
2009            work_s: 0.0,
2010            sum_s: 0.0,
2011            max_s: 0.0,
2012            chunk_s: Vec::new(),
2013            worker_minflt: Vec::new(),
2014            share_frames: Vec::new(),
2015            share_bytes: Vec::new(),
2016            fail_lines: Vec::new(),
2017        };
2018    }
2019    if workers < 2 {
2020        let t_alloc = std::time::Instant::now();
2021        let mut slots = Vec::with_capacity(n);
2022        let alloc_s = t_alloc.elapsed().as_secs_f64();
2023        let flt0 = thread_minflt();
2024        let t_work = std::time::Instant::now();
2025        for i in 0..n {
2026            slots.push(parse_span(boundaries[i], span_end(boundaries, i, len)));
2027        }
2028        let work_s = t_work.elapsed().as_secs_f64();
2029        let worker_minflt = vec![thread_minflt().saturating_sub(flt0)];
2030        return SlotParse {
2031            slots,
2032            alloc_s,
2033            work_s,
2034            sum_s: work_s,
2035            max_s: work_s,
2036            chunk_s: vec![work_s],
2037            worker_minflt,
2038            share_frames: vec![n as u64],
2039            share_bytes: vec![len as u64],
2040            fail_lines: Vec::new(),
2041        };
2042    }
2043    let t_alloc = std::time::Instant::now();
2044    let mut slots = take_slot_buf(n);
2045    let alloc_s = t_alloc.elapsed().as_secs_f64();
2046    // Several byte-balanced ranges per worker. A slow range can be
2047    // stolen. Each range is still one iterator, not one iterator per
2048    // frame: a fresh iterator per frame faulted a private heap.
2049    let ranges = byte_balanced_ranges(boundaries, len, workers);
2050    let chunk_ns: Vec<std::sync::atomic::AtomicU64> = (0..ranges.len())
2051        .map(|_| std::sync::atomic::AtomicU64::new(0))
2052        .collect();
2053    let chunk_flt: Vec<std::sync::atomic::AtomicI64> = (0..ranges.len())
2054        .map(|_| std::sync::atomic::AtomicI64::new(0))
2055        .collect();
2056    let slots_ptr = SlotWritePtr(slots.as_mut_ptr());
2057    let fail_lines = std::sync::Mutex::new(Vec::<(usize, u32)>::new());
2058    let t_work = std::time::Instant::now();
2059    {
2060        let parse_range = |range_index: usize, start: usize, end: usize| {
2061            if start >= end {
2062                return;
2063            }
2064            let flt0 = thread_minflt();
2065            let t_chunk = std::time::Instant::now();
2066            let byte_start = boundaries[start];
2067            let byte_end = span_end(boundaries, end - 1, len);
2068            let mut iter = ConFrameIterator::from_bytes(&bytes[byte_start..byte_end]);
2069            for i in start..end {
2070                let frame = match iter.next() {
2071                    Some(Ok(frame)) => Ok(frame),
2072                    Some(Err(error)) => {
2073                        let line = share_cursor(iter.lines.line_no(), bytes, byte_start);
2074                        fail_lines.lock().expect("fail lines").push((i, line));
2075                        Err(error)
2076                    }
2077                    None => {
2078                        let line = share_cursor(iter.lines.line_no(), bytes, byte_start);
2079                        fail_lines.lock().expect("fail lines").push((i, line));
2080                        Err(error::ParseError::IncompleteFrame)
2081                    }
2082                };
2083                // SAFETY: this range owns `start..end`, so `i` has one
2084                // writer. `slots` outlives the join.
2085                unsafe {
2086                    slots_ptr.write_slot(i, frame);
2087                }
2088            }
2089            chunk_ns[range_index].store(
2090                t_chunk.elapsed().as_nanos() as u64,
2091                std::sync::atomic::Ordering::Relaxed,
2092            );
2093            chunk_flt[range_index].store(
2094                thread_minflt().saturating_sub(flt0),
2095                std::sync::atomic::Ordering::Relaxed,
2096            );
2097        };
2098        let run_ranges = || {
2099            use rayon::prelude::*;
2100            ranges
2101                .par_iter()
2102                .enumerate()
2103                .for_each(|(range_index, &(start, end))| {
2104                    parse_range(range_index, start, end);
2105                });
2106        };
2107        if rayon::current_thread_index().is_some() {
2108            run_ranges();
2109        } else {
2110            match rayon::ThreadPoolBuilder::new().num_threads(workers).build() {
2111                Ok(pool) => pool.install(run_ranges),
2112                Err(_) => {
2113                    for (range_index, &(start, end)) in ranges.iter().enumerate() {
2114                        parse_range(range_index, start, end);
2115                    }
2116                }
2117            }
2118        }
2119    }
2120    let work_s = t_work.elapsed().as_secs_f64();
2121    let mut worker_sum_s = 0.0_f64;
2122    let mut worker_max_s = 0.0_f64;
2123    let mut chunk_s = Vec::new();
2124    let mut worker_minflt = Vec::new();
2125    let mut share_frames = Vec::new();
2126    let mut share_bytes = Vec::new();
2127    for (range_index, &(start, end)) in ranges.iter().enumerate() {
2128        if start >= end {
2129            continue;
2130        }
2131        let seconds =
2132            chunk_ns[range_index].load(std::sync::atomic::Ordering::Relaxed) as f64 / 1.0e9;
2133        chunk_s.push(seconds);
2134        worker_sum_s += seconds;
2135        worker_max_s = worker_max_s.max(seconds);
2136        worker_minflt.push(chunk_flt[range_index].load(std::sync::atomic::Ordering::Relaxed));
2137        share_frames.push((end - start) as u64);
2138        let nbytes = span_end(boundaries, end - 1, len).saturating_sub(boundaries[start]);
2139        share_bytes.push(nbytes as u64);
2140    }
2141    // Each index was written once. Reinterpreting the buffer avoids a
2142    // second serial walk of the frame shells after the workers join.
2143    let slots = unsafe {
2144        let mut raw = std::mem::ManuallyDrop::new(slots);
2145        Vec::from_raw_parts(
2146            raw.as_mut_ptr() as *mut Result<types::ConFrame, error::ParseError>,
2147            n,
2148            raw.capacity(),
2149        )
2150    };
2151    let fail_lines = fail_lines.into_inner().unwrap_or_default();
2152    SlotParse {
2153        slots,
2154        alloc_s,
2155        work_s,
2156        sum_s: worker_sum_s,
2157        max_s: worker_max_s,
2158        chunk_s,
2159        worker_minflt,
2160        share_frames,
2161        share_bytes,
2162        fail_lines,
2163    }
2164}
2165
2166#[cfg(feature = "parallel")]
2167fn parse_byte_span(
2168    bytes: &[u8],
2169    start: usize,
2170    end: usize,
2171) -> Result<types::ConFrame, error::ParseError> {
2172    let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
2173    match iter.next() {
2174        Some(result) => result,
2175        None => Err(error::ParseError::IncompleteFrame),
2176    }
2177}
2178
2179#[cfg(feature = "parallel")]
2180fn parse_text_span(
2181    text: &str,
2182    start: usize,
2183    end: usize,
2184) -> Result<types::ConFrame, error::ParseError> {
2185    let mut iter = ConFrameIterator::new(&text[start..end]);
2186    match iter.next() {
2187        Some(result) => result,
2188        None => Err(error::ParseError::IncompleteFrame),
2189    }
2190}
2191
2192#[cfg(feature = "parallel")]
2193struct TimedParse {
2194    slots: Vec<Result<types::ConFrame, error::ParseError>>,
2195    /// Still alive so later phases of this read use the same workers.
2196    /// `None` when the call stayed on the calling thread.
2197    pool: Option<std::sync::Arc<rayon::ThreadPool>>,
2198    pool_s: f64,
2199    /// Zero while `pool` is still alive. The owner times the join.
2200    pool_join_s: f64,
2201    scan_s: f64,
2202    scan_minflt: i64,
2203    slot_alloc_s: f64,
2204    worker_parse_s: f64,
2205    worker_sum_s: f64,
2206    worker_max_s: f64,
2207    chunk_s: Vec<f64>,
2208    worker_minflt: Vec<i64>,
2209    share_frames: Vec<u64>,
2210    share_bytes: Vec<u64>,
2211    /// `(slot, file line)` recorded by the worker that failed.
2212    fail_lines: Vec<(usize, u32)>,
2213    llc_misses: i64,
2214    numa_remote: i64,
2215    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`].
2216    scan_kind: u8,
2217}
2218
2219/// Keep freed parse pages in the heap.
2220///
2221/// Eight workers otherwise return those pages to the kernel, and the
2222/// next file faults them again. One worker already keeps a smaller set.
2223/// The same policy on every width leaves the parse fault count flat.
2224#[cfg(feature = "parallel")]
2225fn keep_parse_pages() {
2226    // glibc `M_MMAP_MAX` and `M_TRIM_THRESHOLD`. macOS and Windows have
2227    // no `mallopt`; leaving the pages to the system allocator is enough
2228    // for the parse to be correct.
2229    #[cfg(target_os = "linux")]
2230    unsafe {
2231        libc::mallopt(-4, 0);
2232        libc::mallopt(-1, i32::MAX);
2233    }
2234}
2235
2236/// Slot storage kept between parses.
2237///
2238/// Eight workers write one `ConFrame` shell per frame into this buffer.
2239/// A fresh buffer is a new mapping, and those writes are the repeating
2240/// minor faults. The next parse reuses the same pages.
2241#[cfg(feature = "parallel")]
2242fn take_slot_buf(
2243    n: usize,
2244) -> Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>> {
2245    let mut buf = slot_cache()
2246        .lock()
2247        .map(|mut slot| std::mem::take(&mut *slot))
2248        .unwrap_or_default();
2249    if buf.capacity() < n {
2250        buf = Vec::with_capacity(n);
2251    }
2252    unsafe { buf.set_len(n) };
2253    buf
2254}
2255
2256#[cfg(feature = "parallel")]
2257fn slot_cache()
2258-> &'static std::sync::Mutex<Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>>>
2259{
2260    static CACHE: std::sync::Mutex<
2261        Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>>,
2262    > = std::sync::Mutex::new(Vec::new());
2263    &CACHE
2264}
2265
2266/// One pool per worker count, kept for the life of the process.
2267///
2268/// A fresh pool on every file made each worker fault its frames again.
2269/// Reusing the workers lets the allocator hand back the pages just freed.
2270#[cfg(feature = "parallel")]
2271#[allow(clippy::type_complexity)]
2272fn shared_pool(workers: usize) -> Option<std::sync::Arc<rayon::ThreadPool>> {
2273    use std::sync::{Arc, Mutex, OnceLock};
2274    static CACHE: OnceLock<Mutex<Vec<(usize, Arc<rayon::ThreadPool>)>>> = OnceLock::new();
2275    let cache = CACHE.get_or_init(|| Mutex::new(Vec::new()));
2276    let mut guard = cache.lock().ok()?;
2277    if let Some((_, pool)) = guard.iter().find(|(count, _)| *count == workers) {
2278        return Some(Arc::clone(pool));
2279    }
2280    keep_parse_pages();
2281    let pool = Arc::new(
2282        rayon::ThreadPoolBuilder::new()
2283            .num_threads(workers)
2284            .build()
2285            .ok()?,
2286    );
2287    guard.push((workers, Arc::clone(&pool)));
2288    Some(pool)
2289}
2290
2291/// Minor faults of this process.
2292///
2293/// `getrusage` is Unix. Windows reports 0.
2294#[cfg(feature = "parallel")]
2295pub fn process_minflt() -> i64 {
2296    #[cfg(unix)]
2297    {
2298        unsafe {
2299            let mut usage = std::mem::zeroed::<libc::rusage>();
2300            libc::getrusage(libc::RUSAGE_SELF, &mut usage);
2301            usage.ru_minflt
2302        }
2303    }
2304    #[cfg(not(unix))]
2305    {
2306        0
2307    }
2308}
2309
2310/// Page size from `sysconf`, with the POSIX page as the fallback.
2311#[cfg(feature = "parallel")]
2312fn host_page_size() -> usize {
2313    #[cfg(unix)]
2314    {
2315        let raw = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
2316        if raw > 0 { raw as usize } else { 4096 }
2317    }
2318    #[cfg(not(unix))]
2319    {
2320        4096
2321    }
2322}
2323
2324/// Fault one word on each page of `values`.
2325///
2326/// The page count is split by the worker count. The step is the host
2327/// page, so the slice a worker faults is its own. No extra chunk size.
2328#[cfg(feature = "parallel")]
2329fn first_touch_f64(values: &mut [f64], workers: usize) {
2330    use rayon::prelude::*;
2331    if values.is_empty() || workers < 2 {
2332        return;
2333    }
2334    let page = host_page_size().max(std::mem::size_of::<f64>());
2335    let step = (page / std::mem::size_of::<f64>()).max(1);
2336    let pages = values.len().div_ceil(step);
2337    let share = pages.div_ceil(workers);
2338    if rayon::current_thread_index().is_some() {
2339        // One chunk per page. The installed pool hands each page to one
2340        // worker, so that worker faults its own slice.
2341        values.par_chunks_mut(step).for_each(|chunk| {
2342            if let Some(slot) = chunk.first_mut() {
2343                *slot = 0.0;
2344            }
2345        });
2346        return;
2347    }
2348    std::thread::scope(|scope| {
2349        let mut rest = values;
2350        for worker in 0..workers {
2351            if rest.is_empty() {
2352                break;
2353            }
2354            let pages_here = share.min(rest.len().div_ceil(step));
2355            let take = (pages_here * step).min(rest.len());
2356            let (head, tail) = rest.split_at_mut(take);
2357            rest = tail;
2358            let _ = worker;
2359            scope.spawn(move || {
2360                let mut at = 0;
2361                while at < head.len() {
2362                    head[at] = 0.0;
2363                    at += step;
2364                }
2365            });
2366        }
2367    });
2368}
2369
2370/// Move every `Ok` frame on the read's pool.
2371///
2372/// A serial walk of the slot vector grows with the frame count. The
2373/// pool that parsed the frames does this move. One thread still walks
2374/// the slots when that pool is absent.
2375#[cfg(feature = "parallel")]
2376fn finish_results(
2377    pool: Option<&rayon::ThreadPool>,
2378    slots: Vec<Result<types::ConFrame, error::ParseError>>,
2379) -> Result<Vec<types::ConFrame>, error::ParseError> {
2380    if slots.len() < 2 || pool.is_none() {
2381        let mut frames = Vec::with_capacity(slots.len());
2382        for part in slots {
2383            frames.push(part?);
2384        }
2385        return Ok(frames);
2386    }
2387    let pool = pool.expect("pool");
2388    pool.install(|| {
2389        use rayon::prelude::*;
2390        if let Some(index) = slots.par_iter().position_first(|item| item.is_err()) {
2391            return Err(match slots.into_iter().nth(index) {
2392                Some(Err(err)) => err,
2393                _ => error::ParseError::IncompleteFrame,
2394            });
2395        }
2396        Ok(slots
2397            .into_par_iter()
2398            .map(|item| item.expect("ok"))
2399            .collect())
2400    })
2401}
2402
2403/// Header walk on the calling thread.
2404#[cfg(feature = "parallel")]
2405pub const SCAN_SERIAL: u8 = 0;
2406/// Newline index in chunks, then a header walk on the calling thread.
2407///
2408/// Frames that do not share one byte length take this path. The newline
2409/// bytes are split across the pool. The header walk is not, because a
2410/// later frame start is the end of the skip, and that end is not known
2411/// until the skip runs.
2412#[cfg(feature = "parallel")]
2413pub const SCAN_INDEXED: u8 = 1;
2414/// Equal-length frames. Each worker boundary is checked.
2415#[cfg(feature = "parallel")]
2416pub const SCAN_EQUAL_SPAN: u8 = 2;
2417
2418/// Frame starts when every frame occupies the same number of bytes.
2419///
2420/// The first frame sets the span. Each worker boundary is skipped on
2421/// its own slice. A span that does not tile the
2422/// buffer, or a skip that does not land on the next span, returns
2423/// `None` and the caller keeps the serial walk. The starts are
2424/// `0, span, 2*span, ...` and match [`collect_frame_starts`].
2425#[cfg(feature = "parallel")]
2426#[cfg_attr(not(test), allow(dead_code))]
2427fn equal_span_boundaries(bytes: &[u8], workers: usize) -> Option<Vec<usize>> {
2428    if workers < 2 || bytes.len() < 2 {
2429        return None;
2430    }
2431    let mut first = ConFrameIterator::from_bytes(bytes);
2432    match first.forward_fast() {
2433        Some(Ok(())) => {}
2434        _ => return None,
2435    }
2436    let span = first.lines.pos;
2437    if span < 2 {
2438        return None;
2439    }
2440    let n = bytes.len() / span;
2441    if n < 2 {
2442        return None;
2443    }
2444    // Bytes after the last full span are a tail, not another frame.
2445    // The serial walk ignores a whitespace tail.
2446    let tiled = n * span;
2447    if bytes[tiled..].iter().any(|b| !b.is_ascii_whitespace()) {
2448        return None;
2449    }
2450    // Equal byte ranges. A cut that falls inside a frame resyncs to the
2451    // next frame start, and only that header is checked. The checks stay
2452    // on this worker: a nested pool wake was larger than the checks.
2453    let byte_share = tiled.div_ceil(workers);
2454    for worker in 0..workers {
2455        let byte_cut = worker.saturating_mul(byte_share).min(tiled);
2456        if byte_cut >= tiled {
2457            continue;
2458        }
2459        let index = if worker == 0 {
2460            0
2461        } else {
2462            byte_cut.div_ceil(span).min(n - 1)
2463        };
2464        let start = index.saturating_mul(span);
2465        if start >= tiled {
2466            continue;
2467        }
2468        let end = (start + span).min(tiled);
2469        let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
2470        match iter.forward_fast() {
2471            Some(Ok(())) if iter.lines.pos == end - start => {}
2472            _ => return None,
2473        }
2474    }
2475    Some(arithmetic_starts(span, n))
2476}
2477
2478/// Starts `0, span, 2*span, ...` from a buffer kept between files.
2479///
2480/// Building that buffer from a fresh allocation stalled for milliseconds
2481/// after a large free. The next file fills the same pages.
2482#[cfg(feature = "parallel")]
2483static START_CACHE: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
2484
2485#[cfg(feature = "parallel")]
2486#[cfg_attr(not(test), allow(dead_code))]
2487fn arithmetic_starts(span: usize, n: usize) -> Vec<usize> {
2488    let mut starts = START_CACHE
2489        .lock()
2490        .map(|mut slot| std::mem::take(&mut *slot))
2491        .unwrap_or_default();
2492    if starts.len() != n {
2493        starts.resize(n, 0);
2494    }
2495    for (index, slot) in starts.iter_mut().enumerate() {
2496        *slot = index * span;
2497    }
2498    starts
2499}
2500
2501#[cfg(feature = "parallel")]
2502fn recycle_starts(starts: Vec<usize>) {
2503    if let Ok(mut slot) = START_CACHE.lock()
2504        && starts.capacity() >= slot.capacity()
2505    {
2506        *slot = starts;
2507    }
2508}
2509
2510#[cfg(feature = "parallel")]
2511fn parse_frames_parallel_bytes(
2512    bytes: &[u8],
2513    num_threads: Option<usize>,
2514) -> Vec<Result<types::ConFrame, error::ParseError>> {
2515    parse_bytes_timed(bytes, num_threads).slots
2516}
2517
2518/// Scan, slot allocation, and worker parse of `bytes`.
2519///
2520/// `Some(n)` with `n >= 2` uses a pool of that size. Every other count
2521/// stays on the calling thread. `None` follows [`scan_thread_count`].
2522/// The pool build is `pool_s`. The pool join is `pool_join_s`.
2523/// Neither one is inside the scan or the parse.
2524#[cfg(feature = "parallel")]
2525fn parse_bytes_timed(bytes: &[u8], num_threads: Option<usize>) -> TimedParse {
2526    let parse_workers = match num_threads {
2527        Some(n) if n >= 2 => n,
2528        _ => 1,
2529    };
2530    let run = || {
2531        let fault0 = process_minflt();
2532        let t_scan = std::time::Instant::now();
2533        // Equal-length frames skip the serial header walk. Anything else
2534        // still walks headers on this thread. Workers then parse the
2535        // spans in file order.
2536        let (boundaries, scan_kind) =
2537            if let Some(bounds) = equal_span_boundaries(bytes, parse_workers) {
2538                (bounds, SCAN_EQUAL_SPAN)
2539            } else {
2540                (
2541                    collect_frame_starts(ConFrameIterator::from_bytes(bytes), true),
2542                    SCAN_SERIAL,
2543                )
2544            };
2545        let scan_s = t_scan.elapsed().as_secs_f64();
2546        let scan_minflt = process_minflt().saturating_sub(fault0);
2547        let parsed = parse_into_slots_timed(
2548            bytes,
2549            bytes.len(),
2550            &boundaries,
2551            parse_workers,
2552            |start, end| parse_byte_span(bytes, start, end),
2553        );
2554        recycle_starts(boundaries);
2555        (parsed, scan_s, scan_kind, scan_minflt)
2556    };
2557    let (pool_s, pool, packed) = match num_threads {
2558        None => (0.0, None, run()),
2559        Some(n) if n <= 1 => {
2560            let _ = n;
2561            keep_parse_pages();
2562            let fault0 = process_minflt();
2563            let t_scan = std::time::Instant::now();
2564            let boundaries = collect_frame_starts(ConFrameIterator::from_bytes(bytes), true);
2565            let scan_s = t_scan.elapsed().as_secs_f64();
2566            let scan_minflt = process_minflt().saturating_sub(fault0);
2567            let parsed =
2568                parse_into_slots_timed(bytes, bytes.len(), &boundaries, 1, |start, end| {
2569                    parse_byte_span(bytes, start, end)
2570                });
2571            (0.0, None, (parsed, scan_s, SCAN_SERIAL, scan_minflt))
2572        }
2573        Some(n) => {
2574            let t_pool = std::time::Instant::now();
2575            match shared_pool(n) {
2576                Some(pool) => {
2577                    let built = t_pool.elapsed().as_secs_f64();
2578                    let t_wake = std::time::Instant::now();
2579                    let (wake, body) = pool.install(|| {
2580                        let wake = t_wake.elapsed().as_secs_f64();
2581                        (wake, run())
2582                    });
2583                    // The same workers serve later files of this width.
2584                    // Their freed blocks stay warm for the next parse.
2585                    (built + wake, Some(pool), body)
2586                }
2587                None => (t_pool.elapsed().as_secs_f64(), None, run()),
2588            }
2589        }
2590    };
2591    let (parsed, scan_s, scan_kind, scan_minflt) = packed;
2592    TimedParse {
2593        slots: parsed.slots,
2594        pool,
2595        pool_s,
2596        pool_join_s: 0.0,
2597        scan_s,
2598        scan_minflt,
2599        slot_alloc_s: parsed.alloc_s,
2600        worker_parse_s: parsed.work_s,
2601        worker_sum_s: parsed.sum_s,
2602        worker_max_s: parsed.max_s,
2603        chunk_s: parsed.chunk_s,
2604        worker_minflt: parsed.worker_minflt,
2605        share_frames: parsed.share_frames,
2606        share_bytes: parsed.share_bytes,
2607        fail_lines: parsed.fail_lines,
2608        llc_misses: sample_llc_misses(),
2609        numa_remote: numa_remote_pages(),
2610        scan_kind,
2611    }
2612}
2613
2614/// Count frames without building atom payloads (uses [`ConFrameIterator::forward_fast`]
2615/// when possible, else [`ConFrameIterator::forward`]).
2616///
2617/// Prefer this over `read_all_frames(...).len()` when only the frame count is needed.
2618/// A rejected frame is [`LoadError::Parse`] with that frame's file line and
2619/// 0-based index, including when the failure is after the first frame.
2620pub fn count_frames(path: &Path) -> Result<usize, LoadError> {
2621    let contents = load_text(path)?;
2622    let text = contents.as_str().map_err(LoadError::Utf8)?;
2623    let mut n = 0usize;
2624    let mut iter = ConFrameIterator::new(text);
2625    loop {
2626        match iter.forward_fast() {
2627            Some(Ok(())) => n += 1,
2628            Some(Err(e)) => {
2629                return Err(LoadError::Parse(error::LocatedParseError::new(
2630                    e,
2631                    iter.lines.line_no(),
2632                    iter.frame_index(),
2633                )));
2634            }
2635            None => break,
2636        }
2637    }
2638    Ok(n)
2639}
2640
2641/// Reads only the first frame from a file.
2642///
2643/// More efficient than `read_all_frames` for single-frame access because it
2644/// stops parsing after the first frame rather than collecting all of them.
2645///
2646/// ```
2647/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
2648/// use readcon_core::iterators::read_first_frame;
2649/// let frame = read_first_frame(std::path::Path::new("resources/test/tiny_cuh2.con"))?;
2650/// println!("{:?} {}", frame.header.boxl, frame.atom_data.len());
2651/// assert!(!frame.atom_data.is_empty());
2652/// # Ok(())
2653/// # }
2654/// ```
2655pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
2656    let contents = crate::compression::read_file_contents(path)?;
2657    let text = contents.as_str()?;
2658    let mut iter = ConFrameIterator::new(text);
2659    match iter.next() {
2660        Some(Ok(frame)) => Ok(frame),
2661        Some(Err(e)) => Err(Box::new(e)),
2662        None => Err("No frames found in file".into()),
2663    }
2664}
2665
2666/// Skip `index` frames (no atom parse), then parse the next one.
2667///
2668/// `index == 0` is [`read_first_frame`]. Out of range is an error.
2669pub fn read_nth_frame(
2670    path: &Path,
2671    index: usize,
2672) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
2673    let contents = crate::compression::read_file_contents(path)?;
2674    let text = contents.as_str()?;
2675    read_nth_frame_from_text(text, index)
2676}
2677
2678/// Same as [`read_nth_frame`] on an already-loaded buffer.
2679pub fn read_nth_frame_from_text(
2680    text: &str,
2681    index: usize,
2682) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
2683    let mut iter = ConFrameIterator::new(text);
2684    let skipped = iter.skip_frames(index)?;
2685    if skipped < index {
2686        return Err(format!("frame {index} out of range (only {skipped} frames)").into());
2687    }
2688    match iter.next() {
2689        Some(Ok(frame)) => Ok(frame),
2690        Some(Err(e)) => Err(Box::new(e)),
2691        None => Err(format!("frame {index} out of range").into()),
2692    }
2693}
2694
2695/// Reads all frames using the checked `.rcidx` sidecar next to `path`.
2696///
2697/// Uncompressed input uses (and refreshes) `path.rcidx`; compressed
2698/// input is scanned in memory each time. UTF-8 is validated in
2699/// newline-aligned slices.
2700#[cfg(feature = "parallel")]
2701pub fn read_all_frames_indexed(
2702    path: &Path,
2703    num_threads: Option<usize>,
2704) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
2705    let contents = crate::compression::read_file_contents(path)?;
2706    let text = contents.as_str_sliced()?;
2707    let idx = match contents {
2708        crate::compression::FileContents::Mapped(_) => {
2709            crate::frame_index::load_or_build(path, text, true)?
2710        }
2711        crate::compression::FileContents::Owned(_) => {
2712            let on_disk = std::fs::metadata(path)?.len();
2713            crate::frame_index::load_or_build(path, text, on_disk == text.len() as u64)?
2714        }
2715    };
2716    let offs: Vec<usize> = idx.offsets.iter().map(|&o| o as usize).collect();
2717    parse_frames_at_offsets(text, &offs, num_threads)
2718        .into_iter()
2719        .map(|r| r.map_err(|e| Box::new(e) as Box<dyn std::error::Error>))
2720        .collect()
2721}
2722
2723enum IndexStep {
2724    Stay(usize),
2725    Missing(usize),
2726    Landed(usize, usize),
2727    Short(usize, usize),
2728}
2729
2730struct IndexedSpan {
2731    start: usize,
2732    content_end: usize,
2733    next_pos: usize,
2734}
2735
2736/// Lines in `bytes`, counting a final line that has no `\n`.
2737fn line_count(nl: &[usize], len: usize) -> usize {
2738    if len == 0 {
2739        0
2740    } else if nl.last().copied() == Some(len - 1) {
2741        nl.len()
2742    } else {
2743        nl.len() + 1
2744    }
2745}
2746
2747fn line_start_pos(nl: &[usize], len: usize, line_no: usize) -> usize {
2748    if line_no == 0 {
2749        return 0;
2750    }
2751    if line_no >= line_count(nl, len) {
2752        return len;
2753    }
2754    nl[line_no - 1] + 1
2755}
2756
2757/// Line index of the line that contains `pos`, or the line count at `len`.
2758fn line_no_for_pos(nl: &[usize], len: usize, pos: usize) -> usize {
2759    if pos >= len {
2760        return line_count(nl, len);
2761    }
2762    nl.partition_point(|&p| p < pos)
2763}
2764
2765fn indexed_span(nl: &[usize], bytes: &[u8], line_no: usize) -> Option<IndexedSpan> {
2766    let len = bytes.len();
2767    if line_no >= line_count(nl, len) {
2768        return None;
2769    }
2770    let start = if line_no == 0 { 0 } else { nl[line_no - 1] + 1 };
2771    let (raw_end, next_pos) = if line_no < nl.len() {
2772        (nl[line_no], nl[line_no] + 1)
2773    } else {
2774        (len, len)
2775    };
2776    let mut content_end = raw_end;
2777    if content_end > start && bytes[content_end - 1] == b'\r' {
2778        content_end -= 1;
2779    }
2780    Some(IndexedSpan {
2781        start,
2782        content_end,
2783        next_pos,
2784    })
2785}
2786
2787/// `n` cuts covering `0..=len`. A cut may fall in the middle of a header line.
2788#[cfg(feature = "parallel")]
2789fn equal_cuts(len: usize, n: usize) -> Vec<usize> {
2790    let n = n.max(1);
2791    let mut cuts = Vec::with_capacity(n + 1);
2792    for i in 0..=n {
2793        let at = ((i as u128) * (len as u128) / (n as u128)) as usize;
2794        let at = at.min(len);
2795        if cuts.last().copied() != Some(at) {
2796            cuts.push(at);
2797        }
2798    }
2799    if cuts.last().copied() != Some(len) {
2800        cuts.push(len);
2801    }
2802    cuts
2803}
2804
2805/// Newline offsets in `bytes`.
2806///
2807/// `cuts` splits the buffer into `[cuts[i], cuts[i + 1])`. Each piece is
2808/// scanned on its own and the offsets are concatenated. A `\n` belongs to
2809/// the piece that contains that byte, so a header line whose body crosses
2810/// a cut still contributes one newline.
2811#[cfg(feature = "parallel")]
2812fn newline_positions_at_cuts(bytes: &[u8], cuts: &[usize]) -> Vec<usize> {
2813    let spans: Vec<(usize, usize)> = cuts
2814        .windows(2)
2815        .map(|w| (w[0].min(bytes.len()), w[1].min(bytes.len())))
2816        .filter(|(start, end)| start < end)
2817        .collect();
2818    if spans.len() <= 1 {
2819        let (start, end) = spans.first().copied().unwrap_or((0, 0));
2820        return memchr::memchr_iter(b'\n', &bytes[start..end])
2821            .map(|p| start + p)
2822            .collect();
2823    }
2824    // The current Rayon pool, or the global pool when the caller has not
2825    // installed one. Chunk count is the worker cap: two chunks do not
2826    // wake every core.
2827    use rayon::prelude::*;
2828    let parts: Vec<Vec<usize>> = spans
2829        .par_iter()
2830        .map(|&(start, end)| {
2831            memchr::memchr_iter(b'\n', &bytes[start..end])
2832                .map(|p| start + p)
2833                .collect()
2834        })
2835        .collect();
2836    let mut all = Vec::with_capacity(bytes.len() / 32);
2837    for part in parts {
2838        all.extend(part);
2839    }
2840    all
2841}
2842
2843#[cfg(feature = "parallel")]
2844fn newline_positions(bytes: &[u8], threads: usize) -> Vec<usize> {
2845    newline_positions_at_cuts(bytes, &equal_cuts(bytes.len(), threads))
2846}
2847
2848/// Worker count for a newline scan.
2849///
2850/// `Some(n)` with `n >= 2` uses that many workers at every file length.
2851/// A file shorter than two bytes has nothing to split, and [`equal_cuts`]
2852/// drops a cut that repeats the previous byte, so a short file does not
2853/// invent an empty chunk. `None`, `Some(0)`, and `Some(1)` stay on the
2854/// calling thread. An omitted count must not start the global pool:
2855/// that pool made a one-frame file slower. There is no byte-length
2856/// cutoff taken from one host.
2857#[cfg(feature = "parallel")]
2858fn scan_thread_count(num_threads: Option<usize>, len: usize) -> usize {
2859    let requested = match num_threads {
2860        Some(n) if n >= 2 => n,
2861        _ => return 1,
2862    };
2863    if len < 2 { 1 } else { requested }
2864}
2865
2866/// I/O, UTF-8, or a located parse failure while loading a path.
2867#[derive(Debug)]
2868pub enum LoadError {
2869    Io(std::io::Error),
2870    Utf8(std::str::Utf8Error),
2871    Parse(error::LocatedParseError),
2872    /// The path opened and decoded, and it held no frame.
2873    Empty,
2874}
2875
2876impl std::fmt::Display for LoadError {
2877    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2878        match self {
2879            LoadError::Io(e) => write!(f, "{e}"),
2880            LoadError::Utf8(e) => write!(f, "{e}"),
2881            LoadError::Parse(e) => write!(f, "{e}"),
2882            LoadError::Empty => write!(f, "no frames found in file"),
2883        }
2884    }
2885}
2886
2887impl std::error::Error for LoadError {}
2888
2889fn load_error_from_box(err: Box<dyn std::error::Error>) -> LoadError {
2890    if let Some(io) = err.downcast_ref::<std::io::Error>() {
2891        return LoadError::Io(std::io::Error::new(io.kind(), io.to_string()));
2892    }
2893    LoadError::Io(std::io::Error::other(err.to_string()))
2894}
2895
2896fn load_text(path: &Path) -> Result<crate::compression::FileContents, LoadError> {
2897    crate::compression::read_file_contents(path).map_err(load_error_from_box)
2898}
2899
2900/// 1-based line number of the line that contains `byte`.
2901#[cfg(feature = "parallel")]
2902fn file_line_at(text: &str, byte: usize) -> u32 {
2903    let end = byte.min(text.len());
2904    1 + text.as_bytes()[..end]
2905        .iter()
2906        .filter(|&&b| b == b'\n')
2907        .count() as u32
2908}
2909
2910/// Collect every frame, keeping the line and frame index of the first error.
2911pub fn frames_from_text_located(
2912    text: &str,
2913    num_threads: Option<usize>,
2914) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
2915    #[cfg(feature = "parallel")]
2916    {
2917        // A caller that does not ask for a pool stays on this thread.
2918        // Starting the global pool for a long file made a one-frame read slower.
2919        let use_parallel = matches!(num_threads, Some(n) if n >= 2);
2920        if use_parallel {
2921            let pool = num_threads.filter(|&n| n >= 2);
2922            return collect_parallel_located(text, pool);
2923        }
2924    }
2925    #[cfg(not(feature = "parallel"))]
2926    {
2927        let _ = num_threads;
2928    }
2929    let mut frames = Vec::new();
2930    let mut iter = ConFrameIterator::new(text);
2931    let mut index = 0u64;
2932    while let Some(item) = iter.next() {
2933        match item {
2934            Ok(frame) => {
2935                frames.push(frame);
2936                index += 1;
2937            }
2938            Err(e) => {
2939                return Err(error::LocatedParseError::new(
2940                    e,
2941                    iter.lines.line_no(),
2942                    index,
2943                ));
2944            }
2945        }
2946    }
2947    Ok(frames)
2948}
2949
2950/// Read every frame from `path`, with the line and frame index of the first error.
2951pub fn read_all_frames_located(
2952    path: &Path,
2953    num_threads: Option<usize>,
2954) -> Result<Vec<types::ConFrame>, LoadError> {
2955    let contents = load_text(path)?;
2956    let text = contents.as_str().map_err(LoadError::Utf8)?;
2957    frames_from_text_located(text, num_threads).map_err(LoadError::Parse)
2958}
2959
2960/// Read the first frame from `path`, with a line number on failure.
2961pub fn read_first_frame_located(path: &Path) -> Result<types::ConFrame, LoadError> {
2962    let contents = load_text(path)?;
2963    let text = contents.as_str().map_err(LoadError::Utf8)?;
2964    let mut iter = ConFrameIterator::new(text);
2965    match iter.next() {
2966        Some(Ok(frame)) => Ok(frame),
2967        Some(Err(e)) => Err(LoadError::Parse(error::LocatedParseError::new(
2968            e,
2969            iter.lines.line_no(),
2970            0,
2971        ))),
2972        None => Err(LoadError::Empty),
2973    }
2974}
2975
2976/// Skip `index` frames, then parse one, with a line number on failure.
2977pub fn read_nth_frame_located(path: &Path, index: usize) -> Result<types::ConFrame, LoadError> {
2978    let contents = load_text(path)?;
2979    let text = contents.as_str().map_err(LoadError::Utf8)?;
2980    let mut iter = ConFrameIterator::new(text);
2981    match iter.skip_frames_counted(index) {
2982        Ok(skipped) if skipped == index => {}
2983        Ok(skipped) => {
2984            return Err(LoadError::Parse(error::LocatedParseError::new(
2985                error::ParseError::IncompleteFrame,
2986                iter.lines.line_no(),
2987                skipped as u64,
2988            )));
2989        }
2990        Err((failed_at, e)) => {
2991            return Err(LoadError::Parse(error::LocatedParseError::new(
2992                e,
2993                iter.lines.line_no(),
2994                failed_at as u64,
2995            )));
2996        }
2997    }
2998    match iter.next() {
2999        Some(Ok(frame)) => Ok(frame),
3000        Some(Err(e)) => Err(LoadError::Parse(error::LocatedParseError::new(
3001            e,
3002            iter.lines.line_no(),
3003            index as u64,
3004        ))),
3005        None => Err(LoadError::Parse(error::LocatedParseError::new(
3006            error::ParseError::IncompleteFrame,
3007            iter.lines.line_no(),
3008            index as u64,
3009        ))),
3010    }
3011}
3012
3013fn collect_frame_starts(scanner: ConFrameIterator<'_>, skip_whitespace_tail: bool) -> Vec<usize> {
3014    #[cfg(target_arch = "x86_64")]
3015    {
3016        if std::is_x86_feature_detected!("avx2") {
3017            // One AVX2 entry so the line skip stays in this function.
3018            // A separate call per block pays for an AVX/SSE transition.
3019            // SAFETY: the runtime check above is the AVX2 and POPCNT contract.
3020            return unsafe { collect_frame_starts_avx2(scanner, skip_whitespace_tail) };
3021        }
3022    }
3023    collect_frame_starts_inner(scanner, skip_whitespace_tail)
3024}
3025
3026#[cfg(target_arch = "x86_64")]
3027#[target_feature(enable = "avx2,popcnt")]
3028unsafe fn collect_frame_starts_avx2(
3029    scanner: ConFrameIterator<'_>,
3030    skip_whitespace_tail: bool,
3031) -> Vec<usize> {
3032    collect_frame_starts_inner(scanner, skip_whitespace_tail)
3033}
3034
3035#[inline(always)]
3036fn collect_frame_starts_inner(
3037    mut scanner: ConFrameIterator<'_>,
3038    skip_whitespace_tail: bool,
3039) -> Vec<usize> {
3040    let mut boundaries = Vec::new();
3041    loop {
3042        scanner.lines.clear_peek();
3043        let start = scanner.lines.pos;
3044        if start >= scanner.lines.bytes.len() {
3045            break;
3046        }
3047        if skip_whitespace_tail {
3048            let rest = &scanner.lines.bytes[start..];
3049            if rest.iter().all(u8::is_ascii_whitespace) {
3050                break;
3051            }
3052        }
3053        boundaries.push(start);
3054        match scanner.forward_fast_to(None) {
3055            Some(Ok(())) => {}
3056            Some(Err(_)) | None => break,
3057        }
3058    }
3059    boundaries
3060}
3061
3062/// Serial header walk. This is the reference the chunked scan must match.
3063pub fn frame_start_offsets_serial(file_contents: &str) -> Vec<usize> {
3064    collect_frame_starts(ConFrameIterator::new(file_contents), false)
3065}
3066
3067#[cfg(feature = "parallel")]
3068fn scanner_with_newlines<'a>(text: &'a str, newlines: Vec<usize>) -> ConFrameIterator<'a> {
3069    let mut scanner = ConFrameIterator::new(text);
3070    scanner.lines.index_line = 0;
3071    scanner.lines.newlines = Some(newlines);
3072    scanner
3073}
3074
3075#[cfg(feature = "parallel")]
3076fn frame_start_offsets_indexed(text: &str, newlines: Vec<usize>) -> Vec<usize> {
3077    collect_frame_starts(scanner_with_newlines(text, newlines), false)
3078}
3079
3080/// Frame starts for `text` when the newline scan is split at `cuts`.
3081///
3082/// `cuts` is the chunk edges, including 0 and `text.len()`. A cut inside a
3083/// header line is the straddling case: the stitched offsets are still the
3084/// serial offsets.
3085#[cfg(all(feature = "parallel", test))]
3086fn frame_start_offsets_at_cuts(text: &str, cuts: &[usize]) -> Vec<usize> {
3087    let newlines = newline_positions_at_cuts(text.as_bytes(), cuts);
3088    frame_start_offsets_indexed(text, newlines)
3089}
3090
3091/// Frame start byte offsets via [`ConFrameIterator::forward_fast`] (no atom parse).
3092///
3093/// Stops at the first skip error (that start is still included).
3094///
3095/// With the `parallel` feature and more than one worker, the buffer is
3096/// split into chunks and the newline bytes are scanned together. The
3097/// length of the buffer does not choose the worker count. The chunks
3098/// are not aligned to lines. A header
3099/// that straddles a cut is one line, because each `\n` is recorded by the
3100/// chunk that holds that byte and the lists are stitched in order. The
3101/// returned offsets match the serial walk.
3102pub fn frame_start_offsets(file_contents: &str) -> Vec<usize> {
3103    frame_start_offsets_with_threads(file_contents, None)
3104}
3105
3106/// [`frame_start_offsets`] using `num_threads` chunks.
3107///
3108/// `None` uses the machine's parallelism. `Some(0)` and `Some(1)` stay on
3109/// the calling thread. The offsets do not depend on the thread count.
3110///
3111/// The timed work includes the cut setup, the per-chunk scans, the
3112/// stitch, and the frame walk that reads the stitched newline index.
3113pub fn frame_start_offsets_with_threads(
3114    file_contents: &str,
3115    num_threads: Option<usize>,
3116) -> Vec<usize> {
3117    #[cfg(feature = "parallel")]
3118    {
3119        let threads = scan_thread_count(num_threads, file_contents.len());
3120        if threads >= 2 {
3121            let newlines = newline_positions(file_contents.as_bytes(), threads);
3122            return frame_start_offsets_indexed(file_contents, newlines);
3123        }
3124    }
3125    #[cfg(not(feature = "parallel"))]
3126    {
3127        let _ = num_threads;
3128    }
3129    frame_start_offsets_serial(file_contents)
3130}
3131
3132/// Parses frames in parallel using rayon, splitting on frame boundaries.
3133///
3134/// Phase 1: frame starts from a serial [`ConFrameIterator::forward_fast`]
3135/// walk. The header names each block's line count, so the walk does not
3136/// parse atom rows. Phase 2 parses those spans on a Rayon pool and
3137/// stores the frames in file order. See [`parse_frames_parallel_with_threads`].
3138///
3139/// Requires the `parallel` feature.
3140#[cfg(feature = "parallel")]
3141pub fn parse_frames_parallel(
3142    file_contents: &str,
3143) -> Vec<Result<types::ConFrame, error::ParseError>> {
3144    parse_frames_parallel_with_threads(file_contents, None)
3145}
3146
3147/// Like [`parse_frames_parallel`], but `Some(n)` with `n >= 2` parses on
3148/// a Rayon pool of that size. `None`, `Some(0)`, and `Some(1)` stay on
3149/// the calling thread.
3150///
3151/// The boundary walk is serial. The parse ranges are byte-balanced and
3152/// the frames come back in file order, field for field with a serial
3153/// iterator.
3154///
3155/// Requires the `parallel` feature.
3156#[cfg(feature = "parallel")]
3157pub fn parse_frames_parallel_with_threads(
3158    file_contents: &str,
3159    num_threads: Option<usize>,
3160) -> Vec<Result<types::ConFrame, error::ParseError>> {
3161    let parse_workers = match num_threads {
3162        Some(n) if n >= 2 => n,
3163        _ => 1,
3164    };
3165    let run = || {
3166        // A whitespace tail is not a frame. The serial iterator stops
3167        // there. The offset scan used by callers still records that byte
3168        // when a skip error includes it.
3169        let boundaries = collect_frame_starts(ConFrameIterator::new(file_contents), true);
3170        parse_into_slots(
3171            file_contents.as_bytes(),
3172            file_contents.len(),
3173            &boundaries,
3174            parse_workers,
3175            |start, end| parse_text_span(file_contents, start, end),
3176        )
3177    };
3178    match num_threads {
3179        Some(n) if n >= 2 => match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
3180            Ok(pool) => pool.install(run),
3181            Err(_) => run(),
3182        },
3183        _ => run(),
3184    }
3185}
3186
3187/// Phase 2 of [`parse_frames_parallel_with_threads`]: parse the frames
3188/// that start at `boundaries` (strictly increasing byte offsets into
3189/// `file_contents`; frame `i` ends where frame `i + 1` starts).
3190///
3191/// Offsets may come from [`frame_start_offsets`] or from a checked
3192/// [`crate::frame_index::FrameIndex`]. The output slots are allocated
3193/// first. Each worker then fills one contiguous index range. Results
3194/// stay in frame order.
3195///
3196/// The timer for this function starts after `boundaries` exist. It
3197/// includes slot allocation and the worker parse. It does not include
3198/// the newline scan or the stitch.
3199///
3200/// Requires the `parallel` feature.
3201#[cfg(feature = "parallel")]
3202pub fn parse_frames_at_offsets(
3203    file_contents: &str,
3204    boundaries: &[usize],
3205    num_threads: Option<usize>,
3206) -> Vec<Result<types::ConFrame, error::ParseError>> {
3207    let workers = match num_threads {
3208        Some(n) if n <= 1 => 1,
3209        Some(n) => n,
3210        None => rayon::current_num_threads().max(1),
3211    };
3212    let run = || {
3213        parse_into_slots(
3214            file_contents.as_bytes(),
3215            file_contents.len(),
3216            boundaries,
3217            workers,
3218            |start, end| parse_text_span(file_contents, start, end),
3219        )
3220    };
3221    match num_threads {
3222        Some(n) if n >= 2 => match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
3223            Ok(pool) => pool.install(run),
3224            Err(_) => run(),
3225        },
3226        _ => run(),
3227    }
3228}
3229
3230/// Seconds for one phased read. The open time is the file read or the
3231/// map. Scan, slot allocation, and the worker parse are separate.
3232#[derive(Clone, Copy, Debug, Default)]
3233pub struct ReadPhaseTimes {
3234    pub open_mmap_s: f64,
3235    /// Rayon pool construction. Zero when the call stays on one thread.
3236    pub pool_s: f64,
3237    /// Joining the local pool after the scan and the parse. Zero when
3238    /// the call stays on one thread.
3239    pub pool_join_s: f64,
3240    pub scan_s: f64,
3241    pub slot_alloc_s: f64,
3242    pub worker_parse_s: f64,
3243    /// Moving each parsed `Result` into the frame vector.
3244    pub finish_s: f64,
3245    /// Sum of per-chunk parse times. This is CPU time, not wall time.
3246    pub worker_sum_s: f64,
3247    /// Slowest parse chunk. This is inside `worker_parse_s`.
3248    pub worker_max_s: f64,
3249    /// Drop of the mapped or owned file buffer after the parse.
3250    pub close_s: f64,
3251    /// Wake of the worker pool and the other gaps inside the read.
3252    pub schedule_s: f64,
3253    /// Minor faults during the scan.
3254    pub scan_minflt: i64,
3255    /// Sum of per-worker minor faults during the parse.
3256    pub parse_minflt: i64,
3257    /// LLC read misses, or `-1` when the guest refuses the counter.
3258    pub llc_misses: i64,
3259    /// Pages on a node other than node 0. Zero on a one-node guest.
3260    pub numa_remote: i64,
3261    /// Minor faults while the coordinate slab is filled.
3262    pub slab_minflt: i64,
3263    /// Minor faults during the touch checksum.
3264    pub touch_minflt: i64,
3265    /// True when the bytes came from a map rather than an owned buffer.
3266    pub mapped: bool,
3267}
3268
3269/// Frames from one phased read, plus the byte length of the buffer.
3270pub struct PhasedFrames {
3271    pub frames: Vec<types::ConFrame>,
3272    pub phases: ReadPhaseTimes,
3273    pub n_bytes: usize,
3274    /// Per-chunk parse seconds, in claim order.
3275    pub chunk_s: Vec<f64>,
3276    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`] when
3277    /// `parallel` is on. Zero otherwise.
3278    pub scan_kind: u8,
3279    /// Minor faults on each parse worker.
3280    pub worker_minflt: Vec<i64>,
3281    /// Frames in each worker share.
3282    pub share_frames: Vec<u64>,
3283    /// File bytes in each worker share.
3284    pub share_bytes: Vec<u64>,
3285    /// Pool kept for the slab, the touch, and frame teardown.
3286    #[cfg(feature = "parallel")]
3287    pub pool: Option<std::sync::Arc<rayon::ThreadPool>>,
3288}
3289
3290/// Read `path` and time open, scan, slot allocation, and the worker parse.
3291///
3292/// Plain text at or above the map threshold is mapped. Smaller files and
3293/// compressed files are owned buffers. `Some(n)` with `n >= 2` parses on
3294/// that many workers. Every other count parses on the calling thread.
3295/// The frame list matches [`frames_from_bytes`].
3296///
3297/// A parse failure is the [`error::LocatedParseError`] from the parse
3298/// that failed. The path is not opened again.
3299pub fn read_all_frames_phased(
3300    path: &Path,
3301    num_threads: Option<usize>,
3302) -> Result<PhasedFrames, Box<dyn std::error::Error>> {
3303    let t_all = std::time::Instant::now();
3304    let t_open = std::time::Instant::now();
3305    let contents = crate::compression::read_file_contents(path)?;
3306    let open_mmap_s = t_open.elapsed().as_secs_f64();
3307    let mapped = matches!(contents, crate::compression::FileContents::Mapped(_));
3308    let n_bytes = match &contents {
3309        crate::compression::FileContents::Owned(text) => text.len(),
3310        crate::compression::FileContents::Mapped(map) => map.len(),
3311    };
3312    let bytes: &[u8] = match &contents {
3313        crate::compression::FileContents::Owned(text) => text.as_bytes(),
3314        crate::compression::FileContents::Mapped(map) => map,
3315    };
3316    let phased = frames_from_bytes_phased_located(bytes, num_threads)?;
3317    let workers = match num_threads {
3318        Some(n) if n >= 2 => n,
3319        _ => 1,
3320    };
3321    let t_close = std::time::Instant::now();
3322    if let crate::compression::FileContents::Mapped(map) = &contents {
3323        // The serial unmap of a fully resident file is the multi-worker
3324        // tail. Advise the pages away on the read's pool first.
3325        // One worker unmaps directly, so a serial read does not walk
3326        // the pages twice.
3327        if workers >= 2 {
3328            #[cfg(feature = "parallel")]
3329            {
3330                if let Some(pool) = phased.pool.as_ref() {
3331                    pool.install(|| {
3332                        crate::compression::advise_dontneed_parallel(map, workers);
3333                    });
3334                }
3335            }
3336            #[cfg(not(feature = "parallel"))]
3337            {
3338                let _ = (map, workers);
3339            }
3340        }
3341    }
3342    drop(contents);
3343    let close_s = t_close.elapsed().as_secs_f64();
3344    let elapsed = t_all.elapsed().as_secs_f64();
3345    let named = open_mmap_s
3346        + phased.pool_s
3347        + phased.pool_join_s
3348        + phased.scan_s
3349        + phased.slot_alloc_s
3350        + phased.worker_parse_s
3351        + phased.finish_s
3352        + close_s;
3353    let schedule_s = (elapsed - named).max(0.0);
3354    let parse_minflt = phased.worker_minflt.iter().sum();
3355    Ok(PhasedFrames {
3356        frames: phased.frames,
3357        n_bytes,
3358        chunk_s: phased.chunk_s,
3359        scan_kind: phased.scan_kind,
3360        worker_minflt: phased.worker_minflt,
3361        share_frames: phased.share_frames,
3362        share_bytes: phased.share_bytes,
3363        phases: ReadPhaseTimes {
3364            open_mmap_s,
3365            pool_s: phased.pool_s,
3366            pool_join_s: phased.pool_join_s,
3367            scan_s: phased.scan_s,
3368            slot_alloc_s: phased.slot_alloc_s,
3369            worker_parse_s: phased.worker_parse_s,
3370            finish_s: phased.finish_s,
3371            worker_sum_s: phased.worker_sum_s,
3372            worker_max_s: phased.worker_max_s,
3373            close_s,
3374            schedule_s,
3375            scan_minflt: phased.scan_minflt,
3376            parse_minflt,
3377            llc_misses: phased.llc_misses,
3378            numa_remote: phased.numa_remote,
3379            mapped,
3380            ..ReadPhaseTimes::default()
3381        },
3382        #[cfg(feature = "parallel")]
3383        pool: phased.pool,
3384    })
3385}
3386
3387/// [`frames_from_bytes`] with scan, slot allocation, and worker-parse times.
3388///
3389/// `Some(n)` with `n >= 2` uses that pool. Every other count, including
3390/// `None`, stays on the calling thread. That is the `read_con` policy:
3391/// an omitted count does not start a pool.
3392pub struct PhasedByteTimes {
3393    pub frames: Vec<types::ConFrame>,
3394    pub pool_s: f64,
3395    pub pool_join_s: f64,
3396    pub scan_s: f64,
3397    pub slot_alloc_s: f64,
3398    pub worker_parse_s: f64,
3399    pub finish_s: f64,
3400    pub worker_sum_s: f64,
3401    pub worker_max_s: f64,
3402    /// Per-chunk parse seconds, in claim order.
3403    pub chunk_s: Vec<f64>,
3404    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`] when
3405    /// `parallel` is on. Zero otherwise.
3406    pub scan_kind: u8,
3407    /// Minor faults during the scan.
3408    pub scan_minflt: i64,
3409    /// Minor faults on each parse worker, in worker order.
3410    pub worker_minflt: Vec<i64>,
3411    pub share_frames: Vec<u64>,
3412    pub share_bytes: Vec<u64>,
3413    /// LLC read misses, or `-1` when the guest refuses the counter.
3414    pub llc_misses: i64,
3415    /// Pages on a node other than node 0.
3416    pub numa_remote: i64,
3417    /// Pool kept for the slab, the touch, and frame teardown.
3418    #[cfg(feature = "parallel")]
3419    pub pool: Option<std::sync::Arc<rayon::ThreadPool>>,
3420}
3421
3422pub fn frames_from_bytes_phased(
3423    bytes: &[u8],
3424    num_threads: Option<usize>,
3425) -> Result<PhasedByteTimes, error::ParseError> {
3426    frames_from_bytes_phased_located(bytes, num_threads).map_err(|err| err.error)
3427}
3428
3429fn frames_from_bytes_phased_located(
3430    bytes: &[u8],
3431    num_threads: Option<usize>,
3432) -> Result<PhasedByteTimes, error::LocatedParseError> {
3433    #[cfg(feature = "parallel")]
3434    {
3435        // One thread walks the file once. A scan plus a second parse made
3436        // a short file slower than the plain iterator.
3437        if !matches!(num_threads, Some(n) if n >= 2) {
3438            let flt0 = thread_minflt();
3439            let t_work = std::time::Instant::now();
3440            let frames = collect_bytes_located(bytes)?;
3441            let worker_parse_s = t_work.elapsed().as_secs_f64();
3442            let worker_flt = thread_minflt().saturating_sub(flt0);
3443            let n_frames = frames.len() as u64;
3444            return Ok(PhasedByteTimes {
3445                frames,
3446                pool_s: 0.0,
3447                pool_join_s: 0.0,
3448                scan_s: 0.0,
3449                slot_alloc_s: 0.0,
3450                worker_parse_s,
3451                finish_s: 0.0,
3452                worker_sum_s: worker_parse_s,
3453                worker_max_s: worker_parse_s,
3454                chunk_s: vec![worker_parse_s],
3455                scan_kind: SCAN_SERIAL,
3456                scan_minflt: 0,
3457                worker_minflt: vec![worker_flt],
3458                share_frames: vec![n_frames],
3459                share_bytes: vec![bytes.len() as u64],
3460                llc_misses: sample_llc_misses(),
3461                numa_remote: numa_remote_pages(),
3462                pool: None,
3463            });
3464        }
3465        let requested = Some(num_threads.expect("workers"));
3466        let timed = parse_bytes_timed(bytes, requested);
3467        let t_finish = std::time::Instant::now();
3468        let frames = if timed.slots.iter().any(|item| item.is_err()) {
3469            collect_located_slots(timed.slots, &timed.fail_lines)?
3470        } else {
3471            finish_results(timed.pool.as_deref(), timed.slots)
3472                .map_err(|error| error::LocatedParseError::new(error, 0, 0))?
3473        };
3474        let finish_s = t_finish.elapsed().as_secs_f64();
3475        Ok(PhasedByteTimes {
3476            frames,
3477            pool_s: timed.pool_s,
3478            pool_join_s: timed.pool_join_s,
3479            scan_s: timed.scan_s,
3480            slot_alloc_s: timed.slot_alloc_s,
3481            worker_parse_s: timed.worker_parse_s,
3482            finish_s,
3483            worker_sum_s: timed.worker_sum_s,
3484            worker_max_s: timed.worker_max_s,
3485            chunk_s: timed.chunk_s,
3486            scan_kind: timed.scan_kind,
3487            scan_minflt: timed.scan_minflt,
3488            worker_minflt: timed.worker_minflt,
3489            share_frames: timed.share_frames,
3490            share_bytes: timed.share_bytes,
3491            llc_misses: timed.llc_misses,
3492            numa_remote: timed.numa_remote,
3493            pool: timed.pool,
3494        })
3495    }
3496    #[cfg(not(feature = "parallel"))]
3497    {
3498        let _ = num_threads;
3499        let t_work = std::time::Instant::now();
3500        let frames = collect_bytes_located(bytes)?;
3501        let worker_parse_s = t_work.elapsed().as_secs_f64();
3502        Ok(PhasedByteTimes {
3503            frames,
3504            pool_s: 0.0,
3505            pool_join_s: 0.0,
3506            scan_s: 0.0,
3507            slot_alloc_s: 0.0,
3508            worker_parse_s,
3509            finish_s: 0.0,
3510            worker_sum_s: worker_parse_s,
3511            worker_max_s: worker_parse_s,
3512            chunk_s: vec![worker_parse_s],
3513            scan_kind: 0,
3514            scan_minflt: 0,
3515            worker_minflt: Vec::new(),
3516            share_frames: Vec::new(),
3517            share_bytes: Vec::new(),
3518            llc_misses: -1,
3519            numa_remote: 0,
3520        })
3521    }
3522}
3523
3524/// Copy every frame's coordinates into one row-major buffer.
3525///
3526/// `None` means the frames do not share one atom count. `workers >= 2`
3527/// writes disjoint frame runs. The copy is bit-identical to reading
3528/// each frame's `f64` positions in order.
3529pub fn pack_position_slab(frames: &[types::ConFrame], workers: usize) -> Option<Vec<f64>> {
3530    if frames.is_empty() {
3531        return None;
3532    }
3533    let n_atoms = frames[0].positions.nrows();
3534    if frames
3535        .iter()
3536        .any(|frame| frame.positions.nrows() != n_atoms)
3537    {
3538        return None;
3539    }
3540    let stride = n_atoms.saturating_mul(3);
3541    let total = frames.len().saturating_mul(stride);
3542    let mut values = Vec::<f64>::with_capacity(total);
3543    if total > 0 {
3544        // The parallel writers are the first touch of these pages.
3545        // A zeroed allocation would fault the whole slab on this thread.
3546        // SAFETY: every index is overwritten before the buffer is read.
3547        unsafe {
3548            values.set_len(total);
3549        }
3550    }
3551    if stride == 0 {
3552        return Some(values);
3553    }
3554    let write = |frame: &types::ConFrame, dest: &mut [f64]| {
3555        if let Some(src) = frame.positions.f64_slice() {
3556            dest.copy_from_slice(src);
3557        } else {
3558            for (row, slot) in dest.as_chunks_mut::<3>().0.iter_mut().enumerate() {
3559                slot.copy_from_slice(&frame.positions.as_f64_row(row));
3560            }
3561        }
3562    };
3563    #[cfg(feature = "parallel")]
3564    {
3565        if workers >= 2 {
3566            let chunk_frames = frames.len().div_ceil(workers).max(1);
3567            let chunk_vals = chunk_frames.saturating_mul(stride);
3568            let copy_chunks = |dest_all: &mut [f64]| {
3569                use rayon::prelude::*;
3570                dest_all
3571                    .par_chunks_mut(chunk_vals)
3572                    .enumerate()
3573                    .for_each(|(chunk_index, dest)| {
3574                        let base = chunk_index * chunk_frames;
3575                        for (offset, slot) in dest.chunks_exact_mut(stride).enumerate() {
3576                            let index = base + offset;
3577                            if index < frames.len() {
3578                                write(&frames[index], slot);
3579                            }
3580                        }
3581                    });
3582            };
3583            // `par_chunks_mut` follows the installed pool. Outside one,
3584            // a private scope of `workers` threads keeps the global pool
3585            // out of this phase.
3586            if rayon::current_thread_index().is_some() {
3587                first_touch_f64(&mut values, workers);
3588                copy_chunks(&mut values);
3589            } else {
3590                first_touch_f64(&mut values, workers);
3591                std::thread::scope(|scope| {
3592                    let mut rest = &mut values[..];
3593                    let mut base = 0usize;
3594                    let mut handles = Vec::new();
3595                    while base < frames.len() {
3596                        let take_frames = chunk_frames.min(frames.len() - base);
3597                        let take_vals = take_frames * stride;
3598                        let (dest, tail) = rest.split_at_mut(take_vals);
3599                        rest = tail;
3600                        let frame_slice = &frames[base..base + take_frames];
3601                        handles.push(scope.spawn(move || {
3602                            for (frame, slot) in
3603                                frame_slice.iter().zip(dest.chunks_exact_mut(stride))
3604                            {
3605                                write(frame, slot);
3606                            }
3607                        }));
3608                        base += take_frames;
3609                    }
3610                    for handle in handles {
3611                        handle.join().expect("slab worker");
3612                    }
3613                });
3614            }
3615            return Some(values);
3616        }
3617    }
3618    #[cfg(not(feature = "parallel"))]
3619    {
3620        let _ = workers;
3621    }
3622    for (frame, dest) in frames.iter().zip(values.chunks_exact_mut(stride)) {
3623        write(frame, dest);
3624    }
3625    Some(values)
3626}
3627
3628fn mix_touch(hash: u64, value: u64) -> u64 {
3629    hash.wrapping_mul(0x9E3779B185EBCA87) ^ value
3630}
3631
3632fn one_frame_touch(frame: &types::ConFrame, coords: &[f64], index: u64) -> u64 {
3633    let mut hash = index;
3634    for value in frame.header.boxl {
3635        hash = mix_touch(hash, value.to_bits());
3636    }
3637    for atom in &frame.atom_data {
3638        hash = mix_touch(hash, atom.symbol.len() as u64);
3639        for byte in atom.symbol.as_bytes() {
3640            hash = mix_touch(hash, u64::from(*byte));
3641        }
3642    }
3643    for value in coords {
3644        hash = mix_touch(hash, value.to_bits());
3645    }
3646    hash
3647}
3648
3649/// XOR of positions, cell lengths, and atom-type symbols.
3650///
3651/// `positions` is frame-major `[frames, atoms, 3]`. `None` means the
3652/// slice does not match the frames. `workers >= 2` folds disjoint
3653/// frame runs on that many threads. Each frame's hash includes its
3654/// index, and the returned word is the XOR of those hashes.
3655pub fn touch_frame_checksum(
3656    frames: &[types::ConFrame],
3657    positions: &[f64],
3658    workers: usize,
3659) -> Option<u64> {
3660    if frames.is_empty() {
3661        return Some(0);
3662    }
3663    let n_atoms = frames[0].atom_data.len();
3664    let stride = n_atoms.saturating_mul(3);
3665    if stride == 0 || positions.len() != frames.len().saturating_mul(stride) {
3666        return None;
3667    }
3668    if frames.iter().any(|frame| frame.atom_data.len() != n_atoms) {
3669        return None;
3670    }
3671    let one = |index: usize| {
3672        let start = index * stride;
3673        one_frame_touch(
3674            &frames[index],
3675            &positions[start..start + stride],
3676            index as u64,
3677        )
3678    };
3679    Some(reduce_touch(frames.len(), workers, one))
3680}
3681
3682/// [`touch_frame_checksum`] for frames still sitting in optional slots.
3683///
3684/// `None` means a slot is empty or the position slice does not match.
3685pub fn touch_option_checksum(
3686    frames: &[Option<types::ConFrame>],
3687    positions: &[f64],
3688    workers: usize,
3689) -> Option<u64> {
3690    if frames.is_empty() {
3691        return Some(0);
3692    }
3693    if frames.iter().any(Option::is_none) {
3694        return None;
3695    }
3696    let n_atoms = frames[0].as_ref()?.atom_data.len();
3697    let stride = n_atoms.saturating_mul(3);
3698    if stride == 0 || positions.len() != frames.len().saturating_mul(stride) {
3699        return None;
3700    }
3701    if frames
3702        .iter()
3703        .any(|frame| frame.as_ref().map(|item| item.atom_data.len()) != Some(n_atoms))
3704    {
3705        return None;
3706    }
3707    let one = |index: usize| {
3708        let start = index * stride;
3709        one_frame_touch(
3710            frames[index].as_ref().expect("slot"),
3711            &positions[start..start + stride],
3712            index as u64,
3713        )
3714    };
3715    Some(reduce_touch(frames.len(), workers, one))
3716}
3717
3718fn reduce_touch(n: usize, workers: usize, one: impl Fn(usize) -> u64 + Sync) -> u64 {
3719    #[cfg(feature = "parallel")]
3720    if workers >= 2 {
3721        let fold_range = |first: usize, last: usize| {
3722            let mut acc = 0u64;
3723            for index in first..last {
3724                acc ^= one(index);
3725            }
3726            acc
3727        };
3728        let chunk = n.div_ceil(workers).max(1);
3729        // Reuse the installed read pool. A fresh pool per touch is the
3730        // build this phase used to pay on every call.
3731        if rayon::current_thread_index().is_some() {
3732            use rayon::prelude::*;
3733            return (0..workers)
3734                .into_par_iter()
3735                .map(|worker| {
3736                    let first = worker * chunk;
3737                    let last = (first + chunk).min(n);
3738                    fold_range(first, last)
3739                })
3740                .reduce(|| 0u64, |left, right| left ^ right);
3741        }
3742        let mut acc = 0u64;
3743        std::thread::scope(|scope| {
3744            let mut handles = Vec::with_capacity(workers);
3745            for worker in 0..workers {
3746                handles.push(scope.spawn(move || {
3747                    let first = worker * chunk;
3748                    let last = (first + chunk).min(n);
3749                    fold_range(first, last)
3750                }));
3751            }
3752            for handle in handles {
3753                acc ^= handle.join().unwrap_or(0);
3754            }
3755        });
3756        return acc;
3757    }
3758    #[cfg(not(feature = "parallel"))]
3759    {
3760        let _ = workers;
3761    }
3762    (0..n).map(one).fold(0, |acc, item| acc ^ item)
3763}
3764
3765/// Time a newline scan of `text`.
3766///
3767/// `chunked` with `threads >= 2` splits the buffer into that many
3768/// chunks, stitches the newline index, and walks frame starts. The
3769/// pool is built before the timer starts. The serial walk is
3770/// [`frame_start_offsets_serial`]. The returned offsets are the frame
3771/// starts. The caller chooses the worker count. File length does not.
3772pub fn measure_newline_scan(text: &str, threads: usize, chunked: bool) -> (Vec<usize>, f64) {
3773    #[cfg(feature = "parallel")]
3774    {
3775        if chunked && threads >= 2 {
3776            let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build();
3777            let t_scan = std::time::Instant::now();
3778            let offsets = match pool {
3779                Ok(pool) => pool.install(|| {
3780                    let newlines = newline_positions(text.as_bytes(), threads);
3781                    frame_start_offsets_indexed(text, newlines)
3782                }),
3783                Err(_) => {
3784                    let newlines = newline_positions(text.as_bytes(), threads);
3785                    frame_start_offsets_indexed(text, newlines)
3786                }
3787            };
3788            return (offsets, t_scan.elapsed().as_secs_f64());
3789        }
3790    }
3791    #[cfg(not(feature = "parallel"))]
3792    {
3793        let _ = (threads, chunked);
3794    }
3795    let t_scan = std::time::Instant::now();
3796    let offsets = frame_start_offsets_serial(text);
3797    (offsets, t_scan.elapsed().as_secs_f64())
3798}
3799
3800/// Parallel parse that keeps the file line of the first error.
3801/// Workers return the error. They do not write thread-local error state.
3802#[cfg(feature = "parallel")]
3803fn collect_parallel_located(
3804    file_contents: &str,
3805    num_threads: Option<usize>,
3806) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
3807    use rayon::prelude::*;
3808
3809    let boundaries = frame_start_offsets(file_contents);
3810    let parse_chunks = || -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
3811        let num_frames = boundaries.len();
3812        let parts: Vec<Result<types::ConFrame, error::LocatedParseError>> = (0..num_frames)
3813            .into_par_iter()
3814            .map(|i| {
3815                let start = boundaries[i];
3816                let end = if i + 1 < num_frames {
3817                    boundaries[i + 1]
3818                } else {
3819                    file_contents.len()
3820                };
3821                let chunk = &file_contents[start..end];
3822                let mut iter = ConFrameIterator::new(chunk);
3823                match iter.next() {
3824                    Some(Ok(frame)) => Ok(frame),
3825                    Some(Err(e)) => {
3826                        let local = iter.lines.line_no();
3827                        let base = file_line_at(file_contents, start);
3828                        let cursor = if local == 0 { base } else { base + local - 1 };
3829                        Err(error::LocatedParseError::new(e, cursor, i as u64))
3830                    }
3831                    None => Err(error::LocatedParseError::new(
3832                        error::ParseError::IncompleteFrame,
3833                        file_line_at(file_contents, start),
3834                        i as u64,
3835                    )),
3836                }
3837            })
3838            .collect();
3839        let mut frames = Vec::with_capacity(parts.len());
3840        for part in parts {
3841            frames.push(part?);
3842        }
3843        Ok(frames)
3844    };
3845    match num_threads {
3846        None => parse_chunks(),
3847        Some(n) => {
3848            let n = n.max(1);
3849            match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
3850                Ok(pool) => pool.install(parse_chunks),
3851                Err(_) => parse_chunks(),
3852            }
3853        }
3854    }
3855}
3856
3857#[cfg(all(test, feature = "parallel"))]
3858mod parallel_strong_scale_tests {
3859    use super::*;
3860    use std::path::PathBuf;
3861
3862    fn multi_frame_fixture() -> String {
3863        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con");
3864        let one = std::fs::read_to_string(p).expect("fixture");
3865        // Enough frames for >1 worker to exercise the pool.
3866        one.repeat(8)
3867    }
3868
3869    fn sequential_frames(text: &str) -> Vec<types::ConFrame> {
3870        ConFrameIterator::new(text)
3871            .map(|r| r.expect("seq frame"))
3872            .collect()
3873    }
3874
3875    fn frames_payload_key(f: &types::ConFrame) -> (usize, Vec<(String, f64, f64, f64)>) {
3876        let atoms: Vec<_> = f
3877            .atom_data
3878            .iter()
3879            .map(|a| (a.symbol.to_string(), a.x, a.y, a.z))
3880            .collect();
3881        (f.atom_data.len(), atoms)
3882    }
3883
3884    #[test]
3885    fn parallel_workers_match_sequential_payloads() {
3886        let text = multi_frame_fixture();
3887        let seq = sequential_frames(&text);
3888        assert!(seq.len() >= 8);
3889        let seq_keys: Vec<_> = seq.iter().map(frames_payload_key).collect();
3890
3891        for workers in [1usize, 2, 4] {
3892            let par = parse_frames_parallel_with_threads(&text, Some(workers));
3893            assert_eq!(par.len(), seq.len(), "workers={workers}");
3894            let par_keys: Vec<_> = par
3895                .into_iter()
3896                .map(|r| frames_payload_key(&r.expect("par frame")))
3897                .collect();
3898            assert_eq!(par_keys, seq_keys, "workers={workers} frame payloads");
3899        }
3900
3901        // Global pool path agrees too.
3902        let par_default = parse_frames_parallel(&text);
3903        let def_keys: Vec<_> = par_default
3904            .into_iter()
3905            .map(|r| frames_payload_key(&r.expect("par")))
3906            .collect();
3907        assert_eq!(def_keys, seq_keys);
3908    }
3909
3910    fn live_threads() -> usize {
3911        std::fs::read_to_string("/proc/self/status")
3912            .unwrap()
3913            .lines()
3914            .find(|line| line.starts_with("Threads:"))
3915            .unwrap()
3916            .split_whitespace()
3917            .nth(1)
3918            .unwrap()
3919            .parse()
3920            .unwrap()
3921    }
3922
3923    /// A file past the old map and pool gates stays on this thread.
3924    #[test]
3925    fn default_read_of_a_wide_file_stays_on_the_calling_thread() {
3926        const CHILD: &str = "READCON_DEFAULT_THREAD_CHILD";
3927        const NAME: &str = "iterators::parallel_strong_scale_tests::default_read_of_a_wide_file_stays_on_the_calling_thread";
3928        if std::env::var_os(CHILD).is_none() {
3929            let out = std::process::Command::new(std::env::current_exe().unwrap())
3930                .args([NAME, "--exact", "--test-threads=1"])
3931                .env(CHILD, "1")
3932                .output()
3933                .unwrap();
3934            let stdout = String::from_utf8_lossy(&out.stdout);
3935            assert!(
3936                out.status.success() && stdout.contains("test result: ok. 1 passed;"),
3937                "{stdout}{}",
3938                String::from_utf8_lossy(&out.stderr)
3939            );
3940            return;
3941        }
3942        let dir = tempfile::tempdir().unwrap();
3943        let path = dir.path().join("wide.con");
3944        let one = std::fs::read_to_string(
3945            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con"),
3946        )
3947        .unwrap();
3948        std::fs::write(&path, one.repeat(200)).unwrap();
3949        assert!(std::fs::metadata(&path).unwrap().len() > 64 * 1024);
3950        let before = live_threads();
3951        let frames = super::read_all_frames(&path).unwrap();
3952        let after = live_threads();
3953        assert_eq!(before, after, "default read started worker threads");
3954        assert!(frames.len() > 1);
3955        let text = std::fs::read_to_string(&path).unwrap();
3956        let sequential = super::frames_from_text(&text, None).unwrap();
3957        assert_eq!(sequential.len(), frames.len());
3958        assert_eq!(
3959            sequential[0].atom_data[0].x, frames[0].atom_data[0].x,
3960            "default read changed a coordinate"
3961        );
3962    }
3963}
3964
3965#[cfg(all(test, feature = "parallel"))]
3966mod parallel_header_scan_tests {
3967    use super::*;
3968    use std::path::PathBuf;
3969
3970    fn fixture(name: &str) -> String {
3971        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3972            .join("resources/test")
3973            .join(name);
3974        std::fs::read_to_string(path).expect(name)
3975    }
3976
3977    /// A cut on the byte between `\r` and `\n`. The `\n` is in the next chunk.
3978    fn assert_crlf_gap_cut(text: &str) {
3979        let bytes = text.as_bytes();
3980        let mut found = 0usize;
3981        let mut i = 0usize;
3982        while i + 1 < bytes.len() && found < 6 {
3983            if bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
3984                let cut = i + 1;
3985                assert_eq!(bytes[cut - 1], b'\r');
3986                assert_eq!(bytes[cut], b'\n');
3987                assert_cuts_match(text, &[0, cut, bytes.len()]);
3988                found += 1;
3989                i += 2;
3990                continue;
3991            }
3992            i += 1;
3993        }
3994        assert!(found > 0, "fixture has no CRLF pair");
3995    }
3996
3997    fn assert_cuts_match(text: &str, cuts: &[usize]) {
3998        let serial_nl: Vec<usize> = memchr::memchr_iter(b'\n', text.as_bytes()).collect();
3999        let stitched = newline_positions_at_cuts(text.as_bytes(), cuts);
4000        assert_eq!(stitched, serial_nl, "newline stitch at {cuts:?}");
4001        let serial = frame_start_offsets_serial(text);
4002        let parallel = frame_start_offsets_at_cuts(text, cuts);
4003        assert_eq!(parallel, serial, "frame offsets at {cuts:?}");
4004    }
4005
4006    fn header_straddle_cuts(text: &str) -> Vec<Vec<usize>> {
4007        let serial = frame_start_offsets_serial(text);
4008        let bytes = text.as_bytes();
4009        let mut out = Vec::new();
4010        // Equal splits, plus a cut forced into a header line of every frame.
4011        for n in [2usize, 3, 4, 8] {
4012            out.push(equal_cuts(bytes.len(), n));
4013        }
4014        for (index, &start) in serial.iter().enumerate() {
4015            let next = serial.get(index + 1).copied().unwrap_or(bytes.len());
4016            // The header ends at the coordinate label. The window is that
4017            // header, not a fixed count of bytes past the frame start.
4018            let window = super::header_window_end(bytes, start, next);
4019            let mut line = start;
4020            while line < window {
4021                let Some(rel) = memchr::memchr(b'\n', &bytes[line..window]) else {
4022                    break;
4023                };
4024                let end = line + rel;
4025                if end - line > 1 {
4026                    let mid = line + (end - line) / 2;
4027                    // mid is inside the line, so the header byte sits across two chunks.
4028                    assert!(mid > line && mid < end);
4029                    out.push(vec![0, mid, bytes.len()]);
4030                }
4031                line = end + 1;
4032            }
4033        }
4034        out
4035    }
4036
4037    fn assert_slab(text: &str) {
4038        assert!(!text.is_empty());
4039        for cuts in header_straddle_cuts(text) {
4040            assert_cuts_match(text, &cuts);
4041        }
4042        let bare = text.trim_end_matches(['\n', '\r']);
4043        if !bare.is_empty() {
4044            assert_cuts_match(bare, &equal_cuts(bare.len(), 4));
4045            let mid = bare.len() / 2;
4046            let line_start = bare[..mid].rfind('\n').map(|i| i + 1).unwrap_or(0);
4047            let line_end = bare[mid..]
4048                .find('\n')
4049                .map(|i| mid + i)
4050                .unwrap_or(bare.len());
4051            if line_end - line_start > 1 {
4052                let cut = line_start + (line_end - line_start) / 2;
4053                assert_cuts_match(bare, &[0, cut, bare.len()]);
4054            }
4055        }
4056        let tailed = format!("{text}\n   \n\t\n");
4057        assert_cuts_match(
4058            &tailed,
4059            &[0, 3, tailed.len() / 2, tailed.len() - 1, tailed.len()],
4060        );
4061        let crlf = text.replace('\n', "\r\n");
4062        assert_cuts_match(&crlf, &equal_cuts(crlf.len(), 5));
4063        assert_crlf_gap_cut(&crlf);
4064        let serial = frame_start_offsets_serial(text);
4065        if serial.len() >= 2 {
4066            let second = serial[1];
4067            let next = serial.get(2).copied().unwrap_or(text.len());
4068            let broken_at = second + (next - second) / 2;
4069            let broken = &text[..broken_at.max(1)];
4070            assert_cuts_match(broken, &[0, 1.max(broken.len() / 3), broken.len()]);
4071        }
4072    }
4073
4074    #[test]
4075    fn chunked_header_scan_matches_serial_offsets() {
4076        assert_eq!(frame_start_offsets_serial(""), frame_start_offsets(""));
4077        assert_cuts_match("\n", &[0, 1]);
4078        assert_cuts_match("   \n\n", &[0, 2, 5]);
4079        for name in [
4080            "tiny_cuh2.con",
4081            "tiny_cuh2.convel",
4082            "tiny_cuh2_forces.con",
4083            "tiny_cuh2_vel_forces.con",
4084            "cuh2.con",
4085        ] {
4086            let one = fixture(name);
4087            assert_slab(&one);
4088            assert_slab(&one.repeat(3));
4089        }
4090        // The bytes parser (the read_con path) uses the same stitch.
4091        // The single frame is the short file. The repeat is a wider file.
4092        // Both use the requested worker count. There is no length cutoff.
4093        let one = fixture("cuh2.con");
4094        for wide in [one.clone(), one.repeat(8)] {
4095            let serial = frame_start_offsets_serial(&wide);
4096            for workers in [2usize, 3, 4, 8] {
4097                let parallel = frame_start_offsets_with_threads(&wide, Some(workers));
4098                assert_eq!(parallel, serial, "workers={workers} bytes={}", wide.len());
4099                let parsed =
4100                    frames_from_bytes(wide.as_bytes(), Some(workers)).expect("bytes parse");
4101                assert_eq!(parsed.len(), serial.len());
4102                let seq = frames_from_text(&wide, Some(1)).expect("serial parse");
4103                assert_eq!(parsed.len(), seq.len());
4104                assert_eq!(parsed[0].atom_data[0].x, seq[0].atom_data[0].x);
4105                assert_eq!(
4106                    parsed.last().unwrap().atom_data.last().unwrap().z,
4107                    seq.last().unwrap().atom_data.last().unwrap().z
4108                );
4109            }
4110        }
4111    }
4112}
4113
4114/// End of the header window used by straddle cuts.
4115///
4116/// The header runs up to the line that starts the coordinate block.
4117/// When that label is absent the window is the next frame start.
4118#[cfg(all(test, feature = "parallel"))]
4119fn header_window_end(bytes: &[u8], start: usize, limit: usize) -> usize {
4120    let limit = limit.min(bytes.len());
4121    let marker = b"Coordinates";
4122    let mut line = start;
4123    while line < limit {
4124        let rest = &bytes[line..limit];
4125        let rel = memchr::memchr(b'\n', rest).unwrap_or(rest.len());
4126        let content_end = line + rel;
4127        let content = &bytes[line..content_end];
4128        let trimmed = match content.iter().position(|byte| !byte.is_ascii_whitespace()) {
4129            Some(index) => &content[index..],
4130            None => b"",
4131        };
4132        if trimmed.starts_with(marker) {
4133            return line;
4134        }
4135        if content_end >= limit {
4136            break;
4137        }
4138        line = content_end + 1;
4139    }
4140    limit
4141}
4142
4143#[cfg(all(test, feature = "parallel"))]
4144mod parallel_frame_parse_tests {
4145    use super::*;
4146    use std::path::PathBuf;
4147
4148    const ZERO_ATOM: &str = "\
4149zero-atom frame
4150{\"con_spec_version\":3,\"con_spec_minor\":1,\"units\":{\"length\":\"angstrom\",\"energy\":\"eV\"}}
415110.0 10.0 10.0
415290.0 90.0 90.0
4153
4154
41550
4156
4157
4158";
4159
4160    fn fixture(name: &str) -> String {
4161        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4162            .join("resources/test")
4163            .join(name);
4164        std::fs::read_to_string(path).expect(name)
4165    }
4166
4167    fn serial_results(text: &str) -> Vec<Result<types::ConFrame, error::ParseError>> {
4168        ConFrameIterator::new(text).collect()
4169    }
4170
4171    fn written(frame: &types::ConFrame) -> Vec<u8> {
4172        let mut out = Vec::new();
4173        {
4174            let mut writer = crate::writer::ConFrameWriter::new(&mut out);
4175            writer.write_frame(frame).expect("write");
4176            writer.flush().expect("flush");
4177        }
4178        out
4179    }
4180
4181    fn assert_same_results(
4182        label: &str,
4183        serial: Vec<Result<types::ConFrame, error::ParseError>>,
4184        parallel: Vec<Result<types::ConFrame, error::ParseError>>,
4185    ) {
4186        assert_eq!(serial.len(), parallel.len(), "{label} count");
4187        for (i, (left, right)) in serial.into_iter().zip(parallel).enumerate() {
4188            match (left, right) {
4189                (Ok(a), Ok(b)) => {
4190                    assert_eq!(written(&a), written(&b), "{label} bytes frame {i}");
4191                    assert_eq!(a, b, "{label} fields frame {i}");
4192                }
4193                (Err(a), Err(b)) => {
4194                    assert_eq!(
4195                        format!("{a:?}"),
4196                        format!("{b:?}"),
4197                        "{label} error frame {i}"
4198                    );
4199                }
4200                (Ok(_), Err(err)) => panic!("{label} frame {i} serial ok parallel {err:?}"),
4201                (Err(err), Ok(_)) => panic!("{label} frame {i} serial {err:?} parallel ok"),
4202            }
4203        }
4204    }
4205
4206    fn assert_workers(label: &str, text: &str) {
4207        let serial_ok = serial_results(text).iter().all(|item| item.is_ok());
4208        for workers in [1usize, 2, 3, 4, 8] {
4209            let parallel = parse_frames_parallel_with_threads(text, Some(workers));
4210            assert_same_results(
4211                &format!("{label} workers {workers}"),
4212                serial_results(text),
4213                parallel,
4214            );
4215            let bounds = collect_frame_starts(ConFrameIterator::new(text), true);
4216            let at = parse_frames_at_offsets(text, &bounds, Some(workers));
4217            assert_same_results(
4218                &format!("{label} offsets workers {workers}"),
4219                serial_results(text),
4220                at,
4221            );
4222        }
4223        if serial_ok {
4224            for workers in [2usize, 3, 8] {
4225                let bytes = frames_from_bytes(text.as_bytes(), Some(workers)).expect(label);
4226                let seq = frames_from_text(text, Some(1)).expect(label);
4227                assert_eq!(bytes.len(), seq.len(), "{label} byte count");
4228                for (i, (a, b)) in bytes.iter().zip(seq.iter()).enumerate() {
4229                    assert_eq!(written(a), written(b), "{label} byte path frame {i}");
4230                    assert_eq!(a, b, "{label} byte path fields frame {i}");
4231                }
4232            }
4233        }
4234    }
4235
4236    fn crlf_gap_offsets(text: &str) -> Vec<usize> {
4237        let bytes = text.as_bytes();
4238        let mut cut = None;
4239        let mut i = 0usize;
4240        while i + 1 < bytes.len() {
4241            if bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
4242                cut = Some(i + 1);
4243                break;
4244            }
4245            i += 1;
4246        }
4247        let cut = cut.expect("crlf");
4248        frame_start_offsets_at_cuts(text, &[0, cut, bytes.len()])
4249    }
4250
4251    #[test]
4252    fn parallel_slots_match_serial_frames() {
4253        assert_workers("empty", "");
4254        assert_workers("blank", "\n");
4255        assert_workers("space", "   \n\n");
4256        assert_workers("zero", ZERO_ATOM);
4257        assert_workers("zero-repeat", &ZERO_ATOM.repeat(5));
4258        for name in [
4259            "tiny_cuh2.con",
4260            "tiny_cuh2.convel",
4261            "tiny_cuh2_forces.con",
4262            "tiny_cuh2_vel_forces.con",
4263            "tiny_cuh2_charges_spins_magmoms.con",
4264            "tiny_cuh2_displacements.con",
4265            "tiny_cuh2_spreads.con",
4266            "cuh2.con",
4267        ] {
4268            let one = fixture(name);
4269            assert_workers(name, &one);
4270            assert_workers(&format!("{name} x5"), &one.repeat(5));
4271            let bare = one.trim_end_matches(['\n', '\r']);
4272            assert_workers(&format!("{name} bare"), bare);
4273            let tailed = format!("{one}\n   \n\t\n");
4274            assert_workers(&format!("{name} tail"), &tailed);
4275            let crlf = one.replace('\n', "\r\n");
4276            assert_workers(&format!("{name} crlf"), &crlf);
4277            let gap = crlf_gap_offsets(&crlf);
4278            assert_eq!(
4279                gap,
4280                frame_start_offsets_serial(&crlf),
4281                "{name} crlf gap offsets"
4282            );
4283            let parsed = parse_frames_at_offsets(&crlf, &gap, Some(4));
4284            assert_same_results(
4285                &format!("{name} crlf gap parse"),
4286                serial_results(&crlf),
4287                parsed,
4288            );
4289            let serial_offs = frame_start_offsets_serial(&one);
4290            let broken: &str = if serial_offs.len() >= 2 {
4291                let second = serial_offs[1];
4292                let next = serial_offs.get(2).copied().unwrap_or(one.len());
4293                &one[..second + (next - second) / 2]
4294            } else if one.len() > 8 {
4295                &one[..one.len() / 2]
4296            } else {
4297                ""
4298            };
4299            if !broken.is_empty() {
4300                let serial_err = frames_from_text(broken, Some(1))
4301                    .err()
4302                    .map(|e| format!("{e:?}"));
4303                assert!(serial_err.is_some(), "{name} chopped still parsed");
4304                for workers in [1usize, 2, 4, 8] {
4305                    let parallel_err = frames_from_text(broken, Some(workers))
4306                        .err()
4307                        .map(|e| format!("{e:?}"));
4308                    assert_eq!(parallel_err, serial_err, "{name} chopped workers {workers}");
4309                    let byte_err = frames_from_bytes(broken.as_bytes(), Some(workers))
4310                        .err()
4311                        .map(|e| format!("{e:?}"));
4312                    assert_eq!(
4313                        byte_err, serial_err,
4314                        "{name} chopped bytes workers {workers}"
4315                    );
4316                }
4317            }
4318        }
4319    }
4320}
4321
4322#[cfg(all(test, feature = "parallel"))]
4323mod phased_read_tests {
4324    use super::*;
4325    use std::path::PathBuf;
4326
4327    fn fixture(name: &str) -> String {
4328        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4329            .join("resources/test")
4330            .join(name);
4331        std::fs::read_to_string(path).expect(name)
4332    }
4333
4334    #[test]
4335    fn phased_bytes_match_serial_frames() {
4336        let text = fixture("cuh2.con").repeat(4);
4337        let serial = frames_from_text(&text, Some(1)).expect("serial");
4338        for workers in [1usize, 2, 4, 8] {
4339            let phased = frames_from_bytes_phased(text.as_bytes(), Some(workers)).expect("phased");
4340            let parallel = phased.frames;
4341            assert_eq!(parallel.len(), serial.len(), "workers={workers}");
4342            assert!(
4343                phased.scan_s >= 0.0 && phased.slot_alloc_s >= 0.0 && phased.worker_parse_s >= 0.0
4344            );
4345            assert_eq!(parallel[0], serial[0], "workers={workers} first");
4346            assert_eq!(
4347                parallel.last().unwrap(),
4348                serial.last().unwrap(),
4349                "workers={workers} last"
4350            );
4351            let slab = pack_position_slab(&serial, workers).expect("slab");
4352            let stride = serial[0].positions.nrows() * 3;
4353            let src = serial[0].positions.f64_slice().expect("f64");
4354            assert_eq!(&slab[..stride], src);
4355            let (chunked, _) = measure_newline_scan(&text, workers.max(2), true);
4356            let (plain, _) = measure_newline_scan(&text, 1, false);
4357            if workers >= 2 {
4358                assert_eq!(chunked, plain);
4359            }
4360        }
4361    }
4362
4363    #[test]
4364    fn phased_file_matches_serial_and_maps_a_wide_file() {
4365        let text = fixture("cuh2.con").repeat(5);
4366        let dir = std::env::temp_dir();
4367        let path = dir.join("readcon-phased-wide.con");
4368        std::fs::write(&path, &text).expect("write");
4369        let phased = read_all_frames_phased(&path, Some(4)).expect("phased");
4370        let serial = frames_from_text(&text, Some(1)).expect("serial");
4371        assert_eq!(phased.frames, serial);
4372        assert!(phased.phases.mapped, "a file past 64 KiB is mapped");
4373        assert!(phased.phases.worker_parse_s >= 0.0);
4374        assert_eq!(phased.n_bytes, text.len());
4375        let _ = std::fs::remove_file(&path);
4376    }
4377
4378    #[test]
4379    fn equal_span_boundaries_match_the_serial_walk() {
4380        let text = fixture("cuh2.con").repeat(8);
4381        let bytes = text.as_bytes();
4382        let got = equal_span_boundaries(bytes, 4).expect("equal span");
4383        let expect = collect_frame_starts(ConFrameIterator::from_bytes(bytes), true);
4384        assert_eq!(got, expect);
4385        assert_eq!(got.len(), 8);
4386        let phased = frames_from_bytes_phased(bytes, Some(4)).expect("phased");
4387        assert_eq!(phased.scan_kind, SCAN_EQUAL_SPAN);
4388        assert_eq!(
4389            phased.frames,
4390            frames_from_text(&text, Some(1)).expect("serial")
4391        );
4392        let tailed = format!("{text}\n\n  \n");
4393        let tail_phased = frames_from_bytes_phased(tailed.as_bytes(), Some(4)).expect("tail");
4394        assert_eq!(tail_phased.scan_kind, SCAN_EQUAL_SPAN);
4395        assert_eq!(
4396            tail_phased.frames,
4397            frames_from_text(&tailed, Some(1)).expect("tail serial")
4398        );
4399    }
4400
4401    #[test]
4402    fn a_whitespace_tail_uses_the_equal_span_walk() {
4403        let mut text = fixture("cuh2.con").repeat(3);
4404        text.push('\n');
4405        assert!(equal_span_boundaries(text.as_bytes(), 4).is_some());
4406        let serial = frames_from_text(&text, Some(1)).expect("serial");
4407        let parallel = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("phased");
4408        assert_eq!(parallel.scan_kind, SCAN_EQUAL_SPAN);
4409        assert_eq!(parallel.frames, serial);
4410    }
4411
4412    #[test]
4413    fn touch_checksum_matches_across_worker_counts() {
4414        let text = fixture("cuh2.con").repeat(4);
4415        let serial = frames_from_bytes_phased(text.as_bytes(), Some(1)).expect("serial");
4416        let parallel = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("parallel");
4417        let slab_s = pack_position_slab(&serial.frames, 1).expect("slab");
4418        let slab_p = pack_position_slab(&parallel.frames, 4).expect("slab");
4419        let left = touch_frame_checksum(&serial.frames, &slab_s, 1).expect("touch");
4420        let right = touch_frame_checksum(&parallel.frames, &slab_p, 4).expect("touch");
4421        assert_eq!(left, right);
4422        assert_ne!(left, 0);
4423    }
4424}
4425
4426#[cfg(test)]
4427mod later_frame_location_tests {
4428    use super::*;
4429    use std::fs;
4430
4431    fn one_atom(coord: &str) -> String {
4432        format!(
4433            "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"
4434        )
4435    }
4436
4437    fn bad_count_frame() -> String {
4438        "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()
4439    }
4440
4441    fn two_frames() -> String {
4442        format!("{}{}", one_atom("0 0 0 0 0"), bad_count_frame())
4443    }
4444
4445    fn assert_later_count(
4446        err: &error::ParseError,
4447        line: u32,
4448        frame: u64,
4449        iter: &ConFrameIterator<'_>,
4450    ) {
4451        assert_eq!(err.class_name(), "InvalidCount", "{err}");
4452        assert_eq!(iter.lines.line_no(), line, "{err}");
4453        assert_eq!(iter.frame_index(), frame, "{err}");
4454    }
4455
4456    fn assert_first_pass(err: Box<dyn std::error::Error>) {
4457        let located = err
4458            .downcast_ref::<error::LocatedParseError>()
4459            .unwrap_or_else(|| panic!("first pass lost the location: {err}"));
4460        assert_eq!(located.error.class_name(), "InvalidCount", "{located:?}");
4461        assert_eq!(located.line, 20, "{located:?}");
4462        assert_eq!(located.frame_index, 1, "{located:?}");
4463    }
4464
4465    fn take_err<T>(result: Result<T, Box<dyn std::error::Error>>) -> Box<dyn std::error::Error> {
4466        match result {
4467            Err(err) => err,
4468            Ok(_) => panic!("expected a parse error"),
4469        }
4470    }
4471
4472    /// The batch readers report the later frame from the parse that failed.
4473    /// A second open of the path is not required.
4474    #[test]
4475    fn batch_readers_keep_the_later_frame_on_the_first_pass() {
4476        let path = std::env::temp_dir().join(format!(
4477            "readcon-later-frame-first-{}.con",
4478            std::process::id()
4479        ));
4480        fs::write(&path, two_frames()).unwrap();
4481        for threads in [None, Some(1), Some(2)] {
4482            assert_first_pass(take_err(read_all_frames_with_threads(&path, threads)));
4483            assert_first_pass(take_err(read_all_frames_phased(&path, threads)));
4484            assert_first_pass(take_err(read_positions_with_threads(&path, threads)));
4485        }
4486        let _ = fs::remove_file(&path);
4487    }
4488
4489    /// An error in the second frame keeps that frame's index and file line.
4490    #[test]
4491    fn next_forward_and_skip_locate_the_later_frame() {
4492        let text = two_frames();
4493
4494        let mut iter = ConFrameIterator::new(&text);
4495        assert!(iter.next().unwrap().is_ok());
4496        let err = iter.next().unwrap().unwrap_err();
4497        assert_later_count(&err, 20, 1, &iter);
4498
4499        let mut iter = ConFrameIterator::new(&text);
4500        assert!(iter.forward().unwrap().is_ok());
4501        let err = iter.forward().unwrap().unwrap_err();
4502        assert_later_count(&err, 20, 1, &iter);
4503
4504        let mut iter = ConFrameIterator::new(&text);
4505        let err = iter.skip_frames(3).unwrap_err();
4506        assert_later_count(&err, 20, 1, &iter);
4507    }
4508
4509    /// The blank line that opens a velocity block is a file line. A later
4510    /// frame's error must include it. 12 coordinate lines, the separator,
4511    /// and 3 velocity lines put the next frame's line 8 at file line 24.
4512    #[test]
4513    fn skip_after_a_velocity_frame_keeps_the_separator_line() {
4514        let text = format!(
4515            "{}\nH\nVelocities of Component 1\n0 0 0 0 0\n{}",
4516            one_atom("0 0 0 0 0"),
4517            bad_count_frame()
4518        );
4519        let mut iter = ConFrameIterator::new(&text);
4520        assert!(iter.forward().unwrap().is_ok());
4521        let err = iter.forward().unwrap().unwrap_err();
4522        assert_later_count(&err, 24, 1, &iter);
4523    }
4524
4525    #[test]
4526    fn count_frames_locates_the_later_frame() {
4527        let path = std::env::temp_dir().join(format!(
4528            "readcon-later-frame-count-{}.con",
4529            std::process::id()
4530        ));
4531        fs::write(&path, two_frames()).unwrap();
4532        let err = count_frames(&path).unwrap_err();
4533        let _ = fs::remove_file(&path);
4534        match err {
4535            LoadError::Parse(located) => {
4536                assert_eq!(located.error.class_name(), "InvalidCount");
4537                assert_eq!(located.line, 20, "{located}");
4538                assert_eq!(located.frame_index, 1, "{located}");
4539            }
4540            other => panic!("expected a located parse error, got {other}"),
4541        }
4542    }
4543
4544    /// Metadata on line 2 of the second frame is file line 14, not line 2.
4545    /// This is the serial contract the collective reader matches.
4546    #[test]
4547    fn metadata_in_a_later_frame_reports_that_frames_line() {
4548        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";
4549        let text = format!("{}{}", one_atom("0 0 0 0 0"), bad);
4550        for threads in [Some(1usize), Some(2)] {
4551            let err = frames_from_text_located(&text, threads).unwrap_err();
4552            assert_eq!(
4553                err.error.class_name(),
4554                "InvalidMetadataJson",
4555                "{threads:?}: {err}"
4556            );
4557            assert_eq!(err.frame_index, 1, "{threads:?}");
4558            assert_eq!(err.line, 14, "{threads:?}: {err}");
4559        }
4560    }
4561
4562    /// Several spans after the first frame fail. Serial collection keeps
4563    /// the earliest slot. The parallel position reader must report that
4564    /// same line and frame index.
4565    ///
4566    /// The first frame is large. A later worker can fail before the
4567    /// calling thread reaches the earliest bad span, which is the case
4568    /// that used to return the later slot.
4569    #[test]
4570    fn parallel_positions_report_the_earliest_failing_slot() {
4571        const SLOW_ATOMS: usize = 20_000;
4572        let slow = counted_frame(SLOW_ATOMS);
4573        let bad = counted_frame(1);
4574        let text = format!("{slow}{bad}{bad}{bad}");
4575        let earliest_line = 1 + slow.bytes().filter(|byte| *byte == b'\n').count() as u32;
4576        let path =
4577            std::env::temp_dir().join(format!("readcon-earliest-slot-{}.con", std::process::id()));
4578        fs::write(&path, &text).unwrap();
4579        assert_earliest_slot(
4580            take_err(read_positions_with_threads(&path, Some(1))),
4581            earliest_line,
4582        );
4583        assert_earliest_slot(
4584            take_err(read_positions_with_threads(&path, Some(2))),
4585            earliest_line,
4586        );
4587        let _ = fs::remove_file(&path);
4588    }
4589
4590    fn counted_frame(natoms: usize) -> String {
4591        let mut body = String::with_capacity(natoms * 10 + 96);
4592        body.push_str("seed\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\n0 0\n0 0 0\n1\n");
4593        body.push_str(&natoms.to_string());
4594        body.push_str("\n1.0\nH\nCoordinates of Component 1\n");
4595        for _ in 0..natoms {
4596            body.push_str("0 0 0 0 0\n");
4597        }
4598        body
4599    }
4600
4601    fn assert_earliest_slot(err: Box<dyn std::error::Error>, line: u32) {
4602        let located = err
4603            .downcast_ref::<error::LocatedParseError>()
4604            .unwrap_or_else(|| panic!("position read lost the location: {err}"));
4605        assert_eq!(located.error.class_name(), "InvalidCount", "{located:?}");
4606        assert_eq!(located.frame_index, 1, "{located:?}");
4607        assert_eq!(located.line, line, "{located:?}");
4608    }
4609}
4610
4611#[cfg(test)]
4612mod frame_equality_tests {
4613    use super::ConFrameIterator;
4614
4615    fn sample_con(frames: usize, long: bool) -> String {
4616        let mut out = String::new();
4617        for f in 0..frames {
4618            let shift = (f as f64) * 1.0e-4;
4619            out.push_str("Generated by benchmark\n");
4620            out.push_str("{\"con_spec_version\":2}\n");
4621            out.push_str("15.3456 21.702 100.0\n");
4622            out.push_str("90.0 90.0 90.0\n");
4623            out.push_str("0 0\n");
4624            out.push_str(&format!("{f} 0 1\n"));
4625            out.push_str("2\n6 2\n63.546 1.00793\n");
4626            out.push_str("Cu\nCoordinates of Component 1\n");
4627            for i in 0..6 {
4628                let x = (i as f64) * 2.557 + 0.6394 + shift;
4629                let y = 0.9045 + shift * 0.5;
4630                let z = (f as f64) * 1.0e-6 + if i == 3 { -1.25 } else { 0.0 };
4631                if long {
4632                    out.push_str(&format!("   {x:.17}    {y:.17}    {z:.17} 0    {i}\n"));
4633                } else {
4634                    out.push_str(&format!("   {x:.6}    {y:.6}    {z:.6} 0    {i}\n"));
4635                }
4636            }
4637            out.push_str("H\nCoordinates of Component 2\n");
4638            out.push_str("   8.6823    9.947   11.733 0  6\n");
4639            out.push_str(&format!("  -7.9421    9.947   {shift:.6} 0  7\n"));
4640        }
4641        out
4642    }
4643
4644    fn sample_convel() -> String {
4645        let mut out = String::new();
4646        for f in 0..3 {
4647            let shift = (f as f64) * 1.0e-4;
4648            out.push_str("Generated by benchmark\n");
4649            out.push_str("{\"con_spec_version\":2}\n");
4650            out.push_str("15.3456 21.702 100.0\n");
4651            out.push_str("90.0 90.0 90.0\n");
4652            out.push_str("0 0\n");
4653            out.push_str(&format!("{f} 0 1\n"));
4654            out.push_str("2\n4 1\n63.546 1.00793\n");
4655            out.push_str("Cu\nCoordinates of Component 1\n");
4656            for i in 0..4 {
4657                let x = (i as f64) * 2.557 + shift;
4658                out.push_str(&format!("   {x:.17}    0.9045    1.0 0    {i}\n"));
4659            }
4660            out.push_str("H\nCoordinates of Component 2\n");
4661            out.push_str("   1.5    2.5    3.5 0  4\n");
4662            out.push_str("\nCu\nVelocities of Component 1\n");
4663            for i in 0..4 {
4664                let vx = (i as f64) * 1.0e-4 - shift;
4665                out.push_str(&format!("   {vx:.17}    -0.5    0.25 0    {i}\n"));
4666            }
4667            out.push_str("H\nVelocities of Component 2\n");
4668            out.push_str("   0.1    -0.2    0.3 0  4\n");
4669        }
4670        out
4671    }
4672
4673    fn reference_f64(token: &str) -> f64 {
4674        let (val, n) = fast_float2::parse_partial::<f64, _>(token).expect(token);
4675        assert_eq!(n, token.len(), "{token}");
4676        val
4677    }
4678
4679    fn assert_rows_match(text: &str, positions: bool) {
4680        let frames: Vec<_> = ConFrameIterator::new(text)
4681            .map(|frame| frame.expect("parse"))
4682            .collect();
4683        assert!(frames.len() >= 2);
4684        let mut frame_i = 0usize;
4685        let mut row = 0usize;
4686        let mut in_section = false;
4687        for line in text.lines() {
4688            let token = line.trim();
4689            if token.is_empty() {
4690                in_section = true;
4691                row = 0;
4692                continue;
4693            }
4694            if token.starts_with("Generated") {
4695                if row > 0 || in_section {
4696                    frame_i += 1;
4697                }
4698                row = 0;
4699                in_section = false;
4700                continue;
4701            }
4702            let cols: Vec<&str> = token.split_whitespace().collect();
4703            if cols.len() < 4 || reference_ok(cols[0]).is_none() {
4704                continue;
4705            }
4706            if cols[3].parse::<u64>().is_err() {
4707                continue;
4708            }
4709            let got = if in_section {
4710                frames[frame_i].atom_data[row].velocity().expect("velocity")
4711            } else if positions {
4712                let p = frames[frame_i].positions.as_f64_row(row);
4713                [p[0], p[1], p[2]]
4714            } else {
4715                continue;
4716            };
4717            for (k, token) in cols.iter().take(3).enumerate() {
4718                assert_eq!(
4719                    got[k].to_bits(),
4720                    reference_f64(token).to_bits(),
4721                    "frame {frame_i} row {row} col {k} token {token} section {in_section}"
4722                );
4723            }
4724            let aos = if in_section {
4725                frames[frame_i].atom_data[row].velocity().unwrap()
4726            } else {
4727                let atom = &frames[frame_i].atom_data[row];
4728                [atom.x, atom.y, atom.z]
4729            };
4730            for k in 0..3 {
4731                assert_eq!(aos[k].to_bits(), got[k].to_bits());
4732            }
4733            row += 1;
4734        }
4735    }
4736
4737    fn reference_ok(token: &str) -> Option<f64> {
4738        let (val, n) = fast_float2::parse_partial::<f64, _>(token).ok()?;
4739        (n == token.len()).then_some(val)
4740    }
4741
4742    #[test]
4743    fn frames_match_general_decimal_parser() {
4744        assert_rows_match(&sample_con(4, true), true);
4745        assert_rows_match(&sample_con(4, false), true);
4746        let text = sample_convel();
4747        let frames: Vec<_> = ConFrameIterator::new(&text)
4748            .map(|frame| frame.expect("convel"))
4749            .collect();
4750        assert_eq!(frames.len(), 3);
4751        assert!(
4752            frames
4753                .iter()
4754                .all(|frame| frame.atom_data[0].velocity().is_some())
4755        );
4756        assert_eq!(frames[0].positions.nrows(), 5);
4757        // Every stored coordinate and velocity bit matches the general parser.
4758        let mut frame_i = 0usize;
4759        let mut coord_row = 0usize;
4760        let mut vel_row = 0usize;
4761        let mut velocities = false;
4762        for line in text.lines() {
4763            let token = line.trim();
4764            if token.starts_with("Generated") && (coord_row > 0 || velocities) {
4765                frame_i += 1;
4766                coord_row = 0;
4767                vel_row = 0;
4768                velocities = false;
4769            }
4770            if token.starts_with("Velocities") {
4771                velocities = true;
4772                continue;
4773            }
4774            let cols: Vec<&str> = token.split_whitespace().collect();
4775            if cols.len() < 5 {
4776                continue;
4777            }
4778            if reference_ok(cols[0]).is_none() || cols[3].parse::<u64>().is_err() {
4779                continue;
4780            }
4781            let row = if velocities { vel_row } else { coord_row };
4782            let got = if velocities {
4783                frames[frame_i].atom_data[row].velocity().unwrap()
4784            } else {
4785                let p = frames[frame_i].positions.as_f64_row(row);
4786                [p[0], p[1], p[2]]
4787            };
4788            for (k, col) in cols.iter().take(3).enumerate() {
4789                assert_eq!(got[k].to_bits(), reference_f64(col).to_bits(), "{token}");
4790            }
4791            if velocities {
4792                let soa = frames[frame_i].velocities.as_f64_row(row);
4793                for k in 0..3 {
4794                    assert_eq!(soa[k].to_bits(), got[k].to_bits());
4795                }
4796                vel_row += 1;
4797            } else {
4798                coord_row += 1;
4799            }
4800        }
4801        assert_eq!(frame_i, 2);
4802        assert_eq!(vel_row, 5);
4803    }
4804}
4805
4806#[cfg(test)]
4807mod newline_skip_tests {
4808    use super::ConFrameIterator;
4809
4810    /// The per-line search [`ConFrameIterator::advance_lines`] used before
4811    /// the counted skip. Positions and errors are the oracle.
4812    fn naive(bytes: &[u8], n: usize) -> Result<usize, ()> {
4813        let mut pos = 0usize;
4814        for i in 0..n {
4815            let rest = &bytes[pos..];
4816            match memchr::memchr(b'\n', rest) {
4817                Some(at) => pos += at + 1,
4818                None => {
4819                    if rest.is_empty() {
4820                        return Err(());
4821                    }
4822                    pos = bytes.len();
4823                    if i + 1 == n {
4824                        return Ok(pos);
4825                    }
4826                    return Err(());
4827                }
4828            }
4829        }
4830        Ok(pos)
4831    }
4832
4833    #[test]
4834    fn counted_skip_matches_per_line_memchr() {
4835        let mut samples = vec![
4836            String::new(),
4837            "a".into(),
4838            "a\n".into(),
4839            "a\nb".into(),
4840            "\n".into(),
4841            "\n\n".into(),
4842            "abc\ndef\nghi".into(),
4843            "a\r\nb\r\n".into(),
4844            "x".repeat(100) + "\n" + &"y".repeat(40),
4845        ];
4846        let path =
4847            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/cuh2.con");
4848        let one = std::fs::read_to_string(&path).unwrap();
4849        samples.push(one.repeat(2));
4850        let bare = one.trim_end_matches(['\n', '\r']).to_string();
4851        samples.push(bare);
4852        for text in samples {
4853            let bytes = text.as_bytes();
4854            for n in [0usize, 1, 2, 3, 6, 7, 9, 40, 200, 450, 1000, 5000] {
4855                let expect = naive(bytes, n);
4856                let mut it = ConFrameIterator::from_bytes(bytes);
4857                let got = it.advance_lines(n).map(|_| it.byte_pos()).map_err(|_| ());
4858                assert_eq!(got, expect, "n={n} len={}", bytes.len());
4859            }
4860        }
4861    }
4862}
4863
4864#[cfg(test)]
4865mod cached_unit_table_read_paths {
4866    use super::*;
4867
4868    const FRAME: &str = "\
4869v3
4870{\"con_spec_version\":3,\"con_spec_minor\":1,\"units\":{\"length\":\"angstrom\",\"energy\":\"eV\"}}
487110 10 10
487290 90 90
4873
4874
48751
48761
48771.0
4878H
4879Coordinates of Component 1
48800.5 0.25 0.125 0 7
4881";
4882
4883    fn coords(frame: &types::ConFrame) -> [f64; 3] {
4884        frame.positions.as_f64_row(0)
4885    }
4886
4887    /// The serial iterator, the byte reader behind the C API, and the phased
4888    /// reader behind `read_con` all validate units through the one table.
4889    #[test]
4890    fn read_paths_share_the_cached_unit_table() {
4891        let addr = crate::units::cached_unit_table_addr();
4892        let serial = ConFrameIterator::new(FRAME)
4893            .next()
4894            .expect("frame")
4895            .expect("parse");
4896        let from_bytes = frames_from_bytes(FRAME.as_bytes(), Some(1)).expect("bytes");
4897        let phased = frames_from_bytes_phased(FRAME.as_bytes(), Some(1)).expect("phased");
4898        assert_eq!(from_bytes.len(), 1);
4899        assert_eq!(phased.frames.len(), 1);
4900        assert_eq!(coords(&from_bytes[0]), coords(&serial));
4901        assert_eq!(coords(&phased.frames[0]), coords(&serial));
4902        assert_eq!(crate::units::cached_unit_table_addr(), addr);
4903        let bad = FRAME.replace("angstrom", "not-a-unit");
4904        assert!(ConFrameIterator::new(&bad).next().unwrap().is_err());
4905        assert!(frames_from_bytes(bad.as_bytes(), Some(1)).is_err());
4906        assert!(frames_from_bytes_phased(bad.as_bytes(), Some(1)).is_err());
4907        assert_eq!(crate::units::cached_unit_table_addr(), addr);
4908    }
4909}