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    /// Bytes from the start of the next line, a peeked one included, to the
129    /// end of the buffer.
130    pub(crate) fn unread_len(&self) -> usize {
131        let start = match self.peeked {
132            Some(p) => p.as_ptr() as usize - self.bytes.as_ptr() as usize,
133            None => self.pos,
134        };
135        self.bytes.len() - start
136    }
137
138    /// Drop any peek buffer (required before bulk cursor advances).
139    fn clear_peek(&mut self) {
140        if let Some(p) = self.peeked.take() {
141            // Rewind pos to the start of the peeked line.
142            let start = p.as_ptr() as usize - self.bytes.as_ptr() as usize;
143            self.pos = start;
144            // A peek already counted this line. Rewinding must drop that
145            // count so the next read names it once, and must put the
146            // newline index back on the line `pos` now starts.
147            self.line_no = self.line_no.saturating_sub(1);
148            if let Some(nl) = self.newlines.as_deref() {
149                self.index_line = line_no_for_pos(nl, self.bytes.len(), start);
150            }
151        }
152    }
153
154    /// One line from the precomputed newline index.
155    ///
156    /// The cursor must sit on a line start (or at the end). The bytes of
157    /// the line are the same slice `memchr` would return, including a line
158    /// whose body crosses a chunk boundary: only the `\n` is indexed.
159    fn read_one_indexed(&mut self) -> Option<&'a str> {
160        if self.utf8_bad || self.pos >= self.bytes.len() {
161            return None;
162        }
163        let len = self.bytes.len();
164        let line_no = {
165            let nl = self.newlines.as_deref().unwrap();
166            if self.pos == line_start_pos(nl, len, self.index_line) {
167                self.index_line
168            } else {
169                line_no_for_pos(nl, len, self.pos)
170            }
171        };
172        let span = indexed_span(self.newlines.as_deref().unwrap(), self.bytes, line_no)?;
173        let trimmed = &self.bytes[span.start..span.content_end];
174        if self.check_utf8 {
175            match std::str::from_utf8(trimmed) {
176                Ok(s) => {
177                    self.pos = span.next_pos;
178                    self.index_line = line_no + 1;
179                    self.line_no = self.index_line as u32;
180                    Some(s)
181                }
182                Err(_) => {
183                    self.utf8_bad = true;
184                    self.index_line = line_no;
185                    // The rejected line is 1-based `line_no + 1`. The index
186                    // stays on that line so a later read does not skip it.
187                    self.line_no = (line_no as u32).saturating_add(1);
188                    None
189                }
190            }
191        } else {
192            self.pos = span.next_pos;
193            self.index_line = line_no + 1;
194            self.line_no = self.index_line as u32;
195            // SAFETY: source was `&str`; the span is a UTF-8 line cut on ASCII `\n`/`\r`.
196            Some(unsafe { std::str::from_utf8_unchecked(trimmed) })
197        }
198    }
199
200    fn utf8_error(&self) -> Option<error::ParseError> {
201        self.utf8_bad.then(invalid_utf8)
202    }
203
204    /// Keep `err` unless this cursor already rejected a line as UTF-8.
205    fn prefer_utf8(&self, err: error::ParseError) -> error::ParseError {
206        if self.utf8_bad { invalid_utf8() } else { err }
207    }
208}
209
210fn invalid_utf8() -> error::ParseError {
211    error::ParseError::ValidationError("invalid UTF-8".into())
212}
213
214impl<'a> Iterator for MemchrLines<'a> {
215    type Item = &'a str;
216    fn next(&mut self) -> Option<&'a str> {
217        self.next_line()
218    }
219}
220
221impl<'a> LineStream<'a> for MemchrLines<'a> {
222    #[inline]
223    fn next_line(&mut self) -> Option<&'a str> {
224        MemchrLines::next_line(self)
225    }
226    #[inline]
227    fn peek_line(&mut self) -> Option<&'a str> {
228        MemchrLines::peek_line(self)
229    }
230    #[inline]
231    fn next_coord_row(
232        &mut self,
233        spec_version: u32,
234        default_id: u64,
235    ) -> Result<([f64; 3], [bool; 3], u64), crate::error::ParseError> {
236        if let Some(p) = self.peeked.take() {
237            return crate::parser::parse_coord_row_at(p.as_bytes(), 0, spec_version, default_id)
238                .map(|(row, _)| row);
239        }
240        // The failing row is this line even when the numeric scan returns Err
241        // before the cursor moves.
242        self.line_no = self.line_no.saturating_add(1);
243        let (row, pos) =
244            crate::parser::parse_coord_row_at(self.bytes, self.pos, spec_version, default_id)?;
245        self.pos = pos;
246        Ok(row)
247    }
248    fn try_take_coord_block(
249        &mut self,
250        n: usize,
251        spec_version: u32,
252        start_id: u64,
253        symbol: &std::sync::Arc<str>,
254        workers: usize,
255    ) -> Option<crate::parser::CoordRows> {
256        if self.peeked.is_some() || self.utf8_bad {
257            return None;
258        }
259        #[cfg(feature = "parallel")]
260        {
261            let rest = &self.bytes[self.pos..];
262            let block = crate::parser::parallel_coord_rows(
263                rest,
264                n,
265                spec_version,
266                start_id,
267                workers,
268                Some(symbol),
269            )
270            .ok()?;
271            self.pos += block.consumed;
272            self.line_no = self.line_no.saturating_add(n as u32);
273            if self.newlines.is_some() {
274                self.index_line = self.index_line.saturating_add(n);
275            }
276            return Some(block);
277        }
278        #[cfg(not(feature = "parallel"))]
279        {
280            let _ = (n, spec_version, start_id, symbol, workers);
281            None
282        }
283    }
284    #[inline]
285    fn next_vector_row(
286        &mut self,
287        spec_version: u32,
288    ) -> Result<([f64; 3], [bool; 3], u64), crate::error::ParseError> {
289        if let Some(p) = self.peeked.take() {
290            return crate::parser::parse_vector_row_at(p.as_bytes(), 0, spec_version)
291                .map(|(row, _)| row);
292        }
293        let (row, pos) = crate::parser::parse_vector_row_at(self.bytes, self.pos, spec_version)?;
294        self.pos = pos;
295        Ok(row)
296    }
297    fn remainder_is_whitespace(&mut self) -> bool {
298        if self.utf8_bad {
299            return false;
300        }
301        if let Some(line) = self.peek_line()
302            && !line.trim().is_empty()
303        {
304            return false;
305        }
306        // `peek_line` may have rejected a line. That is a decode error,
307        // including when every byte after it is ASCII whitespace.
308        if self.utf8_bad {
309            return false;
310        }
311        self.bytes[self.pos..]
312            .iter()
313            .all(|b| b.is_ascii_whitespace())
314    }
315    fn blank_line_opens_section(&mut self) -> bool {
316        // `peek_line` has already moved `pos` to the line after the peek.
317        let Some(peeked) = self.peeked else {
318            return false;
319        };
320        if !peeked.trim().is_empty() {
321            return false;
322        }
323        match probe_section(&self.bytes[self.pos..]) {
324            SectionProbe::Opens => true,
325            SectionProbe::Closed => false,
326            // Leave the rejected bytes on the cursor. `prefer_utf8` then
327            // reports the decode error instead of a missing section.
328            SectionProbe::InvalidUtf8 => {
329                self.utf8_bad = true;
330                false
331            }
332        }
333    }
334
335    fn blank_opens_velocity_block(&mut self) -> bool {
336        match self.peek_line() {
337            Some(line) if line.trim().is_empty() => self.blank_line_opens_section(),
338            _ => true,
339        }
340    }
341}
342
343fn split_raw_line(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
344    if bytes.is_empty() {
345        return None;
346    }
347    match memchr::memchr(b'\n', bytes) {
348        Some(i) => Some((&bytes[..i], &bytes[i + 1..])),
349        None => Some((bytes, &[])),
350    }
351}
352
353fn strip_cr(line: &[u8]) -> &[u8] {
354    if line.last() == Some(&b'\r') {
355        &line[..line.len() - 1]
356    } else {
357        line
358    }
359}
360
361enum SectionProbe {
362    Opens,
363    Closed,
364    InvalidUtf8,
365}
366
367/// A blank line opens a section when the next line is a symbol and the
368/// line after that is a component label (CON-file.5, CON-header.4).
369fn probe_section(after_blank: &[u8]) -> SectionProbe {
370    let Some((raw1, after1)) = split_raw_line(after_blank) else {
371        return SectionProbe::Closed;
372    };
373    let line1 = strip_cr(raw1);
374    if line1.iter().all(|b| matches!(b, b' ' | b'\t')) {
375        return SectionProbe::Closed;
376    }
377    let Ok(text1) = std::str::from_utf8(line1) else {
378        return SectionProbe::InvalidUtf8;
379    };
380    if text1.trim_start().starts_with('{') {
381        return SectionProbe::Closed;
382    }
383    let Some((raw2, _)) = split_raw_line(after1) else {
384        return SectionProbe::Closed;
385    };
386    let Ok(text2) = std::str::from_utf8(strip_cr(raw2)) else {
387        return SectionProbe::InvalidUtf8;
388    };
389    if crate::parser::line_has_component_label(text2) {
390        SectionProbe::Opens
391    } else {
392        SectionProbe::Closed
393    }
394}
395
396fn blank_opens_section(after_blank: &[u8]) -> bool {
397    matches!(probe_section(after_blank), SectionProbe::Opens)
398}
399
400/// True when [`ConFrameIterator`] yields nothing from byte `pos` of `bytes`
401/// on, because only whitespace is left (CON-frame.1). Frame scans stop
402/// here, so they find the frames that the iterator yields.
403pub(crate) fn no_frame_at(bytes: &[u8], pos: usize) -> bool {
404    match bytes.get(pos) {
405        None => true,
406        // A line that opens with a printable ASCII byte is not blank.
407        Some(b) if b.is_ascii_graphic() => false,
408        Some(_) => MemchrLines::from_bytes(&bytes[pos..]).remainder_is_whitespace(),
409    }
410}
411
412/// The blank test of the full parse: `line` is UTF-8 and [`str::trim`]
413/// leaves nothing of it. A line that opens with a printable ASCII byte is
414/// not blank, so that byte settles it without a decode.
415fn is_blank_line(line: &[u8]) -> bool {
416    match line.first() {
417        Some(b) if b.is_ascii_graphic() => false,
418        _ => std::str::from_utf8(line).is_ok_and(|s| s.trim().is_empty()),
419    }
420}
421
422/// An iterator that lazily parses simulation frames from a `.con` or `.convel`
423/// file's contents.
424///
425/// This struct wraps a memchr line cursor over the file buffer and, upon each
426/// iteration, attempts to parse a complete `ConFrame`. A blank line after the
427/// coordinates is a velocity section only when it begins a complete velocity
428/// block. Otherwise that line is line 1 of the next frame, and it may be empty.
429///
430/// The iterator yields items of type `Result<ConFrame, ParseError>`, allowing for
431/// robust error handling for each frame.
432pub struct ConFrameIterator<'a> {
433    pub(crate) lines: MemchrLines<'a>,
434    /// 0-based index of the frame the cursor is about to read.
435    /// Successful `next` and `forward_fast` advance it. A failure leaves
436    /// it on the frame that failed.
437    frame_index: u64,
438}
439
440/// Newlines seen and the byte index just past the `n`-th `\n`.
441///
442/// When fewer than `n` newlines exist, the count is how many were seen
443/// and the index is `hay.len()`.
444///
445/// # Safety
446/// The CPU supports AVX2.
447#[cfg(target_arch = "x86_64")]
448#[target_feature(enable = "avx2,popcnt")]
449#[inline]
450unsafe fn skip_newlines_avx2(hay: &[u8], n: usize) -> (usize, usize) {
451    use std::arch::x86_64::{
452        __m256i, _mm256_cmpeq_epi8, _mm256_loadu_si256, _mm256_movemask_epi8, _mm256_set1_epi8,
453        _popcnt32,
454    };
455    if n == 0 {
456        return (0, 0);
457    }
458    let mut need = n;
459    let mut offset = 0usize;
460    let ptr = hay.as_ptr();
461    let len = hay.len();
462    let nl = _mm256_set1_epi8(b'\n' as i8);
463    while offset + 32 <= len {
464        // SAFETY: `offset + 32 <= len`, so the 32-byte load stays inside `hay`.
465        let chunk = unsafe { _mm256_loadu_si256(ptr.add(offset) as *const __m256i) };
466        let eq = _mm256_cmpeq_epi8(chunk, nl);
467        let mask = _mm256_movemask_epi8(eq) as u32;
468        let count = _popcnt32(mask as i32) as usize;
469        if count >= need {
470            let mut bits = mask;
471            for _ in 1..need {
472                bits &= bits - 1;
473            }
474            let bit = bits.trailing_zeros() as usize;
475            return (n, offset + bit + 1);
476        }
477        need -= count;
478        offset += 32;
479    }
480    while offset < len {
481        if hay[offset] == b'\n' {
482            need -= 1;
483            if need == 0 {
484                return (n, offset + 1);
485            }
486        }
487        offset += 1;
488    }
489    (n - need, len)
490}
491
492impl<'a> ConFrameIterator<'a> {
493    /// Creates a new `ConFrameIterator` from a string slice of the entire file.
494    ///
495    /// # Arguments
496    ///
497    /// * `file_contents` - A string slice containing the text of one or more `.con` frames.
498    pub fn new(file_contents: &'a str) -> Self {
499        ConFrameIterator {
500            lines: MemchrLines::new(file_contents),
501            frame_index: 0,
502        }
503    }
504
505    /// Like [`Self::new`], but reading from byte `pos` of `file_contents`.
506    ///
507    /// `pos` must be 0 or follow a `\n`. The iterator keeps no state from
508    /// earlier frames, so skipping from `pos` sees what a scan sees there.
509    pub(crate) fn starting_at(file_contents: &'a str, pos: usize) -> Self {
510        debug_assert!(pos == 0 || file_contents.as_bytes()[pos - 1] == b'\n');
511        let mut it = Self::new(file_contents);
512        it.lines.pos = pos;
513        it
514    }
515
516    /// Like [`Self::new`] for a buffer that has not been UTF-8 validated.
517    ///
518    /// Text lines are checked as they are tokenized. Numeric rows reject a
519    /// non-ASCII byte during the fused parse, which is the UTF-8 check for
520    /// those lines.
521    pub fn from_bytes(bytes: &'a [u8]) -> Self {
522        ConFrameIterator {
523            lines: MemchrLines::from_bytes(bytes),
524            frame_index: 0,
525        }
526    }
527
528    /// 0-based index of the frame the cursor is on.
529    ///
530    /// This is the frame a following error belongs to. It is not the
531    /// number of frames still ahead.
532    pub(crate) fn frame_index(&self) -> u64 {
533        self.frame_index
534    }
535
536    fn note_skipped_frame(&mut self) -> Option<Result<(), error::ParseError>> {
537        self.frame_index = self.frame_index.saturating_add(1);
538        Some(Ok(()))
539    }
540
541    /// Bulk-skips `n` lines from the shared memchr cursor.
542    ///
543    /// A final line with no trailing newline still counts when it is the
544    /// last line this call needs and its bytes are present. The full
545    /// parser accepts that line. Further lines after it are missing.
546    ///
547    /// The header has already named the line count, so the skip counts
548    /// newlines instead of parsing the rows. On AVX2 that count is one
549    /// pass over the span.
550    #[inline(always)]
551    fn advance_lines(&mut self, n: usize) -> Result<(), error::ParseError> {
552        self.lines.clear_peek();
553        if self.lines.newlines.is_some() {
554            return self.advance_lines_indexed(n);
555        }
556        #[cfg(target_arch = "x86_64")]
557        {
558            if n > 1 && std::is_x86_feature_detected!("avx2") {
559                return self.advance_lines_avx2(n);
560            }
561        }
562        self.advance_lines_memchr(n)
563    }
564
565    fn advance_lines_memchr(&mut self, n: usize) -> Result<(), error::ParseError> {
566        for i in 0..n {
567            let rest = &self.lines.bytes[self.lines.pos..];
568            match memchr::memchr(b'\n', rest) {
569                Some(pos) => {
570                    self.lines.pos += pos + 1;
571                    self.lines.line_no = self.lines.line_no.saturating_add(1);
572                }
573                None => {
574                    if rest.is_empty() {
575                        return Err(error::ParseError::IncompleteFrame);
576                    }
577                    self.lines.pos = self.lines.bytes.len();
578                    // Count the unterminated line so a later located error
579                    // names it. It satisfies this skip only when it is the
580                    // last line still required; the full parser accepts it.
581                    self.lines.line_no = self.lines.line_no.saturating_add(1);
582                    if i + 1 == n {
583                        return Ok(());
584                    }
585                    return Err(error::ParseError::IncompleteFrame);
586                }
587            }
588        }
589        Ok(())
590    }
591
592    /// [`Self::advance_lines_memchr`] counting newlines in AVX2 chunks.
593    ///
594    /// The landed position and the incomplete-frame condition match the
595    /// per-line search. `n > 1` is the caller's guard: a one-line skip
596    /// is already a single `memchr`.
597    #[cfg(target_arch = "x86_64")]
598    #[inline]
599    fn advance_lines_avx2(&mut self, n: usize) -> Result<(), error::ParseError> {
600        let rest = &self.lines.bytes[self.lines.pos..];
601        let (found, end) = unsafe { skip_newlines_avx2(rest, n) };
602        if found == n {
603            self.lines.pos += end;
604            // The per-line walk counts each skipped line. The vectorized
605            // jump has to keep the same 1-based file line.
606            self.lines.line_no = self
607                .lines
608                .line_no
609                .saturating_add(u32::try_from(n).unwrap_or(u32::MAX));
610            return Ok(());
611        }
612        if rest.is_empty() {
613            return Err(error::ParseError::IncompleteFrame);
614        }
615        let has_tail = rest.last().copied() != Some(b'\n');
616        self.lines.pos = self.lines.bytes.len();
617        let counted = found + usize::from(has_tail);
618        self.lines.line_no = self
619            .lines
620            .line_no
621            .saturating_add(u32::try_from(counted).unwrap_or(u32::MAX));
622        if counted >= n {
623            Ok(())
624        } else {
625            Err(error::ParseError::IncompleteFrame)
626        }
627    }
628
629    /// [`Self::advance_lines`] for `n` lines expected to end at `end`.
630    ///
631    /// When `end` follows a `\n` and exactly `n` newlines lie before it,
632    /// the per-line search would stop at `end` too, so one vectorized
633    /// count replaces it. Any other case takes the per-line search.
634    fn advance_lines_to(&mut self, n: usize, end: Option<usize>) -> Result<(), error::ParseError> {
635        // The newline index jumps by line count. `end` does not change
636        // where that jump lands; it only shortcuts the per-line search.
637        if self.lines.newlines.is_some() {
638            return self.advance_lines(n);
639        }
640        self.lines.clear_peek();
641        let pos = self.lines.pos;
642        let bytes = self.lines.bytes;
643        if let Some(end) = end
644            && pos < end
645            && end <= bytes.len()
646            && bytes[end - 1] == b'\n'
647            && memchr::memchr_iter(b'\n', &bytes[pos..end]).count() == n
648        {
649            self.lines.pos = end;
650            // The per-line walk would count each of these lines. The
651            // vectorized jump has to keep the same 1-based line.
652            self.lines.line_no = self
653                .lines
654                .line_no
655                .saturating_add(u32::try_from(n).unwrap_or(u32::MAX));
656            return Ok(());
657        }
658        self.advance_lines(n)
659    }
660
661    /// [`Self::advance_lines`] using the stitched newline index.
662    ///
663    /// A missing line and a final line with no `\n` land on the same byte
664    /// as the `memchr` loop. The index is not consulted for a full parse.
665    fn advance_lines_indexed(&mut self, n: usize) -> Result<(), error::ParseError> {
666        let len = self.lines.bytes.len();
667        let step = {
668            let nl = self.lines.newlines.as_deref().unwrap();
669            let nlines = line_count(nl, len);
670            let line_no = if self.lines.pos == line_start_pos(nl, len, self.lines.index_line) {
671                self.lines.index_line
672            } else {
673                line_no_for_pos(nl, len, self.lines.pos)
674            };
675            if n == 0 {
676                IndexStep::Stay(line_no)
677            } else if line_no >= nlines {
678                IndexStep::Missing(line_no)
679            } else {
680                match line_no.checked_add(n) {
681                    Some(target) if target <= nlines => {
682                        let pos = if target == nlines {
683                            len
684                        } else {
685                            line_start_pos(nl, len, target)
686                        };
687                        IndexStep::Landed(target, pos)
688                    }
689                    _ => IndexStep::Short(nlines, len),
690                }
691            }
692        };
693        match step {
694            IndexStep::Stay(line_no) => {
695                self.lines.index_line = line_no;
696                self.lines.line_no = u32::try_from(line_no).unwrap_or(u32::MAX);
697                Ok(())
698            }
699            IndexStep::Missing(line_no) => {
700                self.lines.index_line = line_no;
701                self.lines.line_no = u32::try_from(line_no).unwrap_or(u32::MAX);
702                Err(error::ParseError::IncompleteFrame)
703            }
704            IndexStep::Landed(line_no, pos) => {
705                self.lines.index_line = line_no;
706                self.lines.line_no = u32::try_from(line_no).unwrap_or(u32::MAX);
707                self.lines.pos = pos;
708                Ok(())
709            }
710            IndexStep::Short(line_no, pos) => {
711                self.lines.index_line = line_no;
712                self.lines.line_no = u32::try_from(line_no).unwrap_or(u32::MAX);
713                self.lines.pos = pos;
714                Err(error::ParseError::IncompleteFrame)
715            }
716        }
717    }
718
719    /// One line from the shared cursor (same as full-parse path).
720    fn read_line_str(&mut self) -> Option<&'a str> {
721        self.lines.clear_peek();
722        self.lines.next_line()
723    }
724
725    /// memchr-backed equivalent of [`Self::forward`]. Skips the next
726    /// frame without fully parsing its atom data. Shares the same line
727    /// cursor as [`Iterator::next`], so skip and full parse interleave safely.
728    pub fn forward_fast(&mut self) -> Option<Result<(), error::ParseError>> {
729        self.forward_fast_to(None)
730    }
731
732    /// [`Self::forward_fast`] for a frame expected to end at byte `end`.
733    ///
734    /// The result and the new position are the same as without `end`.
735    /// Line blocks that end exactly at `end` are counted, not searched.
736    #[inline(always)]
737    pub(crate) fn forward_fast_to(
738        &mut self,
739        end: Option<usize>,
740    ) -> Option<Result<(), error::ParseError>> {
741        self.lines.clear_peek();
742        if self.lines.pos >= self.lines.bytes.len() {
743            return None;
744        }
745        // Lines 1..=6 of the header are skipped wholesale.
746        if let Err(e) = self.advance_lines(6) {
747            return Some(Err(e));
748        }
749        // Line 7: natm_types.
750        let natm_types: usize = match self.read_line_str() {
751            Some(line) => match crate::parser::parse_count_line(line, 1) {
752                Ok(v) => v[0],
753                Err(e) => return Some(Err(e)),
754            },
755            None => return Some(Err(error::ParseError::IncompleteHeader)),
756        };
757        if natm_types == 0 {
758            // Lines 8 and 9 are whitespace, or absent at EOF. Do not scan
759            // for coordinates or a legacy velocity block (CON-count.1).
760            return match crate::parser::read_zero_atom_tail(|| self.read_line_str()) {
761                Ok(()) => self.note_skipped_frame(),
762                Err(e) => Some(Err(e)),
763            };
764        }
765        // Line 8: natms_per_type.
766        let natms_per_type: Vec<usize> = match self.read_line_str() {
767            Some(line) => match crate::parser::parse_count_line(line, natm_types) {
768                Ok(v) => v,
769                Err(e) => return Some(Err(e)),
770            },
771            None => return Some(Err(error::ParseError::IncompleteHeader)),
772        };
773        if natms_per_type.contains(&0) {
774            return Some(Err(error::ParseError::InvalidCount(
775                "each per-type count must be >= 1".into(),
776            )));
777        }
778        // Line 9: masses_per_type, consumed.
779        if let Err(e) = self.advance_lines(1) {
780            return Some(Err(e));
781        }
782        // A block longer than `usize::MAX` lines never fits the buffer, so a
783        // saturated length ends in `IncompleteFrame` as the exact one would.
784        let total_atoms = natms_per_type
785            .iter()
786            .fold(0usize, |sum, &n| sum.saturating_add(n));
787        let coord_block_lines = total_atoms.saturating_add(natm_types.saturating_mul(2));
788        if let Err(e) = self.advance_lines_to(coord_block_lines, end) {
789            return Some(Err(e));
790        }
791        // Optional sections: blank line + same-shape block, repeated.
792        self.lines.clear_peek();
793        loop {
794            let rest = &self.lines.bytes[self.lines.pos..];
795            if rest.is_empty() {
796                break;
797            }
798            let next_eol = memchr::memchr(b'\n', rest);
799            let line = match next_eol {
800                Some(pos) => &rest[..pos],
801                None => rest,
802            };
803            // The blank test of the full parse, so both end a frame at the
804            // same line.
805            if !is_blank_line(line) {
806                break;
807            }
808            let after = match next_eol {
809                Some(pos) => &rest[pos + 1..],
810                None => &[][..],
811            };
812            // A blank line and then only whitespace is the end of the file,
813            // not a section (CON-frame.5).
814            if after.iter().all(|b| b.is_ascii_whitespace()) {
815                self.lines.pos = self.lines.bytes.len();
816                break;
817            }
818            // A blank that does not begin a section is line 1 of the next
819            // frame, including an empty user line (CON-frame.7, CON-header.4).
820            if !blank_opens_section(after) {
821                break;
822            }
823            // Consume the blank separator and the section block.
824            // The separator is a file line. Skipping it without counting
825            // shifts every later frame's reported line.
826            self.lines.pos += next_eol.map(|p| p + 1).unwrap_or(rest.len());
827            // The separator is a file line. The section block is counted by
828            // the advance, including the vectorized jump.
829            self.lines.line_no = self.lines.line_no.saturating_add(1);
830            if let Err(e) = self.advance_lines_to(coord_block_lines, end) {
831                return Some(Err(e));
832            }
833        }
834        self.note_skipped_frame()
835    }
836
837    /// Skips the next frame without fully parsing its atomic data.
838    ///
839    /// This is more efficient than `next()` if you only need to advance the
840    /// iterator. It reads the frame's header to determine how many lines to skip,
841    /// including any velocity section if present.
842    ///
843    /// # Returns
844    ///
845    /// * `Some(Ok(()))` on a successful skip.
846    /// * `Some(Err(ParseError::...))` if there's an error parsing the header.
847    /// * `None` if the iterator is already at the end.
848    pub fn forward(&mut self) -> Option<Result<(), error::ParseError>> {
849        // Prefer the shared memchr skip path (same cursor as full parse).
850        self.forward_fast()
851    }
852
853    /// Byte offset of the next frame start in the buffer passed to [`Self::new`].
854    pub fn byte_pos(&self) -> usize {
855        self.lines.pos
856    }
857
858    /// Skip `n` frames without parsing atom data. Returns how many were skipped.
859    /// Named `skip_frames` so it does not collide with [`Iterator::skip`].
860    pub fn skip_frames(&mut self, n: usize) -> Result<usize, error::ParseError> {
861        self.skip_frames_counted(n).map_err(|(_, err)| err)
862    }
863
864    /// Like [`Self::skip_frames`]. The error's first field is how many frames
865    /// were skipped before the failure, which is the 0-based index of the
866    /// failing frame when the iterator started at frame 0.
867    pub(crate) fn skip_frames_counted(
868        &mut self,
869        n: usize,
870    ) -> Result<usize, (usize, error::ParseError)> {
871        let mut skipped = 0usize;
872        for _ in 0..n {
873            match self.forward() {
874                None => break,
875                Some(Err(e)) => return Err((skipped, e)),
876                Some(Ok(())) => skipped += 1,
877            }
878        }
879        Ok(skipped)
880    }
881
882    /// Next frame plus the exact substring of the buffer passed to [`Self::new`].
883    ///
884    /// **Corpus ingest contract:** successive successful spans from the same
885    /// `file_contents` are contiguous (`end` of frame *i* equals `start` of frame
886    /// *i+1*) and, for a buffer that is only multi-frame CON (no prefix garbage),
887    /// concatenating all spans reproduces the trajectory text. Campaign stores
888    /// (`readcon-db`) must persist these spans as authoritative blobs—do not
889    /// re-serialize on the hot ingest path unless the caller supplied in-memory
890    /// [`types::ConFrame`] values without source text.
891    ///
892    /// See also [`crate::index_proj::frame_byte_spans`] and
893    /// [`crate::index_proj::spans_cover_buffer`].
894    pub fn next_with_raw_span(
895        &mut self,
896        file_contents: &'a str,
897    ) -> Option<Result<(types::ConFrame, &'a str), error::ParseError>> {
898        let base = file_contents.as_ptr() as usize;
899        let start = {
900            let line = self.lines.peek_line()?;
901            line.as_ptr() as usize - base
902        };
903        let frame = match self.next()? {
904            Ok(f) => f,
905            Err(e) => return Some(Err(e)),
906        };
907        let end = match self.lines.peek_line() {
908            Some(line) => line.as_ptr() as usize - base,
909            None => file_contents.len(),
910        };
911        debug_assert!(end >= start && end <= file_contents.len());
912        Some(Ok((frame, &file_contents[start..end])))
913    }
914
915    /// [`Iterator::next`] plus the byte offset where the frame ends.
916    pub(crate) fn next_with_end(
917        &mut self,
918    ) -> Option<Result<(types::ConFrame, usize), error::ParseError>> {
919        let frame = match self.next()? {
920            Ok(f) => f,
921            Err(e) => return Some(Err(e)),
922        };
923        let end = match self.lines.peek_line() {
924            Some(line) => line.as_ptr() as usize - self.lines.bytes.as_ptr() as usize,
925            None => self.lines.pos,
926        };
927        Some(Ok((frame, end)))
928    }
929}
930
931impl<'a> Iterator for ConFrameIterator<'a> {
932    /// The type of item yielded by the iterator.
933    ///
934    /// Each item is a `Result` that contains a successfully parsed `ConFrame` or a
935    /// `ParseError` if the frame's data is malformed.
936    type Item = Result<types::ConFrame, error::ParseError>;
937
938    /// Advances the iterator and attempts to parse the next frame.
939    ///
940    /// This method will return `None` only when there are no more lines to consume.
941    /// If there are lines but they do not form a complete frame, it will return
942    /// `Some(Err(ParseError::...))`. Invalid UTF-8 is a validation error, not
943    /// the end of the stream.
944    fn next(&mut self) -> Option<Self::Item> {
945        // A line rejected earlier in this cursor (for example while probing
946        // for a velocity block) must not be reported as a clean EOF.
947        if let Some(err) = self.lines.utf8_error() {
948            return Some(Err(err));
949        }
950        // Trailing whitespace after the last complete frame is not a frame
951        // (CON-frame.1, D2). An empty line that is followed by more text is
952        // line 1 of the next frame (CON-frame.7).
953        if self.lines.remainder_is_whitespace() {
954            if let Some(err) = self.lines.utf8_error() {
955                return Some(Err(err));
956            }
957            while self.lines.next_line().is_some() {}
958            return None;
959        }
960        if let Some(err) = self.lines.utf8_error() {
961            return Some(Err(err));
962        }
963        // Otherwise, attempt to parse the next frame from the available lines.
964        let max_rows = Some(self.lines.unread_len() / crate::parser::MIN_COORD_ROW_BYTES);
965        let mut frame = match crate::parser::parse_single_frame_ls(&mut self.lines, max_rows) {
966            Ok(f) => f,
967            Err(e) => return Some(Err(self.lines.prefer_utf8(e))),
968        };
969        // Optional sections mutate AoS; only re-sync section SoA when needed.
970        // Plain .con assembly already filled positions/ids/masses (no O(N)
971        // post-scan when no velocity/force sections were applied).
972        let sections = match parse_declared_sections(&mut self.lines, &mut frame) {
973            Ok(n) => n,
974            // A section line that failed UTF-8 makes `next_line` return
975            // `None`. That must not surface as `IncompleteSection`.
976            Err(e) => return Some(Err(self.lines.prefer_utf8(e))),
977        };
978        if sections > 0 {
979            // One flat fill per section that was actually parsed. The full
980            // sync walks every absent section and writes each row through
981            // ArcArray uniqueness checks.
982            frame.sequential_plain = false;
983            frame.install_declared_section_soa();
984        }
985        self.frame_index = self.frame_index.saturating_add(1);
986        Some(Ok(frame))
987    }
988}
989
990#[cfg(test)]
991mod aos_soa_agreement_tests {
992    use super::*;
993    use std::path::PathBuf;
994
995    #[test]
996    fn iterator_vel_forces_soa_matches_aos() {
997        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
998            .join("resources/test/tiny_cuh2_vel_forces.con");
999        let text = std::fs::read_to_string(&p).expect("fixture");
1000        let fr = ConFrameIterator::new(&text)
1001            .next()
1002            .expect("frame")
1003            .expect("parse");
1004        let n = fr.atom_data.len();
1005        assert!(n > 0);
1006        assert_eq!(fr.positions.nrows(), n);
1007        let has_vel = fr.atom_data.iter().any(|a| a.velocity().is_some());
1008        let has_frc = fr.atom_data.iter().any(|a| a.force().is_some());
1009        if has_vel {
1010            assert_eq!(
1011                fr.velocities.nrows(),
1012                n,
1013                "SoA velocities must match AoS after section parse"
1014            );
1015        }
1016        if has_frc {
1017            assert_eq!(fr.forces.nrows(), n, "SoA forces must match AoS");
1018        }
1019        for (i, a) in fr.atom_data.iter().enumerate() {
1020            let p = fr.positions.as_f64_row(i);
1021            assert_eq!([a.x(), a.y(), a.z()], p);
1022            if let Some(v) = a.velocity() {
1023                assert_eq!(v, fr.velocities.as_f64_row(i));
1024            }
1025            if let Some(f) = a.force() {
1026                assert_eq!(f, fr.forces.as_f64_row(i));
1027            }
1028        }
1029    }
1030
1031    /// After SoA-primary parse, section sync must not require rewriting positions
1032    /// (nrows already equals N); forces SoA still filled from AoS.
1033    #[test]
1034    fn sync_skips_pos_when_nrows_matches_keeps_force_soa() {
1035        let p =
1036            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2_forces.con");
1037        let text = std::fs::read_to_string(&p).expect("fixture");
1038        let fr = ConFrameIterator::new(&text)
1039            .next()
1040            .expect("frame")
1041            .expect("parse");
1042        let n = fr.atom_data.len();
1043        assert_eq!(fr.positions.nrows(), n);
1044        assert_eq!(fr.forces.nrows(), n);
1045        // Snapshot first position SoA row then re-sync; coords must stay bit-identical
1046        // (no needless rewrite would change nothing but we still require agreement).
1047        let p0 = fr.positions.as_f64_row(0);
1048        let mut fr2 = fr.clone();
1049        fr2.sync_arrays_from_atom_data();
1050        assert_eq!(fr2.positions.as_f64_row(0), p0);
1051        assert_eq!(fr2.forces.nrows(), n);
1052        assert_eq!(
1053            fr2.forces.as_f64_row(0),
1054            fr2.atom_data[0].force().expect("force")
1055        );
1056    }
1057
1058    /// Section SoA is filled from one flat buffer. Every value must match the
1059    /// per-atom tail, including past a small-vector boundary.
1060    #[test]
1061    fn declared_sections_soa_matches_every_atom() {
1062        let n = 400;
1063        let mut text = String::new();
1064        text.push_str("soa fill\n");
1065        text.push_str(
1066            "{\"con_spec_version\":2,\"sections\":[\"velocities\",\"forces\",\"energies\"]}\n",
1067        );
1068        text.push_str("10.0 10.0 10.0\n90.0 90.0 90.0\n0 0\n0 0 0\n1\n");
1069        text.push_str(&format!("{n}\n63.546\nCu\nCoordinates of Component 1\n"));
1070        for i in 0..n {
1071            let x = (i as f64) * 0.125 - 3.5;
1072            let y = (i as f64) * -0.05 + 1.0;
1073            let z = (i % 17) as f64 * 0.333;
1074            text.push_str(&format!("{x:.8} {y:.8} {z:.8} 0 {i}\n"));
1075        }
1076        text.push_str("\nCu\nVelocities of Component 1\n");
1077        for i in 0..n {
1078            let vx = (i as f64) * 1.0e-4 - 0.02;
1079            let vy = ((i * 3) % 11) as f64 * -1.0e-3;
1080            let vz = (i as f64).sin() * 0.01;
1081            text.push_str(&format!("{vx:.8} {vy:.8} {vz:.8} 0 {i}\n"));
1082        }
1083        text.push_str("\nCu\nForces of Component 1\n");
1084        for i in 0..n {
1085            let fx = (i as f64) * 0.01;
1086            let fy = -fx / 2.0;
1087            let fz = (i % 5) as f64;
1088            text.push_str(&format!("{fx:.8} {fy:.8} {fz:.8} 0 {i}\n"));
1089        }
1090        text.push_str("\nCu\nEnergies of Component 1\n");
1091        for i in 0..n {
1092            let e = (i as f64) * 0.02 - 4.0;
1093            text.push_str(&format!("{e:.8} 0 {i}\n"));
1094        }
1095        let frame = ConFrameIterator::new(&text)
1096            .next()
1097            .expect("frame")
1098            .expect("parse");
1099        assert_eq!(frame.atom_data.len(), n);
1100        assert_eq!(frame.velocities.nrows(), n);
1101        assert_eq!(frame.forces.nrows(), n);
1102        assert_eq!(frame.atom_energies.len(), n);
1103        assert_eq!(frame.positions.nrows(), n);
1104        assert!(frame.magmoms.nrows() == 0);
1105        for i in 0..n {
1106            let atom = &frame.atom_data[i];
1107            let pos = frame.positions.as_f64_row(i);
1108            assert_eq!(pos[0].to_bits(), atom.x().to_bits());
1109            assert_eq!(pos[1].to_bits(), atom.y().to_bits());
1110            assert_eq!(pos[2].to_bits(), atom.z().to_bits());
1111            let vel = atom.velocity().unwrap();
1112            let soa_v = frame.velocities.as_f64_row(i);
1113            assert_eq!(soa_v[0].to_bits(), vel[0].to_bits());
1114            assert_eq!(soa_v[1].to_bits(), vel[1].to_bits());
1115            assert_eq!(soa_v[2].to_bits(), vel[2].to_bits());
1116            let frc = atom.force().unwrap();
1117            let soa_f = frame.forces.as_f64_row(i);
1118            assert_eq!(soa_f[0].to_bits(), frc[0].to_bits());
1119            assert_eq!(soa_f[1].to_bits(), frc[1].to_bits());
1120            assert_eq!(soa_f[2].to_bits(), frc[2].to_bits());
1121            assert_eq!(
1122                frame.atom_energies.get_f64(i).to_bits(),
1123                atom.energy().unwrap().to_bits()
1124            );
1125            assert_eq!(frame.atom_ids[i], atom.atom_id);
1126        }
1127    }
1128}
1129
1130/// Reads all frames from a file.
1131///
1132/// A plain file is parsed from a window on the calling thread, so the
1133/// file is not resident together with the finished frames. Compressed
1134/// input is read into one buffer. A worker pool runs only when
1135/// [`read_all_frames_with_threads`] is asked for two or more threads.
1136pub fn read_all_frames(path: &Path) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
1137    read_all_frames_with_threads(path, None)
1138}
1139
1140/// Collect every frame from `text`.
1141///
1142/// `None`, `Some(0)`, and `Some(1)` parse on the calling thread.
1143/// `Some(n)` with `n >= 2` uses a Rayon pool of that size when the
1144/// `parallel` feature is on. Builds without `parallel` always parse
1145/// sequentially.
1146///
1147/// ```
1148/// use readcon_core::iterators::frames_from_text;
1149/// let text = include_str!("../resources/test/tiny_multi_cuh2.con");
1150/// let one = frames_from_text(text, Some(1)).unwrap();
1151/// let pool = frames_from_text(text, Some(2)).unwrap();
1152/// assert_eq!(one.len(), pool.len());
1153/// assert!(one.len() >= 2);
1154/// ```
1155pub fn frames_from_text(
1156    text: &str,
1157    num_threads: Option<usize>,
1158) -> Result<Vec<types::ConFrame>, error::ParseError> {
1159    #[cfg(feature = "parallel")]
1160    {
1161        if let Some(n) = num_threads.filter(|n| *n >= 2) {
1162            // The phased path finishes the slots on the same pool. A
1163            // serial move of every frame was a growing tail once a worker
1164            // owned tens of thousands of frames.
1165            return Ok(frames_from_bytes_phased(text.as_bytes(), Some(n))?.frames);
1166        }
1167    }
1168    #[cfg(not(feature = "parallel"))]
1169    {
1170        let _ = num_threads;
1171    }
1172    ConFrameIterator::new(text).collect()
1173}
1174
1175/// Like [`read_all_frames`], with an explicit worker count.
1176///
1177/// `None`, `Some(0)`, and `Some(1)` parse on the calling thread.
1178/// `Some(n)` with `n >= 2` pins a Rayon pool when `parallel` is on.
1179///
1180/// A parse failure is the [`error::LocatedParseError`] from that parse.
1181/// Its line is the cursor that failed and its frame is the 0-based index.
1182/// The path is not opened again.
1183///
1184/// One thread reads a plain file in windows. Two or more threads map
1185/// the file. Compressed input is read into one buffer.
1186pub fn read_all_frames_with_threads(
1187    path: &Path,
1188    num_threads: Option<usize>,
1189) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
1190    if matches!(num_threads, Some(n) if n >= 2) {
1191        let bytes = crate::compression::read_bytes_owned(path)?;
1192        return Ok(frames_from_bytes_located(bytes.as_slice(), num_threads)?);
1193    }
1194    stream_frames(path).map_err(public_load_error)
1195}
1196
1197fn public_load_error(err: LoadError) -> Box<dyn std::error::Error> {
1198    match err {
1199        LoadError::Parse(located) => Box::new(located),
1200        other => Box::new(other),
1201    }
1202}
1203
1204/// Read plain text in windows and parse each complete frame before the next
1205/// window. The window is a few mebibytes, so the file is not resident with
1206/// the finished frames. Gzip and zstd are inflated into the same window.
1207fn stream_frames(path: &Path) -> Result<Vec<types::ConFrame>, LoadError> {
1208    use std::io::Read;
1209    let mut file = std::fs::File::open(path).map_err(LoadError::Io)?;
1210    let mut magic = [0u8; 4];
1211    let n = file.read(&mut magic).map_err(LoadError::Io)?;
1212    let compression = crate::compression::detect_compression(&magic[..n]);
1213    if matches!(compression, crate::compression::Compression::None) {
1214        let file_len = path.metadata().map(|m| m.len()).unwrap_or(0);
1215        // A modest plain file stays mapped, the same way the coordinate
1216        // read does. Copying it into a buffer first was a visible share of
1217        // a million-atom frame read. The windowed reader stays for a long
1218        // trajectory so the text is not resident beside every frame.
1219        // 128 MiB covers a million-atom frame with velocities and forces.
1220        // The 100k-frame, 32-atom trajectory is larger and stays on the window.
1221        if file_len > 0 && file_len <= 128 * 1024 * 1024 {
1222            drop(file);
1223            let source = crate::compression::read_bytes_owned(path).map_err(load_error_from_box)?;
1224            return frames_from_bytes_located(source.as_slice(), Some(1)).map_err(LoadError::Parse);
1225        }
1226        return stream_reader(&mut file, &magic[..n], file_len);
1227    }
1228    drop(file);
1229    // Indexed members inflate straight into one buffer. The plain bytes
1230    // are parsed in place, so a gzip file is not copied into a `String`.
1231    let bytes = crate::compression::read_file_bytes(path).map_err(load_error_from_box)?;
1232    let budget = crate::compression::decode_thread_budget();
1233    let threads = if budget >= 2 { Some(budget) } else { Some(1) };
1234    frames_from_bytes_located(&bytes, threads).map_err(LoadError::Parse)
1235}
1236
1237/// Decode `bytes`. At end of input, a hard parse error in the valid prefix
1238/// wins over a trailing non-UTF-8 byte, so that byte does not hide it.
1239fn utf8_text(bytes: &[u8], eof: bool) -> Result<&str, LoadError> {
1240    match std::str::from_utf8(bytes) {
1241        Ok(text) => Ok(text),
1242        Err(err) if eof => earlier_parse_or_utf8(bytes, err),
1243        Err(err) => Err(LoadError::Utf8(err)),
1244    }
1245}
1246
1247fn error_needs_more_input(error: &error::ParseError) -> bool {
1248    matches!(
1249        error,
1250        error::ParseError::IncompleteHeader
1251            | error::ParseError::IncompleteFrame
1252            | error::ParseError::IncompleteVelocitySection
1253            | error::ParseError::IncompleteForceSection
1254            | error::ParseError::IncompleteEnergySection
1255            | error::ParseError::IncompleteSection(_)
1256    )
1257}
1258
1259fn earlier_parse_or_utf8(bytes: &[u8], err: std::str::Utf8Error) -> Result<&str, LoadError> {
1260    let valid = err.valid_up_to();
1261    if valid > 0 {
1262        if let Ok(prefix) = std::str::from_utf8(&bytes[..valid]) {
1263            if let Err(located) = frames_from_text_located(prefix, Some(1)) {
1264                // A frame cut off by the bad byte is still the UTF-8 error.
1265                // A finished frame that already failed is the error to keep.
1266                if !error_needs_more_input(&located.error) {
1267                    return Err(LoadError::Parse(located));
1268                }
1269            }
1270        }
1271    }
1272    Err(LoadError::Utf8(err))
1273}
1274
1275fn stream_reader(
1276    reader: &mut dyn std::io::Read,
1277    prefix: &[u8],
1278    file_len: u64,
1279) -> Result<Vec<types::ConFrame>, LoadError> {
1280    let mut buf = Vec::with_capacity(8 << 20);
1281    buf.extend_from_slice(prefix);
1282    let mut frames = Vec::new();
1283    let mut sized = false;
1284    let mut line_base = 0u32;
1285    let mut eof = false;
1286    let mut read_more = |buf: &mut Vec<u8>, eof: &mut bool| -> Result<(), LoadError> {
1287        if *eof {
1288            return Ok(());
1289        }
1290        let start = buf.len();
1291        buf.resize(start + (8 << 20), 0);
1292        let got = reader.read(&mut buf[start..]).map_err(LoadError::Io)?;
1293        buf.truncate(start + got);
1294        if got == 0 {
1295            *eof = true;
1296        }
1297        Ok(())
1298    };
1299    if buf.len() < (8 << 20) {
1300        read_more(&mut buf, &mut eof)?;
1301    }
1302    loop {
1303        if buf.is_empty() {
1304            break;
1305        }
1306        let end = if eof {
1307            buf.len()
1308        } else {
1309            match buf.iter().rposition(|byte| *byte == b'\n') {
1310                Some(i) => i + 1,
1311                None => {
1312                    read_more(&mut buf, &mut eof)?;
1313                    continue;
1314                }
1315            }
1316        };
1317        let text = utf8_text(&buf[..end], eof)?;
1318        let (parsed, consumed) = take_complete_frames(text, eof, line_base, frames.len() as u64)?;
1319        if consumed == 0 && !eof {
1320            // The window holds the start of one frame and nothing else.
1321            // Parsing again after every 8 MiB rereads that prefix. Pull
1322            // the rest of a single oversized frame, then parse it once.
1323            if frames.is_empty() && file_len > 0 && file_len <= 512 * 1024 * 1024 {
1324                while !eof {
1325                    let before = buf.len();
1326                    read_more(&mut buf, &mut eof)?;
1327                    if buf.len() == before {
1328                        break;
1329                    }
1330                }
1331                continue;
1332            }
1333            let before = buf.len();
1334            read_more(&mut buf, &mut eof)?;
1335            if buf.len() == before && !eof {
1336                eof = true;
1337            }
1338            continue;
1339        }
1340        frames.extend(parsed);
1341        if !sized && consumed > 0 && !frames.is_empty() && file_len > 0 {
1342            let per = (consumed / frames.len()).max(1);
1343            let guess = (file_len as usize) / per + 8;
1344            if guess > frames.capacity() {
1345                frames.reserve_exact(guess - frames.len());
1346            }
1347            sized = true;
1348        }
1349        line_base += buf[..consumed]
1350            .iter()
1351            .filter(|byte| **byte == b'\n')
1352            .count() as u32;
1353        buf.drain(..consumed);
1354        if buf.is_empty() && eof {
1355            break;
1356        }
1357        if !eof && buf.len() < (8 << 20) {
1358            read_more(&mut buf, &mut eof)?;
1359        }
1360    }
1361    Ok(frames)
1362}
1363
1364fn take_complete_frames(
1365    text: &str,
1366    eof: bool,
1367    line_base: u32,
1368    frame_base: u64,
1369) -> Result<(Vec<types::ConFrame>, usize), LoadError> {
1370    let mut iter = ConFrameIterator::new(text);
1371    iter.lines.line_no = line_base;
1372    let mut frames = Vec::new();
1373    loop {
1374        // A finished frame may have peeked the next user line. That peek
1375        // moves `pos` past the line. Rewind before recording the resume point.
1376        iter.lines.clear_peek();
1377        let start = iter.lines.pos;
1378        if !eof && text[start..].trim().is_empty() {
1379            // A trailing blank line is the next frame's user line, not the
1380            // end of the file. Leave it for the next window.
1381            return Ok((frames, start));
1382        }
1383        match iter.next() {
1384            Some(Ok(frame)) => frames.push(frame),
1385            Some(Err(error)) => {
1386                // A complete line that is already illegal will not become
1387                // legal when the next window arrives. An unfinished frame
1388                // might: the window stopped at a newline before its rows.
1389                if eof || !error_needs_more_input(&error) {
1390                    return Err(LoadError::Parse(error::LocatedParseError::new(
1391                        error,
1392                        iter.lines.line_no(),
1393                        frame_base + frames.len() as u64,
1394                    )));
1395                }
1396                return Ok((frames, start));
1397            }
1398            None => {
1399                if eof {
1400                    return Ok((frames, text.len()));
1401                }
1402                return Ok((frames, start));
1403            }
1404        }
1405    }
1406}
1407
1408/// Positions of every frame, one row-major `f64` buffer.
1409#[derive(Debug)]
1410///
1411/// A parse failure is the [`error::LocatedParseError`] from the boundary
1412/// scan or the span parse that failed. The path is not opened again.
1413///
1414/// The boundary walk records each frame start. Each worker then parses
1415/// only its span and writes that frame's coordinates at
1416/// `frame * n_atoms * 3`. The returned buffer is the only retained
1417/// coordinate storage. A file whose frames do not share one atom count
1418/// is rejected, because the buffer is rectangular.
1419/// Rectangular coordinates for every frame of one file.
1420///
1421/// `values` is row-major `x, y, z`, index `frame * n_atoms * 3 + atom * 3`.
1422/// That `Vec` is the only coordinate allocation. Python `read_positions`
1423/// moves it into the NumPy array and does not copy the doubles again.
1424pub struct PositionBlock {
1425    pub n_frames: usize,
1426    pub n_atoms: usize,
1427    pub values: Vec<f64>,
1428}
1429
1430/// Read every frame's coordinates through the column walker.
1431///
1432/// `num_threads` of `None`, `Some(0)`, and `Some(1)` stay on the calling
1433/// thread. `Some(n)` with `n >= 2` uses that many workers when `parallel`
1434/// is enabled. The buffer is rectangular: frames that do not share one
1435/// atom count are an error. The returned [`PositionBlock`] owns the parse
1436/// buffer. Python `read_positions` moves `values` into NumPy.
1437///
1438/// # Examples
1439///
1440/// ```
1441/// # use std::path::Path;
1442/// let block = readcon_core::iterators::read_positions_with_threads(
1443///     Path::new("resources/test/tiny_cuh2.con"),
1444///     None,
1445/// )
1446/// .unwrap();
1447/// assert_eq!(
1448///     (block.n_frames, block.n_atoms, block.values.len()),
1449///     (1, 4, 12)
1450/// );
1451/// ```
1452pub fn read_positions_with_threads(
1453    path: &Path,
1454    num_threads: Option<usize>,
1455) -> Result<PositionBlock, Box<dyn std::error::Error>> {
1456    let contents = crate::compression::read_file_contents(path)?;
1457    let block = match &contents {
1458        crate::compression::FileContents::Owned(text) => {
1459            fill_position_block(text.as_bytes(), num_threads)
1460        }
1461        crate::compression::FileContents::Mapped(map) => fill_position_block(map, num_threads),
1462    }?;
1463    Ok(block)
1464}
1465
1466/// Masses and cell that belong with [`read_positions_with_threads`].
1467///
1468/// The column walker fills these when it certifies the file. Otherwise they
1469/// come from the frame parser, which is the same source as before.
1470pub struct CoordColumns {
1471    pub block: PositionBlock,
1472    pub masses: Vec<f64>,
1473    pub cells: Vec<f64>,
1474    /// Column 5, file order, length `n_frames * n_atoms` when identity was read.
1475    pub atom_ids: Vec<u64>,
1476    /// Decoded column-4 bitmask, same length as `atom_ids` when identity was read.
1477    pub fixed: Vec<u8>,
1478    /// One run-length element list when every frame matches, otherwise one per frame.
1479    ///
1480    /// Each run is `(symbol, count)` in file order. An empty list means identity
1481    /// was not collected.
1482    pub species: Vec<Vec<(String, usize)>>,
1483    pub has_section: bool,
1484    /// True when any atom is held on some axes and free on others.
1485    pub partial_fixed: bool,
1486}
1487
1488/// Positions, masses, and cells for a stock CON or convel file.
1489///
1490/// A certified file is one coordinate pass. Anything else uses the frame
1491/// reader. Python `read_coords` returns these columns.
1492pub fn read_coord_columns(
1493    path: &Path,
1494    num_threads: Option<usize>,
1495) -> Result<CoordColumns, Box<dyn std::error::Error>> {
1496    let contents = crate::compression::read_file_contents(path)?;
1497    let bytes = contents.as_bytes();
1498    if let Ok(Some(columns)) = certified_columns(bytes, num_threads, true) {
1499        return Ok(columns);
1500    }
1501    let frames = frames_from_bytes_located(bytes, num_threads)?;
1502    Ok(columns_from_frames(&frames))
1503}
1504
1505fn certified_columns(
1506    bytes: &[u8],
1507    num_threads: Option<usize>,
1508    identity: bool,
1509) -> Result<Option<CoordColumns>, error::LocatedParseError> {
1510    let mut parsed = {
1511        let parsed = if parallel_column_threads(num_threads) {
1512            #[cfg(feature = "parallel")]
1513            {
1514                crate::columns::positions_parallel(bytes, num_threads.unwrap_or(1), identity)
1515            }
1516            #[cfg(not(feature = "parallel"))]
1517            {
1518                let _ = num_threads;
1519                if identity {
1520                    crate::columns::positions_with_identity(bytes)
1521                } else {
1522                    crate::columns::positions_from_con_bytes(bytes)
1523                }
1524            }
1525        } else if identity {
1526            crate::columns::positions_with_identity(bytes)
1527        } else {
1528            crate::columns::positions_from_con_bytes(bytes)
1529        };
1530        match parsed {
1531            Ok(parsed) if parsed.stock => parsed,
1532            _ => return Ok(None),
1533        }
1534    };
1535    if parsed.n_frames == 0 {
1536        return Ok(Some(CoordColumns {
1537            block: PositionBlock {
1538                n_frames: 0,
1539                n_atoms: 0,
1540                values: Vec::new(),
1541            },
1542            masses: Vec::new(),
1543            cells: Vec::new(),
1544            atom_ids: Vec::new(),
1545            fixed: Vec::new(),
1546            species: Vec::new(),
1547            has_section: false,
1548            partial_fixed: false,
1549        }));
1550    }
1551    let Some(n_atoms) = parsed.rectangular_atoms() else {
1552        let first = parsed.counts[0];
1553        let index = parsed
1554            .counts
1555            .iter()
1556            .position(|count| *count != first)
1557            .unwrap_or(0);
1558        let line = file_line_at_bytes(
1559            bytes,
1560            parsed.spans.get(index).map(|span| span.start).unwrap_or(0),
1561        );
1562        return Err(error::LocatedParseError::new(
1563            error::ParseError::InvalidCount(
1564                "frame atom count does not match the first frame".into(),
1565            ),
1566            line,
1567            index as u64,
1568        ));
1569    };
1570    let ColumnIdentity {
1571        atom_ids,
1572        fixed,
1573        species,
1574        partial_fixed,
1575    } = if identity {
1576        match frame_identity(&parsed, n_atoms) {
1577            Some(identity) => identity,
1578            None => return Ok(None),
1579        }
1580    } else {
1581        ColumnIdentity::default()
1582    };
1583    let has_section = parsed.has_section;
1584    let values = std::mem::take(&mut parsed.positions);
1585    Ok(Some(CoordColumns {
1586        block: PositionBlock {
1587            n_frames: parsed.n_frames,
1588            n_atoms,
1589            values,
1590        },
1591        masses: parsed.masses,
1592        cells: parsed.cells,
1593        atom_ids,
1594        fixed,
1595        species,
1596        has_section,
1597        partial_fixed,
1598    }))
1599}
1600
1601/// Column 5, the fixed mask, and the element runs for one coordinate read.
1602#[derive(Default)]
1603struct ColumnIdentity {
1604    atom_ids: Vec<u64>,
1605    fixed: Vec<u8>,
1606    species: Vec<Vec<(String, usize)>>,
1607    partial_fixed: bool,
1608}
1609
1610/// Column 5, the fixed mask, and element runs. `None` when the rich columns
1611/// do not partition the frames, so the caller uses the frame parser.
1612fn frame_identity(parsed: &crate::columns::ConPositions, n_atoms: usize) -> Option<ColumnIdentity> {
1613    let n = parsed.n_frames.saturating_mul(n_atoms);
1614    if parsed.ids.len() != n || parsed.flags.len() != n {
1615        return None;
1616    }
1617    let mut runs = Vec::with_capacity(parsed.n_frames);
1618    let mut components = parsed.components.iter();
1619    for &count in &parsed.counts {
1620        let mut left = count;
1621        let mut frame_runs = Vec::new();
1622        while left > 0 {
1623            let (symbol, width) = components.next()?;
1624            if *width == 0 || *width > left {
1625                return None;
1626            }
1627            frame_runs.push((symbol.to_string(), *width));
1628            left -= *width;
1629        }
1630        runs.push(frame_runs);
1631    }
1632    if components.next().is_some() {
1633        return None;
1634    }
1635    let partial_fixed = parsed.flags.iter().any(|flag| *flag != 0 && *flag != 7);
1636    let species = if runs.windows(2).all(|pair| pair[0] == pair[1]) {
1637        runs.into_iter().take(1).collect()
1638    } else {
1639        runs
1640    };
1641    Some(ColumnIdentity {
1642        atom_ids: parsed.ids.clone(),
1643        fixed: parsed.flags.clone(),
1644        species,
1645        partial_fixed,
1646    })
1647}
1648
1649fn parallel_column_threads(num_threads: Option<usize>) -> bool {
1650    // `None`, `Some(0)`, and `Some(1)` stay on the calling thread. A pool
1651    // runs only for an explicit count of two or more.
1652    #[cfg(feature = "parallel")]
1653    {
1654        matches!(num_threads, Some(n) if n >= 2)
1655    }
1656    #[cfg(not(feature = "parallel"))]
1657    {
1658        let _ = num_threads;
1659        false
1660    }
1661}
1662
1663fn columns_from_frames(frames: &[types::ConFrame]) -> CoordColumns {
1664    let n_frames = frames.len();
1665    let n_atoms = frames
1666        .first()
1667        .map(|frame| frame.positions.nrows())
1668        .unwrap_or(0);
1669    let mut values = Vec::with_capacity(n_frames * n_atoms * 3);
1670    let mut masses = Vec::with_capacity(n_frames * n_atoms);
1671    let mut cells = Vec::with_capacity(n_frames * 6);
1672    let mut atom_ids = Vec::with_capacity(n_frames * n_atoms);
1673    let mut fixed = Vec::with_capacity(n_frames * n_atoms);
1674    let mut runs = Vec::with_capacity(n_frames);
1675    let mut has_section = false;
1676    let mut partial_fixed = false;
1677    for frame in frames {
1678        cells.extend_from_slice(&frame.header.boxl);
1679        cells.extend_from_slice(&frame.header.angles);
1680        if let Some(src) = frame.positions.f64_slice() {
1681            values.extend_from_slice(src);
1682        } else {
1683            for row in 0..frame.positions.nrows() {
1684                values.extend_from_slice(&frame.positions.as_f64_row(row));
1685            }
1686        }
1687        for (type_index, &count) in frame.header.natms_per_type.iter().enumerate() {
1688            let mass = frame
1689                .header
1690                .masses_per_type
1691                .get(type_index)
1692                .copied()
1693                .unwrap_or(0.0);
1694            masses.extend(std::iter::repeat_n(mass, count));
1695        }
1696        has_section |= frame.has_velocities()
1697            || frame.has_forces()
1698            || frame.has_energies()
1699            || frame.has_charges()
1700            || frame.has_spins()
1701            || frame.has_magmoms()
1702            || frame.has_displacements()
1703            || frame.has_spreads();
1704        let mut frame_runs: Vec<(String, usize)> = Vec::new();
1705        for atom in &frame.atom_data {
1706            atom_ids.push(atom.atom_id);
1707            let mask = crate::types::encode_fixed_bitmask(atom.fixed());
1708            if mask != 0 && mask != 7 {
1709                partial_fixed = true;
1710            }
1711            fixed.push(mask);
1712            if let Some((symbol, count)) = frame_runs.last_mut()
1713                && symbol == atom.symbol.as_ref()
1714            {
1715                *count += 1;
1716                continue;
1717            }
1718            frame_runs.push((atom.symbol.to_string(), 1));
1719        }
1720        runs.push(frame_runs);
1721    }
1722    let species = if runs.windows(2).all(|pair| pair[0] == pair[1]) {
1723        runs.into_iter().take(1).collect()
1724    } else {
1725        runs
1726    };
1727    CoordColumns {
1728        block: PositionBlock {
1729            n_frames,
1730            n_atoms,
1731            values,
1732        },
1733        masses,
1734        cells,
1735        atom_ids,
1736        fixed,
1737        species,
1738        has_section,
1739        partial_fixed,
1740    }
1741}
1742
1743/// 1-based file line of the line that contains `byte`.
1744fn file_line_at_bytes(bytes: &[u8], byte: usize) -> u32 {
1745    let end = byte.min(bytes.len());
1746    1 + memchr::memchr_iter(b'\n', &bytes[..end]).count() as u32
1747}
1748
1749/// File line of a cursor that counted from the start of a share.
1750///
1751/// `local` is 1-based inside the share. A cursor that never moved still
1752/// names the share's first line.
1753fn share_cursor(local: u32, bytes: &[u8], share_start: usize) -> u32 {
1754    let base = file_line_at_bytes(bytes, share_start);
1755    if local == 0 {
1756        base
1757    } else {
1758        base.saturating_add(local.saturating_sub(1))
1759    }
1760}
1761
1762fn frame_starts(bytes: &[u8]) -> Result<Vec<usize>, error::LocatedParseError> {
1763    let mut scanner = ConFrameIterator::from_bytes(bytes);
1764    let mut boundaries = Vec::new();
1765    loop {
1766        scanner.lines.clear_peek();
1767        let start = scanner.lines.pos;
1768        if no_frame_at(bytes, start) {
1769            break;
1770        }
1771        boundaries.push(start);
1772        match scanner.forward_fast() {
1773            Some(Ok(())) => {}
1774            Some(Err(err)) => {
1775                return Err(error::LocatedParseError::new(
1776                    err,
1777                    scanner.lines.line_no(),
1778                    scanner.frame_index(),
1779                ));
1780            }
1781            None => break,
1782        }
1783    }
1784    Ok(boundaries)
1785}
1786
1787/// Parse one span. The error keeps this frame's file line.
1788fn parse_span_located(
1789    bytes: &[u8],
1790    start: usize,
1791    end: usize,
1792    frame_index: u64,
1793) -> Result<types::ConFrame, error::LocatedParseError> {
1794    let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
1795    match iter.next() {
1796        Some(Ok(frame)) => Ok(frame),
1797        Some(Err(err)) => Err(error::LocatedParseError::new(
1798            err,
1799            share_cursor(iter.lines.line_no(), bytes, start),
1800            frame_index,
1801        )),
1802        None => Err(error::LocatedParseError::new(
1803            error::ParseError::IncompleteFrame,
1804            file_line_at_bytes(bytes, start),
1805            frame_index,
1806        )),
1807    }
1808}
1809
1810fn copy_positions(frame: &types::ConFrame, dest: &mut [f64]) -> Result<(), error::ParseError> {
1811    if frame.positions.nrows().saturating_mul(3) != dest.len() {
1812        return Err(error::ParseError::InvalidCount(
1813            "frame atom count does not match the first frame".into(),
1814        ));
1815    }
1816    if let Some(src) = frame.positions.f64_slice() {
1817        dest.copy_from_slice(src);
1818        return Ok(());
1819    }
1820    for (i, slot) in dest.as_chunks_mut::<3>().0.iter_mut().enumerate() {
1821        slot.copy_from_slice(&frame.positions.as_f64_row(i));
1822    }
1823    Ok(())
1824}
1825
1826fn fill_position_block(
1827    bytes: &[u8],
1828    num_threads: Option<usize>,
1829) -> Result<PositionBlock, error::LocatedParseError> {
1830    match certified_columns(bytes, num_threads, false) {
1831        Ok(Some(columns)) => return Ok(columns.block),
1832        Ok(None) => {}
1833        Err(err) => return Err(err),
1834    }
1835    let boundaries = frame_starts(bytes)?;
1836    if boundaries.is_empty() {
1837        return Ok(PositionBlock {
1838            n_frames: 0,
1839            n_atoms: 0,
1840            values: Vec::new(),
1841        });
1842    }
1843    let end0 = boundaries.get(1).copied().unwrap_or(bytes.len());
1844    let first = parse_span_located(bytes, boundaries[0], end0, 0)?;
1845    let n_atoms = first.positions.nrows();
1846    let n_frames = boundaries.len();
1847    let stride = n_atoms.saturating_mul(3);
1848    let mut values = vec![0.0_f64; n_frames.saturating_mul(stride)];
1849    if stride == 0 {
1850        return Ok(PositionBlock {
1851            n_frames,
1852            n_atoms,
1853            values,
1854        });
1855    }
1856    fill_position_spans(bytes, &boundaries, n_atoms, num_threads, &mut values)?;
1857    Ok(PositionBlock {
1858        n_frames,
1859        n_atoms,
1860        values,
1861    })
1862}
1863
1864fn fill_position_spans(
1865    bytes: &[u8],
1866    boundaries: &[usize],
1867    n_atoms: usize,
1868    num_threads: Option<usize>,
1869    values: &mut [f64],
1870) -> Result<(), error::LocatedParseError> {
1871    let stride = n_atoms * 3;
1872    let write_one = |i: usize, dest: &mut [f64]| {
1873        let start = boundaries[i];
1874        let end = boundaries.get(i + 1).copied().unwrap_or(bytes.len());
1875        let frame = parse_span_located(bytes, start, end, i as u64)?;
1876        copy_positions(&frame, dest).map_err(|error| {
1877            error::LocatedParseError::new(error, file_line_at_bytes(bytes, start), i as u64)
1878        })
1879    };
1880    #[cfg(feature = "parallel")]
1881    {
1882        let parallel = !matches!(num_threads, Some(n) if n <= 1);
1883        if parallel {
1884            use rayon::prelude::*;
1885            // The serial walk reports the earliest failing span. A pool
1886            // that stops on the first finished failure can hand back a
1887            // later span of the same file. `find_map_first` keeps the
1888            // earliest slot and still stops spans to its right.
1889            let mut run = || match values
1890                .par_chunks_mut(stride)
1891                .enumerate()
1892                .find_map_first(|(i, dest)| write_one(i, dest).err())
1893            {
1894                Some(err) => Err(err),
1895                None => Ok(()),
1896            };
1897            return match num_threads {
1898                Some(n) if n >= 2 => {
1899                    let pool = rayon::ThreadPoolBuilder::new()
1900                        .num_threads(n)
1901                        .build()
1902                        .map_err(|err| {
1903                            error::LocatedParseError::new(
1904                                error::ParseError::ValidationError(err.to_string()),
1905                                0,
1906                                0,
1907                            )
1908                        })?;
1909                    pool.install(run)
1910                }
1911                _ => run(),
1912            };
1913        }
1914    }
1915    #[cfg(not(feature = "parallel"))]
1916    {
1917        let _ = num_threads;
1918    }
1919    for (i, dest) in values.chunks_mut(stride).enumerate() {
1920        write_one(i, dest)?;
1921    }
1922    Ok(())
1923}
1924
1925/// [`frames_from_text`] for bytes that may not be UTF-8 yet.
1926pub fn frames_from_bytes(
1927    bytes: &[u8],
1928    num_threads: Option<usize>,
1929) -> Result<Vec<types::ConFrame>, error::ParseError> {
1930    #[cfg(feature = "parallel")]
1931    {
1932        if let Some(n) = num_threads.filter(|n| *n >= 2) {
1933            let parts = parse_frames_parallel_bytes(bytes, Some(n));
1934            let mut frames = Vec::with_capacity(parts.len());
1935            for r in parts {
1936                frames.push(r?);
1937            }
1938            return Ok(frames);
1939        }
1940    }
1941    #[cfg(not(feature = "parallel"))]
1942    {
1943        let _ = num_threads;
1944    }
1945    let mut frames = Vec::new();
1946    for r in ConFrameIterator::from_bytes(bytes) {
1947        frames.push(r?);
1948    }
1949    Ok(frames)
1950}
1951
1952/// [`frames_from_bytes`] that keeps the line and frame of the first error.
1953fn frames_from_bytes_located(
1954    bytes: &[u8],
1955    num_threads: Option<usize>,
1956) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
1957    #[cfg(feature = "parallel")]
1958    {
1959        if let Some(n) = num_threads.filter(|n| *n >= 2) {
1960            let timed = parse_bytes_timed(bytes, Some(n));
1961            return collect_located_slots(timed.slots, &timed.fail_lines);
1962        }
1963    }
1964    #[cfg(not(feature = "parallel"))]
1965    {
1966        let _ = num_threads;
1967    }
1968    collect_bytes_located(bytes)
1969}
1970
1971/// Serial byte parse. The cursor is the iterator's file line.
1972///
1973/// Species shells are built in this pass. Wrapping a finished column walk
1974/// in a second shell pass was slower on a million-atom frame, so
1975/// `read_positions` stays on the column walk and a full frame stays here.
1976fn collect_bytes_located(bytes: &[u8]) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
1977    let mut frames = Vec::new();
1978    let mut iter = ConFrameIterator::from_bytes(bytes);
1979    while let Some(item) = iter.next() {
1980        match item {
1981            Ok(frame) => frames.push(frame),
1982            Err(error) => {
1983                return Err(error::LocatedParseError::new(
1984                    error,
1985                    iter.lines.line_no(),
1986                    iter.frame_index(),
1987                ));
1988            }
1989        }
1990    }
1991    Ok(frames)
1992}
1993
1994/// First failed slot, with the file line its worker stored.
1995#[cfg(feature = "parallel")]
1996fn collect_located_slots(
1997    slots: Vec<Result<types::ConFrame, error::ParseError>>,
1998    fail_lines: &[(usize, u32)],
1999) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
2000    let mut frames = Vec::with_capacity(slots.len());
2001    for (index, part) in slots.into_iter().enumerate() {
2002        match part {
2003            Ok(frame) => frames.push(frame),
2004            Err(error) => {
2005                let line = fail_lines
2006                    .iter()
2007                    .find(|(slot, _)| *slot == index)
2008                    .map(|(_, line)| *line)
2009                    .unwrap_or(0);
2010                return Err(error::LocatedParseError::new(error, line, index as u64));
2011            }
2012        }
2013    }
2014    Ok(frames)
2015}
2016
2017#[cfg(feature = "parallel")]
2018fn span_end(boundaries: &[usize], index: usize, len: usize) -> usize {
2019    if index + 1 < boundaries.len() {
2020        boundaries[index + 1]
2021    } else {
2022        len
2023    }
2024}
2025
2026/// Pointer to slot storage shared by the workers that claim chunks.
2027///
2028/// Each claimed index is written by one thread, so the alias is the
2029/// partition. The pointer stays valid until those workers join.
2030#[cfg(feature = "parallel")]
2031#[derive(Copy, Clone)]
2032struct SlotWritePtr(*mut std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>);
2033
2034#[cfg(feature = "parallel")]
2035impl SlotWritePtr {
2036    /// # Safety
2037    /// `index` is in range and this call is the only write of that slot.
2038    unsafe fn write_slot(self, index: usize, value: Result<types::ConFrame, error::ParseError>) {
2039        unsafe {
2040            (*self.0.add(index)).write(value);
2041        }
2042    }
2043}
2044
2045// SAFETY: `SlotWritePtr` is copied into the claim loop, and that loop
2046// writes each slot index from exactly one worker. The allocation
2047// outlives the join.
2048#[cfg(feature = "parallel")]
2049unsafe impl Send for SlotWritePtr {}
2050
2051// SAFETY: same partition as `Send`. Workers do not read a slot another
2052// worker is writing.
2053#[cfg(feature = "parallel")]
2054unsafe impl Sync for SlotWritePtr {}
2055
2056/// Minor faults of the calling thread.
2057///
2058/// `RUSAGE_THREAD` is Linux. Other hosts report 0; the counter is
2059/// diagnostic and the parse result does not depend on it.
2060#[cfg(feature = "parallel")]
2061fn thread_minflt() -> i64 {
2062    // Diagnostic. Miri has no `getrusage`; the parse does not use this count.
2063    #[cfg(all(target_os = "linux", not(miri)))]
2064    {
2065        unsafe {
2066            let mut usage = std::mem::zeroed::<libc::rusage>();
2067            libc::getrusage(libc::RUSAGE_THREAD, &mut usage);
2068            usage.ru_minflt
2069        }
2070    }
2071    #[cfg(not(all(target_os = "linux", not(miri))))]
2072    {
2073        0
2074    }
2075}
2076
2077/// Hardware LLC-miss counter for this process.
2078///
2079/// `-1` means the guest refused the event. This KVM guest returns
2080/// `EPERM` for `perf_event_open`, so the parse explanation uses the
2081/// per-worker minor faults instead.
2082#[cfg(feature = "parallel")]
2083fn sample_llc_misses() -> i64 {
2084    // `perf_event_open` is Linux. `-1` is the existing "counter refused" value.
2085    // Miri cannot make that syscall.
2086    #[cfg(not(all(target_os = "linux", not(miri))))]
2087    {
2088        return -1;
2089    }
2090    #[cfg(all(target_os = "linux", not(miri)))]
2091    {
2092        #[repr(C)]
2093        struct PerfEventAttr {
2094            typ: u32,
2095            size: u32,
2096            config: u64,
2097            rest: [u64; 14],
2098        }
2099        let attr = PerfEventAttr {
2100            typ: 3,
2101            size: std::mem::size_of::<PerfEventAttr>() as u32,
2102            // PERF_COUNT_HW_CACHE_LL, OP_READ, RESULT_MISS.
2103            config: 2 | (1 << 16),
2104            rest: [0; 14],
2105        };
2106        let fd = unsafe {
2107            libc::syscall(
2108                libc::SYS_perf_event_open,
2109                &attr as *const PerfEventAttr,
2110                0i32,
2111                -1i32,
2112                -1i32,
2113                0u64,
2114            )
2115        };
2116        if fd < 0 {
2117            return -1;
2118        }
2119        let fd = fd as libc::c_int;
2120        let mut value = 0u64;
2121        let n = unsafe { libc::read(fd, &mut value as *mut u64 as *mut libc::c_void, 8) };
2122        unsafe {
2123            libc::close(fd);
2124        }
2125        if n == 8 { value as i64 } else { -1 }
2126    }
2127}
2128
2129/// Pages that do not sit on node 0.
2130///
2131/// A guest with one memory node has no remote pages. The count is 0
2132/// in that case. A wider node list sums the pages `numa_maps` records
2133/// on every node other than 0.
2134#[cfg(feature = "parallel")]
2135fn numa_remote_pages() -> i64 {
2136    let online = std::fs::read_to_string("/sys/devices/system/node/online").unwrap_or_default();
2137    let online = online.trim();
2138    let nodes = if let Some((start, end)) = online.split_once('-') {
2139        let start: i64 = start.parse().unwrap_or(0);
2140        let end: i64 = end.parse().unwrap_or(0);
2141        end - start + 1
2142    } else {
2143        1
2144    };
2145    if nodes <= 1 {
2146        return 0;
2147    }
2148    let text = std::fs::read_to_string("/proc/self/numa_maps").unwrap_or_default();
2149    let mut remote = 0i64;
2150    for line in text.lines() {
2151        for token in line.split_whitespace() {
2152            if let Some(rest) = token.strip_prefix('N')
2153                && let Some((node, pages)) = rest.split_once('=')
2154                && node != "0"
2155            {
2156                remote += pages.parse::<i64>().unwrap_or(0);
2157            }
2158        }
2159    }
2160    remote
2161}
2162
2163/// True when `boundaries` is `0, span, 2*span, ...`.
2164#[cfg(feature = "parallel")]
2165fn equal_frame_stride(boundaries: &[usize]) -> bool {
2166    if boundaries.len() < 2 {
2167        return false;
2168    }
2169    let span = boundaries[1] - boundaries[0];
2170    if span == 0 || boundaries[0] != 0 {
2171        return false;
2172    }
2173    boundaries
2174        .iter()
2175        .enumerate()
2176        .all(|(index, start)| *start == index * span)
2177}
2178
2179/// One contiguous frame run per worker.
2180#[cfg(feature = "parallel")]
2181fn one_range_per_worker(n: usize, workers: usize) -> Vec<(usize, usize)> {
2182    let workers = workers.max(1).min(n.max(1));
2183    let mut ranges = Vec::with_capacity(workers);
2184    let mut start = 0usize;
2185    for worker in 0..workers {
2186        let end = if worker + 1 == workers {
2187            n
2188        } else {
2189            ((worker as u128 + 1) * (n as u128) / workers as u128) as usize
2190        };
2191        if end > start {
2192            ranges.push((start, end));
2193            start = end;
2194        }
2195    }
2196    ranges
2197}
2198
2199/// Contiguous frame ranges that cover `boundaries` and split the file
2200/// into similar byte lengths.
2201///
2202/// There are up to four ranges per worker, and never more ranges than
2203/// frames. Each range holds at least one frame. The cuts stay on frame
2204/// starts, so a worker parses a whole frame.
2205#[cfg(feature = "parallel")]
2206fn byte_balanced_ranges(boundaries: &[usize], len: usize, workers: usize) -> Vec<(usize, usize)> {
2207    let n = boundaries.len();
2208    if n == 0 {
2209        return Vec::new();
2210    }
2211    let workers = workers.max(1).min(n);
2212    let chunks = workers.saturating_mul(4).clamp(workers, n);
2213    let origin = boundaries[0];
2214    let total = len.saturating_sub(origin).max(1) as u128;
2215    let mut ranges = Vec::with_capacity(chunks);
2216    let mut start = 0usize;
2217    for chunk in 0..chunks {
2218        if start >= n {
2219            break;
2220        }
2221        let end = if chunk + 1 == chunks {
2222            n
2223        } else {
2224            let target = origin + ((total * (chunk as u128 + 1)) / chunks as u128) as usize;
2225            let mut idx = if start + 1 < n {
2226                boundaries[start + 1..].partition_point(|&b| b < target) + start + 1
2227            } else {
2228                n
2229            };
2230            let min_end = start + 1;
2231            let reserved = chunks - chunk - 1;
2232            let max_end = n.saturating_sub(reserved);
2233            if max_end >= min_end {
2234                idx = idx.clamp(min_end, max_end);
2235            } else {
2236                idx = min_end.min(n);
2237            }
2238            idx
2239        };
2240        if end > start {
2241            ranges.push((start, end));
2242            start = end;
2243        }
2244    }
2245    if start < n {
2246        match ranges.last_mut() {
2247            Some(last) => last.1 = n,
2248            None => ranges.push((start, n)),
2249        }
2250    }
2251    ranges
2252}
2253
2254/// Parse each frame into a slot allocated before any worker runs.
2255///
2256/// Workers claim chunk indexes from an atomic counter and write only
2257/// those slots. The returned vector is that slot storage, in boundary
2258/// order, which is the serial iterator order. `workers < 2` parses on
2259/// the calling thread.
2260#[cfg(feature = "parallel")]
2261fn parse_into_slots<F>(
2262    bytes: &[u8],
2263    len: usize,
2264    boundaries: &[usize],
2265    workers: usize,
2266    parse_span: F,
2267) -> Vec<Result<types::ConFrame, error::ParseError>>
2268where
2269    F: Fn(usize, usize) -> Result<types::ConFrame, error::ParseError> + Sync,
2270{
2271    parse_into_slots_timed(bytes, len, boundaries, workers, parse_span).slots
2272}
2273
2274/// Slot allocation and the worker parse, timed apart.
2275#[cfg(feature = "parallel")]
2276struct SlotParse {
2277    slots: Vec<Result<types::ConFrame, error::ParseError>>,
2278    alloc_s: f64,
2279    work_s: f64,
2280    sum_s: f64,
2281    max_s: f64,
2282    /// One entry per worker range, in worker order. This is CPU time.
2283    chunk_s: Vec<f64>,
2284    /// Minor faults on each worker during its range.
2285    worker_minflt: Vec<i64>,
2286    /// Frames in each worker's share.
2287    share_frames: Vec<u64>,
2288    /// File bytes in each worker's share.
2289    share_bytes: Vec<u64>,
2290    /// `(slot, file line)` for each parse that failed. The line is the
2291    /// cursor of that parse, not a later reread.
2292    fail_lines: Vec<(usize, u32)>,
2293}
2294
2295/// Slot allocation and the worker parse, timed apart.
2296///
2297/// `alloc_s` is the slot vector. `work_s` is the parse wall. `sum_s` is
2298/// the sum of per-chunk parse times and `max_s` is the slowest chunk.
2299/// `chunk_s` keeps every chunk. `workers < 2` still reserves the slot
2300/// vector before the calling thread fills it, and that one chunk's time
2301/// is the sum, the max, and the only entry.
2302#[cfg(feature = "parallel")]
2303fn parse_into_slots_timed<F>(
2304    bytes: &[u8],
2305    len: usize,
2306    boundaries: &[usize],
2307    workers: usize,
2308    parse_span: F,
2309) -> SlotParse
2310where
2311    F: Fn(usize, usize) -> Result<types::ConFrame, error::ParseError> + Sync,
2312{
2313    let n = boundaries.len();
2314    if n == 0 {
2315        return SlotParse {
2316            slots: Vec::new(),
2317            alloc_s: 0.0,
2318            work_s: 0.0,
2319            sum_s: 0.0,
2320            max_s: 0.0,
2321            chunk_s: Vec::new(),
2322            worker_minflt: Vec::new(),
2323            share_frames: Vec::new(),
2324            share_bytes: Vec::new(),
2325            fail_lines: Vec::new(),
2326        };
2327    }
2328    if workers < 2 {
2329        let t_alloc = std::time::Instant::now();
2330        let mut slots = Vec::with_capacity(n);
2331        let alloc_s = t_alloc.elapsed().as_secs_f64();
2332        let flt0 = thread_minflt();
2333        let t_work = std::time::Instant::now();
2334        for i in 0..n {
2335            slots.push(parse_span(boundaries[i], span_end(boundaries, i, len)));
2336        }
2337        let work_s = t_work.elapsed().as_secs_f64();
2338        let worker_minflt = vec![thread_minflt().saturating_sub(flt0)];
2339        return SlotParse {
2340            slots,
2341            alloc_s,
2342            work_s,
2343            sum_s: work_s,
2344            max_s: work_s,
2345            chunk_s: vec![work_s],
2346            worker_minflt,
2347            share_frames: vec![n as u64],
2348            share_bytes: vec![len as u64],
2349            fail_lines: Vec::new(),
2350        };
2351    }
2352    let t_alloc = std::time::Instant::now();
2353    let mut slots = take_slot_buf(n);
2354    let alloc_s = t_alloc.elapsed().as_secs_f64();
2355    // Equal frames are one range per worker. Each range prestashes its
2356    // coordinate shells and drops that stash at the end, so four ranges
2357    // on one worker allocate the same shells four times. Unequal frames
2358    // keep several byte-balanced ranges so a long frame can be stolen.
2359    let ranges = if equal_frame_stride(boundaries) {
2360        one_range_per_worker(n, workers)
2361    } else {
2362        byte_balanced_ranges(boundaries, len, workers)
2363    };
2364    let chunk_ns: Vec<std::sync::atomic::AtomicU64> = (0..ranges.len())
2365        .map(|_| std::sync::atomic::AtomicU64::new(0))
2366        .collect();
2367    let chunk_flt: Vec<std::sync::atomic::AtomicI64> = (0..ranges.len())
2368        .map(|_| std::sync::atomic::AtomicI64::new(0))
2369        .collect();
2370    let slots_ptr = SlotWritePtr(slots.as_mut_ptr());
2371    let fail_lines = std::sync::Mutex::new(Vec::<(usize, u32)>::new());
2372    let t_work = std::time::Instant::now();
2373    {
2374        let parse_range = |range_index: usize, start: usize, end: usize| {
2375            if start >= end {
2376                return;
2377            }
2378            let flt0 = thread_minflt();
2379            let t_chunk = std::time::Instant::now();
2380            let byte_start = boundaries[start];
2381            let byte_end = span_end(boundaries, end - 1, len);
2382            let wide = end - start == 1 && byte_end.saturating_sub(byte_start) >= (256 << 10);
2383            if wide {
2384                crate::parser::set_wide_workers(workers);
2385            }
2386            let mut iter = ConFrameIterator::from_bytes(&bytes[byte_start..byte_end]);
2387            OWNED_RANGES.with(|ranges| {
2388                let generation = PARSE_GEN.load(std::sync::atomic::Ordering::Relaxed);
2389                let mut owned = ranges.borrow_mut();
2390                if owned.first().map(|(seen, _, _)| *seen) != Some(generation) {
2391                    owned.clear();
2392                }
2393                owned.push((generation, start, end));
2394            });
2395            let mut known_atoms = 0usize;
2396            for i in start..end {
2397                let frame = match iter.next() {
2398                    Some(Ok(frame)) => {
2399                        if known_atoms == 0 {
2400                            known_atoms = frame.atom_data.len();
2401                            let left = end - i - 1;
2402                            if known_atoms > 0 && left > 0 {
2403                                crate::parser::prestash_coord_shells(left, known_atoms);
2404                            }
2405                        }
2406                        Ok(frame)
2407                    }
2408                    Some(Err(error)) => {
2409                        let line = share_cursor(iter.lines.line_no(), bytes, byte_start);
2410                        fail_lines.lock().expect("fail lines").push((i, line));
2411                        Err(error)
2412                    }
2413                    None => {
2414                        let line = share_cursor(iter.lines.line_no(), bytes, byte_start);
2415                        fail_lines.lock().expect("fail lines").push((i, line));
2416                        Err(error::ParseError::IncompleteFrame)
2417                    }
2418                };
2419                // SAFETY: this range owns `start..end`, so `i` has one
2420                // writer. `slots` outlives the join.
2421                unsafe {
2422                    slots_ptr.write_slot(i, frame);
2423                }
2424            }
2425            crate::parser::clear_coord_shells();
2426            if wide {
2427                crate::parser::set_wide_workers(1);
2428            }
2429            chunk_ns[range_index].store(
2430                t_chunk.elapsed().as_nanos() as u64,
2431                std::sync::atomic::Ordering::Relaxed,
2432            );
2433            chunk_flt[range_index].store(
2434                thread_minflt().saturating_sub(flt0),
2435                std::sync::atomic::Ordering::Relaxed,
2436            );
2437        };
2438        let run_ranges = || {
2439            use rayon::prelude::*;
2440            ranges
2441                .par_iter()
2442                .enumerate()
2443                .for_each(|(range_index, &(start, end))| {
2444                    parse_range(range_index, start, end);
2445                });
2446        };
2447        if rayon::current_thread_index().is_some() {
2448            run_ranges();
2449        } else {
2450            match rayon::ThreadPoolBuilder::new().num_threads(workers).build() {
2451                Ok(pool) => pool.install(run_ranges),
2452                Err(_) => {
2453                    for (range_index, &(start, end)) in ranges.iter().enumerate() {
2454                        parse_range(range_index, start, end);
2455                    }
2456                }
2457            }
2458        }
2459    }
2460    let work_s = t_work.elapsed().as_secs_f64();
2461    let mut worker_sum_s = 0.0_f64;
2462    let mut worker_max_s = 0.0_f64;
2463    let mut chunk_s = Vec::new();
2464    let mut worker_minflt = Vec::new();
2465    let mut share_frames = Vec::new();
2466    let mut share_bytes = Vec::new();
2467    for (range_index, &(start, end)) in ranges.iter().enumerate() {
2468        if start >= end {
2469            continue;
2470        }
2471        let seconds =
2472            chunk_ns[range_index].load(std::sync::atomic::Ordering::Relaxed) as f64 / 1.0e9;
2473        chunk_s.push(seconds);
2474        worker_sum_s += seconds;
2475        worker_max_s = worker_max_s.max(seconds);
2476        worker_minflt.push(chunk_flt[range_index].load(std::sync::atomic::Ordering::Relaxed));
2477        share_frames.push((end - start) as u64);
2478        let nbytes = span_end(boundaries, end - 1, len).saturating_sub(boundaries[start]);
2479        share_bytes.push(nbytes as u64);
2480    }
2481    // Each index was written once. Reinterpreting the buffer avoids a
2482    // second serial walk of the frame shells after the workers join.
2483    let slots = unsafe {
2484        let mut raw = std::mem::ManuallyDrop::new(slots);
2485        Vec::from_raw_parts(
2486            raw.as_mut_ptr() as *mut Result<types::ConFrame, error::ParseError>,
2487            n,
2488            raw.capacity(),
2489        )
2490    };
2491    let fail_lines = fail_lines.into_inner().unwrap_or_default();
2492    SlotParse {
2493        slots,
2494        alloc_s,
2495        work_s,
2496        sum_s: worker_sum_s,
2497        max_s: worker_max_s,
2498        chunk_s,
2499        worker_minflt,
2500        share_frames,
2501        share_bytes,
2502        fail_lines,
2503    }
2504}
2505
2506#[cfg(feature = "parallel")]
2507fn parse_byte_span(
2508    bytes: &[u8],
2509    start: usize,
2510    end: usize,
2511) -> Result<types::ConFrame, error::ParseError> {
2512    let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
2513    match iter.next() {
2514        Some(result) => result,
2515        None => Err(error::ParseError::IncompleteFrame),
2516    }
2517}
2518
2519#[cfg(feature = "parallel")]
2520fn parse_text_span(
2521    text: &str,
2522    start: usize,
2523    end: usize,
2524) -> Result<types::ConFrame, error::ParseError> {
2525    let mut iter = ConFrameIterator::new(&text[start..end]);
2526    match iter.next() {
2527        Some(result) => result,
2528        None => Err(error::ParseError::IncompleteFrame),
2529    }
2530}
2531
2532#[cfg(feature = "parallel")]
2533struct TimedParse {
2534    slots: Vec<Result<types::ConFrame, error::ParseError>>,
2535    /// Still alive so later phases of this read use the same workers.
2536    /// `None` when the call stayed on the calling thread.
2537    pool: Option<std::sync::Arc<rayon::ThreadPool>>,
2538    pool_s: f64,
2539    /// Zero while `pool` is still alive. The owner times the join.
2540    pool_join_s: f64,
2541    scan_s: f64,
2542    scan_minflt: i64,
2543    slot_alloc_s: f64,
2544    worker_parse_s: f64,
2545    worker_sum_s: f64,
2546    worker_max_s: f64,
2547    chunk_s: Vec<f64>,
2548    worker_minflt: Vec<i64>,
2549    share_frames: Vec<u64>,
2550    share_bytes: Vec<u64>,
2551    /// `(slot, file line)` recorded by the worker that failed.
2552    fail_lines: Vec<(usize, u32)>,
2553    llc_misses: i64,
2554    numa_remote: i64,
2555    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`].
2556    scan_kind: u8,
2557}
2558
2559/// Keep freed parse pages in the heap.
2560///
2561/// Eight workers otherwise return those pages to the kernel, and the
2562/// next file faults them again. One worker already keeps a smaller set.
2563/// The same policy on every width leaves the parse fault count flat.
2564#[cfg(feature = "parallel")]
2565fn keep_parse_pages() {
2566    // glibc `M_MMAP_MAX` and `M_TRIM_THRESHOLD`. macOS and Windows have
2567    // no `mallopt`; leaving the pages to the system allocator is enough
2568    // for the parse to be correct.
2569    #[cfg(all(target_os = "linux", not(miri)))]
2570    unsafe {
2571        libc::mallopt(-4, 0);
2572        libc::mallopt(-1, i32::MAX);
2573    }
2574}
2575
2576/// Slot storage kept between parses.
2577///
2578/// Eight workers write one `ConFrame` shell per frame into this buffer.
2579/// A fresh buffer is a new mapping, and those writes are the repeating
2580/// minor faults. The next parse reuses the same pages.
2581#[cfg(feature = "parallel")]
2582fn take_slot_buf(
2583    n: usize,
2584) -> Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>> {
2585    let mut buf = slot_cache()
2586        .lock()
2587        .map(|mut slot| std::mem::take(&mut *slot))
2588        .unwrap_or_default();
2589    if buf.capacity() < n {
2590        buf = Vec::with_capacity(n);
2591    }
2592    unsafe { buf.set_len(n) };
2593    buf
2594}
2595
2596#[cfg(feature = "parallel")]
2597fn slot_cache()
2598-> &'static std::sync::Mutex<Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>>>
2599{
2600    static CACHE: std::sync::Mutex<
2601        Vec<std::mem::MaybeUninit<Result<types::ConFrame, error::ParseError>>>,
2602    > = std::sync::Mutex::new(Vec::new());
2603    &CACHE
2604}
2605
2606#[cfg(feature = "parallel")]
2607thread_local! {
2608    static OWNED_RANGES: std::cell::RefCell<Vec<(usize, usize, usize)>> =
2609        const { std::cell::RefCell::new(Vec::new()) };
2610}
2611
2612/// Bumped at the start of each parallel parse. Workers record it with
2613/// their frame ranges. Releasing frames only frees ranges from this
2614/// generation, so a parse does not have to wake every pool thread first.
2615#[cfg(feature = "parallel")]
2616static PARSE_GEN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
2617
2618/// Drop `frames` on the threads that allocated them.
2619///
2620/// A parallel parse hands each worker a contiguous range. Freeing those
2621/// frames on the same workers keeps the pages in that worker's heap for
2622/// the next file. `workers < 2` drops on the calling thread.
2623pub fn release_parsed_frames(frames: Vec<types::ConFrame>, workers: usize) {
2624    #[cfg(feature = "parallel")]
2625    {
2626        if workers >= 2 {
2627            if let Some(pool) = shared_pool(workers) {
2628                let mut slots: Vec<Option<types::ConFrame>> =
2629                    frames.into_iter().map(Some).collect();
2630                let ptr = slots.as_mut_ptr() as usize;
2631                let len = slots.len();
2632                let generation = PARSE_GEN.load(std::sync::atomic::Ordering::Relaxed);
2633                pool.broadcast(|_| {
2634                    let ranges =
2635                        OWNED_RANGES.with(|ranges| std::mem::take(&mut *ranges.borrow_mut()));
2636                    for (seen, start, end) in ranges {
2637                        if seen != generation {
2638                            continue;
2639                        }
2640                        let end = end.min(len);
2641                        let start = start.min(end);
2642                        for index in start..end {
2643                            unsafe {
2644                                drop((*((ptr as *mut Option<types::ConFrame>).add(index))).take());
2645                            }
2646                        }
2647                    }
2648                });
2649                drop(slots);
2650                return;
2651            }
2652        }
2653    }
2654    let _ = workers;
2655    drop(frames);
2656}
2657
2658/// One pool per worker count, kept for the life of the process.
2659///
2660/// A fresh pool on every file made each worker fault its frames again.
2661/// Reusing the workers lets the allocator hand back the pages just freed.
2662#[cfg(feature = "parallel")]
2663#[allow(clippy::type_complexity)]
2664fn shared_pool(workers: usize) -> Option<std::sync::Arc<rayon::ThreadPool>> {
2665    use std::sync::{Arc, Mutex, OnceLock};
2666    static CACHE: OnceLock<Mutex<Vec<(usize, Arc<rayon::ThreadPool>)>>> = OnceLock::new();
2667    let cache = CACHE.get_or_init(|| Mutex::new(Vec::new()));
2668    let mut guard = cache.lock().ok()?;
2669    if let Some((_, pool)) = guard.iter().find(|(count, _)| *count == workers) {
2670        return Some(Arc::clone(pool));
2671    }
2672    keep_parse_pages();
2673    let pool = Arc::new(
2674        rayon::ThreadPoolBuilder::new()
2675            .num_threads(workers)
2676            .build()
2677            .ok()?,
2678    );
2679    guard.push((workers, Arc::clone(&pool)));
2680    Some(pool)
2681}
2682
2683/// Minor faults of this process.
2684///
2685/// `getrusage` is Unix. Windows reports 0.
2686#[cfg(feature = "parallel")]
2687pub fn process_minflt() -> i64 {
2688    // Diagnostic. Miri has no `getrusage`; the parse does not use this count.
2689    #[cfg(all(unix, not(miri)))]
2690    {
2691        unsafe {
2692            let mut usage = std::mem::zeroed::<libc::rusage>();
2693            libc::getrusage(libc::RUSAGE_SELF, &mut usage);
2694            usage.ru_minflt
2695        }
2696    }
2697    #[cfg(not(all(unix, not(miri))))]
2698    {
2699        0
2700    }
2701}
2702
2703/// Page size from `sysconf`, with the POSIX page as the fallback.
2704#[cfg(feature = "parallel")]
2705fn host_page_size() -> usize {
2706    #[cfg(all(unix, not(miri)))]
2707    {
2708        let raw = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
2709        if raw > 0 { raw as usize } else { 4096 }
2710    }
2711    #[cfg(not(all(unix, not(miri))))]
2712    {
2713        4096
2714    }
2715}
2716
2717/// Fault one word on each page of `values`.
2718///
2719/// The page count is split by the worker count. The step is the host
2720/// page, so the slice a worker faults is its own. No extra chunk size.
2721#[cfg(feature = "parallel")]
2722fn first_touch_f64(values: &mut [f64], workers: usize) {
2723    use rayon::prelude::*;
2724    if values.is_empty() || workers < 2 {
2725        return;
2726    }
2727    let page = host_page_size().max(std::mem::size_of::<f64>());
2728    let step = (page / std::mem::size_of::<f64>()).max(1);
2729    let pages = values.len().div_ceil(step);
2730    let share = pages.div_ceil(workers);
2731    if rayon::current_thread_index().is_some() {
2732        // One chunk per page. The installed pool hands each page to one
2733        // worker, so that worker faults its own slice.
2734        values.par_chunks_mut(step).for_each(|chunk| {
2735            if let Some(slot) = chunk.first_mut() {
2736                *slot = 0.0;
2737            }
2738        });
2739        return;
2740    }
2741    std::thread::scope(|scope| {
2742        let mut rest = values;
2743        for worker in 0..workers {
2744            if rest.is_empty() {
2745                break;
2746            }
2747            let pages_here = share.min(rest.len().div_ceil(step));
2748            let take = (pages_here * step).min(rest.len());
2749            let (head, tail) = rest.split_at_mut(take);
2750            rest = tail;
2751            let _ = worker;
2752            scope.spawn(move || {
2753                let mut at = 0;
2754                while at < head.len() {
2755                    head[at] = 0.0;
2756                    at += step;
2757                }
2758            });
2759        }
2760    });
2761}
2762
2763/// Move every `Ok` frame on the read's pool.
2764///
2765/// A serial walk of the slot vector grows with the frame count. The
2766/// pool that parsed the frames does this move. One thread still walks
2767/// the slots when that pool is absent.
2768#[cfg(feature = "parallel")]
2769fn finish_results(
2770    pool: Option<&rayon::ThreadPool>,
2771    slots: Vec<Result<types::ConFrame, error::ParseError>>,
2772) -> Result<Vec<types::ConFrame>, error::ParseError> {
2773    if slots.len() < 2 || pool.is_none() {
2774        let mut frames = Vec::with_capacity(slots.len());
2775        for part in slots {
2776            frames.push(part?);
2777        }
2778        return Ok(frames);
2779    }
2780    let pool = pool.expect("pool");
2781    pool.install(|| {
2782        use rayon::prelude::*;
2783        if let Some(index) = slots.par_iter().position_first(|item| item.is_err()) {
2784            return Err(match slots.into_iter().nth(index) {
2785                Some(Err(err)) => err,
2786                _ => error::ParseError::IncompleteFrame,
2787            });
2788        }
2789        Ok(slots
2790            .into_par_iter()
2791            .map(|item| item.expect("ok"))
2792            .collect())
2793    })
2794}
2795
2796/// Header walk on the calling thread.
2797pub const SCAN_SERIAL: u8 = 0;
2798/// Newline index in chunks, then a header walk on the calling thread.
2799///
2800/// Frames that do not share one byte length take this path. The newline
2801/// bytes are split across the pool. The header walk is not, because a
2802/// later frame start is the end of the skip, and that end is not known
2803/// until the skip runs.
2804pub const SCAN_INDEXED: u8 = 1;
2805/// Equal-length frames. Each worker boundary is checked.
2806pub const SCAN_EQUAL_SPAN: u8 = 2;
2807
2808/// Frame starts when every frame occupies the same number of bytes.
2809///
2810/// The first frame sets the span. Each worker boundary is skipped on
2811/// its own slice. A span that does not tile the
2812/// buffer, or a skip that does not land on the next span, returns
2813/// `None` and the caller keeps the serial walk. The starts are
2814/// `0, span, 2*span, ...` and match [`collect_frame_starts`].
2815#[cfg(feature = "parallel")]
2816#[cfg_attr(not(test), allow(dead_code))]
2817fn equal_span_boundaries(bytes: &[u8], workers: usize) -> Option<Vec<usize>> {
2818    if workers < 2 || bytes.len() < 2 {
2819        return None;
2820    }
2821    let mut first = ConFrameIterator::from_bytes(bytes);
2822    match first.forward_fast() {
2823        Some(Ok(())) => {}
2824        _ => return None,
2825    }
2826    let span = first.lines.pos;
2827    if span < 2 {
2828        return None;
2829    }
2830    let n = bytes.len() / span;
2831    if n < 2 {
2832        return None;
2833    }
2834    // Bytes after the last full span are a tail, not another frame.
2835    // The serial walk ignores a whitespace tail.
2836    let tiled = n * span;
2837    if bytes[tiled..].iter().any(|b| !b.is_ascii_whitespace()) {
2838        return None;
2839    }
2840    // Equal byte ranges. A cut that falls inside a frame resyncs to the
2841    // next frame start, and only that header is checked. The checks stay
2842    // on this worker: a nested pool wake was larger than the checks.
2843    let byte_share = tiled.div_ceil(workers);
2844    for worker in 0..workers {
2845        let byte_cut = worker.saturating_mul(byte_share).min(tiled);
2846        if byte_cut >= tiled {
2847            continue;
2848        }
2849        let index = if worker == 0 {
2850            0
2851        } else {
2852            byte_cut.div_ceil(span).min(n - 1)
2853        };
2854        let start = index.saturating_mul(span);
2855        if start >= tiled {
2856            continue;
2857        }
2858        let end = (start + span).min(tiled);
2859        let mut iter = ConFrameIterator::from_bytes(&bytes[start..end]);
2860        match iter.forward_fast() {
2861            Some(Ok(())) if iter.lines.pos == end - start => {}
2862            _ => return None,
2863        }
2864    }
2865    Some(arithmetic_starts(span, n))
2866}
2867
2868/// Starts `0, span, 2*span, ...` from a buffer kept between files.
2869///
2870/// Building that buffer from a fresh allocation stalled for milliseconds
2871/// after a large free. The next file fills the same pages.
2872#[cfg(feature = "parallel")]
2873static START_CACHE: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
2874
2875#[cfg(feature = "parallel")]
2876#[cfg_attr(not(test), allow(dead_code))]
2877fn arithmetic_starts(span: usize, n: usize) -> Vec<usize> {
2878    let mut starts = START_CACHE
2879        .lock()
2880        .map(|mut slot| std::mem::take(&mut *slot))
2881        .unwrap_or_default();
2882    if starts.len() != n {
2883        starts.resize(n, 0);
2884    }
2885    for (index, slot) in starts.iter_mut().enumerate() {
2886        *slot = index * span;
2887    }
2888    starts
2889}
2890
2891#[cfg(feature = "parallel")]
2892fn recycle_starts(starts: Vec<usize>) {
2893    if let Ok(mut slot) = START_CACHE.lock()
2894        && starts.capacity() >= slot.capacity()
2895    {
2896        *slot = starts;
2897    }
2898}
2899
2900#[cfg(feature = "parallel")]
2901fn parse_frames_parallel_bytes(
2902    bytes: &[u8],
2903    num_threads: Option<usize>,
2904) -> Vec<Result<types::ConFrame, error::ParseError>> {
2905    parse_bytes_timed(bytes, num_threads).slots
2906}
2907
2908/// Frame starts for a pool of `workers`.
2909///
2910/// Equal byte spans stay arithmetic. Anything else builds the newline
2911/// index on the pool and walks headers by line count, so the walk does
2912/// not reread atom rows. One worker keeps the serial byte walk.
2913#[cfg(feature = "parallel")]
2914fn parallel_boundaries(bytes: &[u8], workers: usize) -> (Vec<usize>, u8) {
2915    if workers >= 2 {
2916        if let Some(bounds) = equal_span_boundaries(bytes, workers) {
2917            return (bounds, SCAN_EQUAL_SPAN);
2918        }
2919        let newlines = newline_positions(bytes, workers);
2920        let mut scanner = ConFrameIterator::from_bytes(bytes);
2921        scanner.lines.index_line = 0;
2922        scanner.lines.newlines = Some(newlines);
2923        return (collect_frame_starts(scanner, true), SCAN_INDEXED);
2924    }
2925    (
2926        collect_frame_starts(ConFrameIterator::from_bytes(bytes), true),
2927        SCAN_SERIAL,
2928    )
2929}
2930
2931/// Scan, slot allocation, and worker parse of `bytes`.
2932///
2933/// `Some(n)` with `n >= 2` uses a pool of that size. Every other count
2934/// stays on the calling thread. `None` follows [`scan_thread_count`].
2935/// The pool build is `pool_s`. The pool join is `pool_join_s`.
2936/// Neither one is inside the scan or the parse.
2937#[cfg(feature = "parallel")]
2938fn parse_bytes_timed(bytes: &[u8], num_threads: Option<usize>) -> TimedParse {
2939    let parse_workers = match num_threads {
2940        Some(n) if n >= 2 => n,
2941        _ => 1,
2942    };
2943    let run = || {
2944        let fault0 = process_minflt();
2945        let t_scan = std::time::Instant::now();
2946        // Equal-length frames skip the serial header walk. Anything else
2947        // still walks headers on this thread. Workers then parse the
2948        // spans in file order.
2949        let (boundaries, scan_kind) = parallel_boundaries(bytes, parse_workers);
2950        let scan_s = t_scan.elapsed().as_secs_f64();
2951        let scan_minflt = process_minflt().saturating_sub(fault0);
2952        let parsed = parse_into_slots_timed(
2953            bytes,
2954            bytes.len(),
2955            &boundaries,
2956            parse_workers,
2957            |start, end| parse_byte_span(bytes, start, end),
2958        );
2959        recycle_starts(boundaries);
2960        (parsed, scan_s, scan_kind, scan_minflt)
2961    };
2962    let (pool_s, pool, packed) = match num_threads {
2963        None => (0.0, None, run()),
2964        Some(n) if n <= 1 => {
2965            let _ = n;
2966            keep_parse_pages();
2967            let fault0 = process_minflt();
2968            let t_scan = std::time::Instant::now();
2969            let boundaries = collect_frame_starts(ConFrameIterator::from_bytes(bytes), true);
2970            let scan_s = t_scan.elapsed().as_secs_f64();
2971            let scan_minflt = process_minflt().saturating_sub(fault0);
2972            let parsed =
2973                parse_into_slots_timed(bytes, bytes.len(), &boundaries, 1, |start, end| {
2974                    parse_byte_span(bytes, start, end)
2975                });
2976            (0.0, None, (parsed, scan_s, SCAN_SERIAL, scan_minflt))
2977        }
2978        Some(n) => {
2979            let t_pool = std::time::Instant::now();
2980            match shared_pool(n) {
2981                Some(pool) => {
2982                    let built = t_pool.elapsed().as_secs_f64();
2983                    PARSE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2984                    let t_wake = std::time::Instant::now();
2985                    let (wake, body) = pool.install(|| {
2986                        let wake = t_wake.elapsed().as_secs_f64();
2987                        (wake, run())
2988                    });
2989                    // The same workers serve later files of this width.
2990                    // Their freed blocks stay warm for the next parse.
2991                    (built + wake, Some(pool), body)
2992                }
2993                None => (t_pool.elapsed().as_secs_f64(), None, run()),
2994            }
2995        }
2996    };
2997    let (parsed, scan_s, scan_kind, scan_minflt) = packed;
2998    TimedParse {
2999        slots: parsed.slots,
3000        pool,
3001        pool_s,
3002        pool_join_s: 0.0,
3003        scan_s,
3004        scan_minflt,
3005        slot_alloc_s: parsed.alloc_s,
3006        worker_parse_s: parsed.work_s,
3007        worker_sum_s: parsed.sum_s,
3008        worker_max_s: parsed.max_s,
3009        chunk_s: parsed.chunk_s,
3010        worker_minflt: parsed.worker_minflt,
3011        share_frames: parsed.share_frames,
3012        share_bytes: parsed.share_bytes,
3013        fail_lines: parsed.fail_lines,
3014        llc_misses: sample_llc_misses(),
3015        numa_remote: numa_remote_pages(),
3016        scan_kind,
3017    }
3018}
3019
3020/// Count frames without building atom payloads (uses [`ConFrameIterator::forward_fast`]
3021/// when possible, else [`ConFrameIterator::forward`]).
3022///
3023/// Prefer this over `read_all_frames(...).len()` when only the frame count is needed.
3024/// A rejected frame is [`LoadError::Parse`] with that frame's file line and
3025/// 0-based index, including when the failure is after the first frame.
3026pub fn count_frames(path: &Path) -> Result<usize, LoadError> {
3027    let contents = load_text(path)?;
3028    let text = utf8_text(contents.as_bytes(), true)?;
3029    let mut n = 0usize;
3030    let mut iter = ConFrameIterator::new(text);
3031    loop {
3032        match iter.forward_fast() {
3033            Some(Ok(())) => n += 1,
3034            Some(Err(e)) => {
3035                return Err(LoadError::Parse(error::LocatedParseError::new(
3036                    e,
3037                    iter.lines.line_no(),
3038                    iter.frame_index(),
3039                )));
3040            }
3041            None => break,
3042        }
3043    }
3044    Ok(n)
3045}
3046
3047/// Reads only the first frame from a file.
3048///
3049/// More efficient than `read_all_frames` for single-frame access because it
3050/// stops parsing after the first frame rather than collecting all of them.
3051///
3052/// ```
3053/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
3054/// use readcon_core::iterators::read_first_frame;
3055/// let frame = read_first_frame(std::path::Path::new("resources/test/tiny_cuh2.con"))?;
3056/// println!("{:?} {}", frame.header.boxl, frame.atom_data.len());
3057/// assert!(!frame.atom_data.is_empty());
3058/// # Ok(())
3059/// # }
3060/// ```
3061pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
3062    let contents = crate::compression::read_file_contents(path)?;
3063    let text = contents.as_str()?;
3064    let mut iter = ConFrameIterator::new(text);
3065    match iter.next() {
3066        Some(Ok(frame)) => Ok(frame),
3067        Some(Err(e)) => Err(Box::new(e)),
3068        None => Err("No frames found in file".into()),
3069    }
3070}
3071
3072/// Skip `index` frames (no atom parse), then parse the next one.
3073///
3074/// `index == 0` is [`read_first_frame`]. Out of range is an error.
3075pub fn read_nth_frame(
3076    path: &Path,
3077    index: usize,
3078) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
3079    let contents = crate::compression::read_file_contents(path)?;
3080    let text = contents.as_str()?;
3081    read_nth_frame_from_text(text, index)
3082}
3083
3084/// Same as [`read_nth_frame`] on an already-loaded buffer.
3085pub fn read_nth_frame_from_text(
3086    text: &str,
3087    index: usize,
3088) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
3089    let mut iter = ConFrameIterator::new(text);
3090    let skipped = iter.skip_frames(index)?;
3091    if skipped < index {
3092        return Err(format!("frame {index} out of range (only {skipped} frames)").into());
3093    }
3094    match iter.next() {
3095        Some(Ok(frame)) => Ok(frame),
3096        Some(Err(e)) => Err(Box::new(e)),
3097        None => Err(format!("frame {index} out of range").into()),
3098    }
3099}
3100
3101/// Reads all frames using the checked `.rcidx` sidecar next to `path`.
3102///
3103/// Uncompressed input uses (and refreshes) `path.rcidx`; compressed
3104/// input is scanned in memory each time. UTF-8 is validated in
3105/// newline-aligned slices.
3106#[cfg(feature = "parallel")]
3107pub fn read_all_frames_indexed(
3108    path: &Path,
3109    num_threads: Option<usize>,
3110) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
3111    let contents = crate::compression::read_file_contents(path)?;
3112    let text = contents.as_str_sliced()?;
3113    let idx = match contents {
3114        crate::compression::FileContents::Mapped(_) => {
3115            crate::frame_index::load_or_build(path, text, true)?
3116        }
3117        crate::compression::FileContents::Owned(_) => {
3118            let on_disk = std::fs::metadata(path)?.len();
3119            crate::frame_index::load_or_build(path, text, on_disk == text.len() as u64)?
3120        }
3121    };
3122    let offs: Vec<usize> = idx.offsets.iter().map(|&o| o as usize).collect();
3123    parse_frames_at_offsets(text, &offs, num_threads)
3124        .into_iter()
3125        .map(|r| r.map_err(|e| Box::new(e) as Box<dyn std::error::Error>))
3126        .collect()
3127}
3128
3129enum IndexStep {
3130    Stay(usize),
3131    Missing(usize),
3132    Landed(usize, usize),
3133    Short(usize, usize),
3134}
3135
3136struct IndexedSpan {
3137    start: usize,
3138    content_end: usize,
3139    next_pos: usize,
3140}
3141
3142/// Lines in `bytes`, counting a final line that has no `\n`.
3143fn line_count(nl: &[usize], len: usize) -> usize {
3144    if len == 0 {
3145        0
3146    } else if nl.last().copied() == Some(len - 1) {
3147        nl.len()
3148    } else {
3149        nl.len() + 1
3150    }
3151}
3152
3153fn line_start_pos(nl: &[usize], len: usize, line_no: usize) -> usize {
3154    if line_no == 0 {
3155        return 0;
3156    }
3157    if line_no >= line_count(nl, len) {
3158        return len;
3159    }
3160    nl[line_no - 1] + 1
3161}
3162
3163/// Line index of the line that contains `pos`, or the line count at `len`.
3164fn line_no_for_pos(nl: &[usize], len: usize, pos: usize) -> usize {
3165    if pos >= len {
3166        return line_count(nl, len);
3167    }
3168    nl.partition_point(|&p| p < pos)
3169}
3170
3171fn indexed_span(nl: &[usize], bytes: &[u8], line_no: usize) -> Option<IndexedSpan> {
3172    let len = bytes.len();
3173    if line_no >= line_count(nl, len) {
3174        return None;
3175    }
3176    let start = if line_no == 0 { 0 } else { nl[line_no - 1] + 1 };
3177    let (raw_end, next_pos) = if line_no < nl.len() {
3178        (nl[line_no], nl[line_no] + 1)
3179    } else {
3180        (len, len)
3181    };
3182    let mut content_end = raw_end;
3183    if content_end > start && bytes[content_end - 1] == b'\r' {
3184        content_end -= 1;
3185    }
3186    Some(IndexedSpan {
3187        start,
3188        content_end,
3189        next_pos,
3190    })
3191}
3192
3193/// `n` cuts covering `0..=len`. A cut may fall in the middle of a header line.
3194#[cfg(feature = "parallel")]
3195fn equal_cuts(len: usize, n: usize) -> Vec<usize> {
3196    let n = n.max(1);
3197    let mut cuts = Vec::with_capacity(n + 1);
3198    for i in 0..=n {
3199        let at = ((i as u128) * (len as u128) / (n as u128)) as usize;
3200        let at = at.min(len);
3201        if cuts.last().copied() != Some(at) {
3202            cuts.push(at);
3203        }
3204    }
3205    if cuts.last().copied() != Some(len) {
3206        cuts.push(len);
3207    }
3208    cuts
3209}
3210
3211/// Newline offsets in `bytes`.
3212///
3213/// `cuts` splits the buffer into `[cuts[i], cuts[i + 1])`. Each piece is
3214/// scanned on its own and the offsets are concatenated. A `\n` belongs to
3215/// the piece that contains that byte, so a header line whose body crosses
3216/// a cut still contributes one newline.
3217#[cfg(feature = "parallel")]
3218fn newline_positions_at_cuts(bytes: &[u8], cuts: &[usize]) -> Vec<usize> {
3219    let spans: Vec<(usize, usize)> = cuts
3220        .windows(2)
3221        .map(|w| (w[0].min(bytes.len()), w[1].min(bytes.len())))
3222        .filter(|(start, end)| start < end)
3223        .collect();
3224    if spans.len() <= 1 {
3225        let (start, end) = spans.first().copied().unwrap_or((0, 0));
3226        return memchr::memchr_iter(b'\n', &bytes[start..end])
3227            .map(|p| start + p)
3228            .collect();
3229    }
3230    // The current Rayon pool, or the global pool when the caller has not
3231    // installed one. Chunk count is the worker cap: two chunks do not
3232    // wake every core.
3233    use rayon::prelude::*;
3234    let parts: Vec<Vec<usize>> = spans
3235        .par_iter()
3236        .map(|&(start, end)| {
3237            memchr::memchr_iter(b'\n', &bytes[start..end])
3238                .map(|p| start + p)
3239                .collect()
3240        })
3241        .collect();
3242    let mut all = Vec::with_capacity(bytes.len() / 32);
3243    for part in parts {
3244        all.extend(part);
3245    }
3246    all
3247}
3248
3249#[cfg(feature = "parallel")]
3250fn newline_positions(bytes: &[u8], threads: usize) -> Vec<usize> {
3251    newline_positions_at_cuts(bytes, &equal_cuts(bytes.len(), threads))
3252}
3253
3254/// Worker count for a newline scan.
3255///
3256/// `Some(n)` with `n >= 2` uses that many workers at every file length.
3257/// A file shorter than two bytes has nothing to split, and [`equal_cuts`]
3258/// drops a cut that repeats the previous byte, so a short file does not
3259/// invent an empty chunk. `None`, `Some(0)`, and `Some(1)` stay on the
3260/// calling thread. An omitted count must not start the global pool:
3261/// that pool made a one-frame file slower. There is no byte-length
3262/// cutoff taken from one host.
3263#[cfg(feature = "parallel")]
3264fn scan_thread_count(num_threads: Option<usize>, len: usize) -> usize {
3265    let requested = match num_threads {
3266        Some(n) if n >= 2 => n,
3267        _ => return 1,
3268    };
3269    if len < 2 { 1 } else { requested }
3270}
3271
3272/// I/O, UTF-8, or a located parse failure while loading a path.
3273#[derive(Debug)]
3274pub enum LoadError {
3275    Io(std::io::Error),
3276    Utf8(std::str::Utf8Error),
3277    Parse(error::LocatedParseError),
3278    /// The path opened and decoded, and it held no frame.
3279    Empty,
3280}
3281
3282impl std::fmt::Display for LoadError {
3283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3284        match self {
3285            LoadError::Io(e) => write!(f, "{e}"),
3286            LoadError::Utf8(e) => write!(f, "{e}"),
3287            LoadError::Parse(e) => write!(f, "{e}"),
3288            LoadError::Empty => write!(f, "no frames found in file"),
3289        }
3290    }
3291}
3292
3293impl std::error::Error for LoadError {}
3294
3295fn load_error_from_box(err: Box<dyn std::error::Error>) -> LoadError {
3296    if let Some(io) = err.downcast_ref::<std::io::Error>() {
3297        return LoadError::Io(std::io::Error::new(io.kind(), io.to_string()));
3298    }
3299    LoadError::Io(std::io::Error::other(err.to_string()))
3300}
3301
3302fn load_text(path: &Path) -> Result<crate::compression::FileContents, LoadError> {
3303    crate::compression::read_file_contents(path).map_err(load_error_from_box)
3304}
3305
3306/// 1-based line number of the line that contains `byte`.
3307#[cfg(feature = "parallel")]
3308fn file_line_at(text: &str, byte: usize) -> u32 {
3309    let end = byte.min(text.len());
3310    1 + text.as_bytes()[..end]
3311        .iter()
3312        .filter(|&&b| b == b'\n')
3313        .count() as u32
3314}
3315
3316/// Collect every frame, keeping the line and frame index of the first error.
3317pub fn frames_from_text_located(
3318    text: &str,
3319    num_threads: Option<usize>,
3320) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
3321    #[cfg(feature = "parallel")]
3322    {
3323        // A caller that does not ask for a pool stays on this thread.
3324        // Starting the global pool for a long file made a one-frame read slower.
3325        let use_parallel = matches!(num_threads, Some(n) if n >= 2);
3326        if use_parallel {
3327            let pool = num_threads.filter(|&n| n >= 2);
3328            return collect_parallel_located(text, pool);
3329        }
3330    }
3331    #[cfg(not(feature = "parallel"))]
3332    {
3333        let _ = num_threads;
3334    }
3335    let mut frames = Vec::new();
3336    let mut iter = ConFrameIterator::new(text);
3337    let mut index = 0u64;
3338    while let Some(item) = iter.next() {
3339        match item {
3340            Ok(frame) => {
3341                frames.push(frame);
3342                index += 1;
3343            }
3344            Err(e) => {
3345                return Err(error::LocatedParseError::new(
3346                    e,
3347                    iter.lines.line_no(),
3348                    index,
3349                ));
3350            }
3351        }
3352    }
3353    Ok(frames)
3354}
3355
3356/// Read every frame from `path`, with the line and frame index of the first error.
3357pub fn read_all_frames_located(
3358    path: &Path,
3359    num_threads: Option<usize>,
3360) -> Result<Vec<types::ConFrame>, LoadError> {
3361    if matches!(num_threads, Some(n) if n >= 2) {
3362        let contents = load_text(path)?;
3363        let text = utf8_text(contents.as_bytes(), true)?;
3364        return frames_from_text_located(text, num_threads).map_err(LoadError::Parse);
3365    }
3366    stream_frames(path)
3367}
3368
3369/// Read the first frame from `path`, with a line number on failure.
3370pub fn read_first_frame_located(path: &Path) -> Result<types::ConFrame, LoadError> {
3371    let contents = load_text(path)?;
3372    let text = utf8_text(contents.as_bytes(), true)?;
3373    let mut iter = ConFrameIterator::new(text);
3374    match iter.next() {
3375        Some(Ok(frame)) => Ok(frame),
3376        Some(Err(e)) => Err(LoadError::Parse(error::LocatedParseError::new(
3377            e,
3378            iter.lines.line_no(),
3379            0,
3380        ))),
3381        None => Err(LoadError::Empty),
3382    }
3383}
3384
3385/// Skip `index` frames, then parse one, with a line number on failure.
3386pub fn read_nth_frame_located(path: &Path, index: usize) -> Result<types::ConFrame, LoadError> {
3387    let contents = load_text(path)?;
3388    let text = utf8_text(contents.as_bytes(), true)?;
3389    let mut iter = ConFrameIterator::new(text);
3390    match iter.skip_frames_counted(index) {
3391        Ok(skipped) if skipped == index => {}
3392        Ok(skipped) => {
3393            return Err(LoadError::Parse(error::LocatedParseError::new(
3394                error::ParseError::IncompleteFrame,
3395                iter.lines.line_no(),
3396                skipped as u64,
3397            )));
3398        }
3399        Err((failed_at, e)) => {
3400            return Err(LoadError::Parse(error::LocatedParseError::new(
3401                e,
3402                iter.lines.line_no(),
3403                failed_at as u64,
3404            )));
3405        }
3406    }
3407    match iter.next() {
3408        Some(Ok(frame)) => Ok(frame),
3409        Some(Err(e)) => Err(LoadError::Parse(error::LocatedParseError::new(
3410            e,
3411            iter.lines.line_no(),
3412            index as u64,
3413        ))),
3414        None => Err(LoadError::Parse(error::LocatedParseError::new(
3415            error::ParseError::IncompleteFrame,
3416            iter.lines.line_no(),
3417            index as u64,
3418        ))),
3419    }
3420}
3421
3422fn collect_frame_starts(scanner: ConFrameIterator<'_>, skip_whitespace_tail: bool) -> Vec<usize> {
3423    #[cfg(target_arch = "x86_64")]
3424    {
3425        if std::is_x86_feature_detected!("avx2") {
3426            // One AVX2 entry so the line skip stays in this function.
3427            // A separate call per block pays for an AVX/SSE transition.
3428            // SAFETY: the runtime check above is the AVX2 and POPCNT contract.
3429            return unsafe { collect_frame_starts_avx2(scanner, skip_whitespace_tail) };
3430        }
3431    }
3432    collect_frame_starts_inner(scanner, skip_whitespace_tail)
3433}
3434
3435#[cfg(target_arch = "x86_64")]
3436#[target_feature(enable = "avx2,popcnt")]
3437unsafe fn collect_frame_starts_avx2(
3438    scanner: ConFrameIterator<'_>,
3439    skip_whitespace_tail: bool,
3440) -> Vec<usize> {
3441    collect_frame_starts_inner(scanner, skip_whitespace_tail)
3442}
3443
3444#[inline(always)]
3445fn collect_frame_starts_inner(
3446    mut scanner: ConFrameIterator<'_>,
3447    skip_whitespace_tail: bool,
3448) -> Vec<usize> {
3449    // Callers used to skip only an ASCII tail. Every scan now stops where
3450    // the sequential reader stops, so the flag no longer changes the result.
3451    let _ = skip_whitespace_tail;
3452    let mut boundaries = Vec::new();
3453    loop {
3454        scanner.lines.clear_peek();
3455        let start = scanner.lines.pos;
3456        // A whitespace tail, including Unicode space, is not another frame
3457        // (CON-frame.1).
3458        if no_frame_at(scanner.lines.bytes, start) {
3459            break;
3460        }
3461        boundaries.push(start);
3462        match scanner.forward_fast_to(None) {
3463            Some(Ok(())) => {}
3464            Some(Err(_)) | None => break,
3465        }
3466    }
3467    boundaries
3468}
3469
3470/// Serial header walk. This is the reference the chunked scan must match.
3471pub fn frame_start_offsets_serial(file_contents: &str) -> Vec<usize> {
3472    collect_frame_starts(ConFrameIterator::new(file_contents), false)
3473}
3474
3475#[cfg(feature = "parallel")]
3476fn scanner_with_newlines<'a>(text: &'a str, newlines: Vec<usize>) -> ConFrameIterator<'a> {
3477    let mut scanner = ConFrameIterator::new(text);
3478    scanner.lines.index_line = 0;
3479    scanner.lines.newlines = Some(newlines);
3480    scanner
3481}
3482
3483#[cfg(feature = "parallel")]
3484fn frame_start_offsets_indexed(text: &str, newlines: Vec<usize>) -> Vec<usize> {
3485    collect_frame_starts(scanner_with_newlines(text, newlines), false)
3486}
3487
3488/// Frame starts for `text` when the newline scan is split at `cuts`.
3489///
3490/// `cuts` is the chunk edges, including 0 and `text.len()`. A cut inside a
3491/// header line is the straddling case: the stitched offsets are still the
3492/// serial offsets.
3493#[cfg(all(feature = "parallel", test))]
3494fn frame_start_offsets_at_cuts(text: &str, cuts: &[usize]) -> Vec<usize> {
3495    let newlines = newline_positions_at_cuts(text.as_bytes(), cuts);
3496    frame_start_offsets_indexed(text, newlines)
3497}
3498
3499/// Frame start byte offsets via [`ConFrameIterator::forward_fast`] (no atom parse).
3500///
3501/// Stops at the first skip error (that start is still included).
3502///
3503/// With the `parallel` feature and more than one worker, the buffer is
3504/// split into chunks and the newline bytes are scanned together. The
3505/// length of the buffer does not choose the worker count. The chunks
3506/// are not aligned to lines. A header
3507/// that straddles a cut is one line, because each `\n` is recorded by the
3508/// chunk that holds that byte and the lists are stitched in order. The
3509/// returned offsets match the serial walk.
3510pub fn frame_start_offsets(file_contents: &str) -> Vec<usize> {
3511    frame_start_offsets_with_threads(file_contents, None)
3512}
3513
3514/// [`frame_start_offsets`] using `num_threads` chunks.
3515///
3516/// `None` uses the machine's parallelism. `Some(0)` and `Some(1)` stay on
3517/// the calling thread. The offsets do not depend on the thread count.
3518///
3519/// The timed work includes the cut setup, the per-chunk scans, the
3520/// stitch, and the frame walk that reads the stitched newline index.
3521pub fn frame_start_offsets_with_threads(
3522    file_contents: &str,
3523    num_threads: Option<usize>,
3524) -> Vec<usize> {
3525    #[cfg(feature = "parallel")]
3526    {
3527        let threads = scan_thread_count(num_threads, file_contents.len());
3528        if threads >= 2 {
3529            let newlines = newline_positions(file_contents.as_bytes(), threads);
3530            return frame_start_offsets_indexed(file_contents, newlines);
3531        }
3532    }
3533    #[cfg(not(feature = "parallel"))]
3534    {
3535        let _ = num_threads;
3536    }
3537    frame_start_offsets_serial(file_contents)
3538}
3539
3540/// Parses frames in parallel using rayon, splitting on frame boundaries.
3541///
3542/// Phase 1: frame starts from a serial [`ConFrameIterator::forward_fast`]
3543/// walk. The header names each block's line count, so the walk does not
3544/// parse atom rows. Phase 2 parses those spans on a Rayon pool and
3545/// stores the frames in file order. See [`parse_frames_parallel_with_threads`].
3546///
3547/// Requires the `parallel` feature.
3548#[cfg(feature = "parallel")]
3549pub fn parse_frames_parallel(
3550    file_contents: &str,
3551) -> Vec<Result<types::ConFrame, error::ParseError>> {
3552    parse_frames_parallel_with_threads(file_contents, None)
3553}
3554
3555/// Like [`parse_frames_parallel`], but `Some(n)` with `n >= 2` parses on
3556/// a Rayon pool of that size. `None`, `Some(0)`, and `Some(1)` stay on
3557/// the calling thread.
3558///
3559/// The boundary walk is serial. The parse ranges are byte-balanced and
3560/// the frames come back in file order, field for field with a serial
3561/// iterator.
3562///
3563/// Requires the `parallel` feature.
3564#[cfg(feature = "parallel")]
3565pub fn parse_frames_parallel_with_threads(
3566    file_contents: &str,
3567    num_threads: Option<usize>,
3568) -> Vec<Result<types::ConFrame, error::ParseError>> {
3569    let parse_workers = match num_threads {
3570        Some(n) if n >= 2 => n,
3571        _ => 1,
3572    };
3573    let run = || {
3574        // A whitespace tail is not a frame. The serial iterator stops
3575        // there. The offset scan used by callers still records that byte
3576        // when a skip error includes it.
3577        let (boundaries, _kind) = parallel_boundaries(file_contents.as_bytes(), parse_workers);
3578        parse_into_slots(
3579            file_contents.as_bytes(),
3580            file_contents.len(),
3581            &boundaries,
3582            parse_workers,
3583            |start, end| parse_text_span(file_contents, start, end),
3584        )
3585    };
3586    match num_threads {
3587        Some(n) if n >= 2 => match shared_pool(n) {
3588            Some(pool) => pool.install(run),
3589            None => run(),
3590        },
3591        _ => run(),
3592    }
3593}
3594
3595/// Phase 2 of [`parse_frames_parallel_with_threads`]: parse the frames
3596/// that start at `boundaries` (strictly increasing byte offsets into
3597/// `file_contents`; frame `i` ends where frame `i + 1` starts).
3598///
3599/// Offsets may come from [`frame_start_offsets`] or from a checked
3600/// [`crate::frame_index::FrameIndex`]. The output slots are allocated
3601/// first. Each worker then fills one contiguous index range. Results
3602/// stay in frame order.
3603///
3604/// The timer for this function starts after `boundaries` exist. It
3605/// includes slot allocation and the worker parse. It does not include
3606/// the newline scan or the stitch.
3607///
3608/// Requires the `parallel` feature.
3609#[cfg(feature = "parallel")]
3610pub fn parse_frames_at_offsets(
3611    file_contents: &str,
3612    boundaries: &[usize],
3613    num_threads: Option<usize>,
3614) -> Vec<Result<types::ConFrame, error::ParseError>> {
3615    let workers = match num_threads {
3616        Some(n) if n <= 1 => 1,
3617        Some(n) => n,
3618        None => rayon::current_num_threads().max(1),
3619    };
3620    let run = || {
3621        parse_into_slots(
3622            file_contents.as_bytes(),
3623            file_contents.len(),
3624            boundaries,
3625            workers,
3626            |start, end| parse_text_span(file_contents, start, end),
3627        )
3628    };
3629    match num_threads {
3630        Some(n) if n >= 2 => match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
3631            Ok(pool) => pool.install(run),
3632            Err(_) => run(),
3633        },
3634        _ => run(),
3635    }
3636}
3637
3638/// Seconds for one phased read. The open time is the file read or the
3639/// map. Scan, slot allocation, and the worker parse are separate.
3640#[derive(Clone, Copy, Debug, Default)]
3641pub struct ReadPhaseTimes {
3642    pub open_mmap_s: f64,
3643    /// Rayon pool construction. Zero when the call stays on one thread.
3644    pub pool_s: f64,
3645    /// Joining the local pool after the scan and the parse. Zero when
3646    /// the call stays on one thread.
3647    pub pool_join_s: f64,
3648    pub scan_s: f64,
3649    pub slot_alloc_s: f64,
3650    pub worker_parse_s: f64,
3651    /// Moving each parsed `Result` into the frame vector.
3652    pub finish_s: f64,
3653    /// Sum of per-chunk parse times. This is CPU time, not wall time.
3654    pub worker_sum_s: f64,
3655    /// Slowest parse chunk. This is inside `worker_parse_s`.
3656    pub worker_max_s: f64,
3657    /// Drop of the mapped or owned file buffer after the parse.
3658    pub close_s: f64,
3659    /// Wake of the worker pool and the other gaps inside the read.
3660    pub schedule_s: f64,
3661    /// Minor faults during the scan.
3662    pub scan_minflt: i64,
3663    /// Sum of per-worker minor faults during the parse.
3664    pub parse_minflt: i64,
3665    /// LLC read misses, or `-1` when the guest refuses the counter.
3666    pub llc_misses: i64,
3667    /// Pages on a node other than node 0. Zero on a one-node guest.
3668    pub numa_remote: i64,
3669    /// Minor faults while the coordinate slab is filled.
3670    pub slab_minflt: i64,
3671    /// Minor faults during the touch checksum.
3672    pub touch_minflt: i64,
3673    /// True when the bytes came from a map rather than an owned buffer.
3674    pub mapped: bool,
3675}
3676
3677/// Frames from one phased read, plus the byte length of the buffer.
3678pub struct PhasedFrames {
3679    pub frames: Vec<types::ConFrame>,
3680    pub phases: ReadPhaseTimes,
3681    pub n_bytes: usize,
3682    /// Per-chunk parse seconds, in claim order.
3683    pub chunk_s: Vec<f64>,
3684    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`] when
3685    /// `parallel` is on. Zero otherwise.
3686    pub scan_kind: u8,
3687    /// Minor faults on each parse worker.
3688    pub worker_minflt: Vec<i64>,
3689    /// Frames in each worker share.
3690    pub share_frames: Vec<u64>,
3691    /// File bytes in each worker share.
3692    pub share_bytes: Vec<u64>,
3693    /// Pool kept for the slab, the touch, and frame teardown.
3694    #[cfg(feature = "parallel")]
3695    pub pool: Option<std::sync::Arc<rayon::ThreadPool>>,
3696}
3697
3698/// Read `path` and time open, scan, slot allocation, and the worker parse.
3699///
3700/// Plain text at or above the map threshold is mapped. Smaller files and
3701/// compressed files are owned buffers. `Some(n)` with `n >= 2` parses on
3702/// that many workers. Every other count parses on the calling thread.
3703/// The frame list matches [`frames_from_bytes`].
3704///
3705/// A parse failure is the [`error::LocatedParseError`] from the parse
3706/// that failed. The path is not opened again.
3707/// Inflate each indexed member and parse its coordinate rows on that same
3708/// worker, with no barrier between the two. Old indexes without a row
3709/// table return `None` and the caller keeps the full-buffer parser.
3710#[cfg(feature = "parallel")]
3711fn piped_stock_frames(
3712    file: &[u8],
3713    threads: usize,
3714) -> Result<Option<(Vec<types::ConFrame>, f64, f64)>, Box<dyn std::error::Error>> {
3715    use std::sync::atomic::{AtomicBool, Ordering};
3716    let Some(index) = crate::compression::indexed_members(file) else {
3717        return Ok(None);
3718    };
3719    if threads < 2 {
3720        return Ok(None);
3721    }
3722    // A flipped length or row count must not overflow the sum or reserve
3723    // a buffer the compressed file cannot hold. The full parser reads it.
3724    let Some(plain_total) = crate::compression::trusted_plain_total(file.len(), &index.plain)
3725    else {
3726        return Ok(None);
3727    };
3728    let Some(n) = crate::compression::trusted_row_total(plain_total, &index.rows) else {
3729        return Ok(None);
3730    };
3731    if n == 0 {
3732        return Ok(None);
3733    }
3734    let t_alloc = std::time::Instant::now();
3735    let mut offset = Vec::with_capacity(index.plain.len());
3736    let mut cursor = 0usize;
3737    for len in &index.plain {
3738        offset.push(cursor);
3739        cursor += *len;
3740    }
3741    let mut ends = index.starts[1..].to_vec();
3742    ends.push(file.len());
3743    let header: std::sync::Mutex<Option<types::FrameHeader>> = std::sync::Mutex::new(None);
3744    let symbols: std::sync::Mutex<Vec<(std::sync::Arc<str>, usize)>> =
3745        std::sync::Mutex::new(Vec::new());
3746    let ready = AtomicBool::new(false);
3747    let failed = AtomicBool::new(false);
3748    let spec = std::sync::atomic::AtomicU32::new(0);
3749    let row_origin = std::sync::atomic::AtomicUsize::new(0);
3750    let mut xyz = Vec::<f64>::new();
3751    xyz.reserve(n.saturating_mul(3));
3752    let mut ids = Vec::<u64>::new();
3753    ids.reserve(n);
3754    let mut flags = Vec::<[bool; 3]>::new();
3755    flags.reserve(n);
3756    let mut atoms = crate::types::take_atom_spare(n);
3757    unsafe { atoms.set_len(n) };
3758    let error: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
3759    let alloc_s = t_alloc.elapsed().as_secs_f64();
3760    let nworkers = threads.min(index.plain.len()).max(1);
3761    let t_scope = std::time::Instant::now();
3762    let xyz_addr = xyz.as_mut_ptr() as usize;
3763    let ids_addr = ids.as_mut_ptr() as usize;
3764    let flags_addr = flags.as_mut_ptr() as usize;
3765    let atoms_addr = atoms.as_mut_ptr() as usize;
3766    crate::parser::on_pool(nworkers, || {
3767        rayon::scope(|scope| {
3768            for worker in 0..nworkers {
3769                let index = &index;
3770                let offset = &offset;
3771                let ends = &ends;
3772                let header = &header;
3773                let symbols = &symbols;
3774                let error = &error;
3775                let ready = &ready;
3776                let failed = &failed;
3777                let spec = &spec;
3778                let row_origin = &row_origin;
3779                scope.spawn(move |_| {
3780                    let xyz_ptr = xyz_addr as *mut f64;
3781                    let ids_ptr = ids_addr as *mut u64;
3782                    let flags_ptr = flags_addr as *mut [bool; 3];
3783                    let atoms_ptr = atoms_addr as *mut std::mem::MaybeUninit<types::AtomDatum>;
3784                    // One inflate buffer per pool thread, kept after the first file.
3785                    thread_local! {
3786                        static INFLATE_BUF: std::cell::RefCell<Vec<u8>> =
3787                            const { std::cell::RefCell::new(Vec::new()) };
3788                    }
3789                    crate::parser::pin_worker(worker);
3790                    let mut symbol: Option<std::sync::Arc<str>> = None;
3791                    let mut member = worker;
3792                    while member < index.plain.len() {
3793                        let src = &file[index.starts[member]..ends[member]];
3794                        let plain_len = index.plain[member];
3795                        let parsed = INFLATE_BUF.with(|cell| {
3796                            let mut buf = cell.borrow_mut();
3797                            buf.clear();
3798                            if buf.capacity() < plain_len {
3799                                let extra = plain_len - buf.len();
3800                                buf.reserve(extra);
3801                            }
3802                            unsafe { buf.set_len(plain_len) };
3803                            if let Err(err) = crate::compression::inflate_member(
3804                                index.gzip,
3805                                src,
3806                                &mut buf[..plain_len],
3807                            ) {
3808                                return Err(err.to_string());
3809                            }
3810                            if member == 0 {
3811                                match stock_header(&buf[..plain_len]) {
3812                                    Some((parsed, syms, origin)) => {
3813                                        spec.store(parsed.spec_version, Ordering::Release);
3814                                        row_origin.store(origin, Ordering::Release);
3815                                        *symbols.lock().unwrap() = syms;
3816                                        *header.lock().unwrap() = Some(parsed);
3817                                        ready.store(true, Ordering::Release);
3818                                    }
3819                                    None => {
3820                                        // More than one species, or a header this
3821                                        // fast path does not own. Stop the other
3822                                        // workers and let the full parser read
3823                                        // the concatenated members.
3824                                        failed.store(true, Ordering::Release);
3825                                        return Ok(());
3826                                    }
3827                                }
3828                            }
3829                            let (base, count) = index.rows[member];
3830                            if count == 0 {
3831                                return Ok(());
3832                            }
3833                            while !ready.load(Ordering::Acquire) {
3834                                if failed.load(Ordering::Acquire) {
3835                                    return Ok(());
3836                                }
3837                                std::thread::yield_now();
3838                            }
3839                            if symbol.is_none() {
3840                                symbol =
3841                                    symbols.lock().unwrap().first().map(|(sym, _)| sym.clone());
3842                            }
3843                            let Some(symbol) = symbol.as_ref() else {
3844                                return Err("indexed frame has no species".into());
3845                            };
3846                            let spec_v = spec.load(Ordering::Acquire);
3847                            let origin = row_origin.load(Ordering::Acquire);
3848                            let local = if offset[member] >= origin {
3849                                0
3850                            } else {
3851                                origin - offset[member]
3852                            };
3853                            let row = base as usize;
3854                            let nrows = count as usize;
3855                            if row.checked_add(nrows).map(|end| end <= n) != Some(true) {
3856                                return Err("indexed row is outside the frame".into());
3857                            }
3858                            if let Err(err) = crate::parser::parse_coord_run(
3859                                &buf[..plain_len],
3860                                local,
3861                                nrows,
3862                                spec_v,
3863                                row,
3864                                Some(symbol),
3865                                xyz_ptr,
3866                                ids_ptr,
3867                                flags_ptr,
3868                                atoms_ptr,
3869                            ) {
3870                                return Err(err.to_string());
3871                            }
3872                            Ok(())
3873                        });
3874                        if let Err(err) = parsed {
3875                            *error.lock().unwrap() = Some(err);
3876                            failed.store(true, Ordering::Release);
3877                            return;
3878                        }
3879                        if failed.load(Ordering::Acquire) {
3880                            return;
3881                        }
3882                        member += nworkers;
3883                    }
3884                });
3885            }
3886        });
3887    });
3888    let scope_s = t_scope.elapsed().as_secs_f64();
3889    if let Some(err) = error.into_inner().unwrap_or(None) {
3890        std::mem::forget(atoms);
3891        return Err(err.into());
3892    }
3893    let Some(header) = header.into_inner().unwrap_or(None) else {
3894        std::mem::forget(atoms);
3895        return Ok(None);
3896    };
3897    // Every coordinate, id, and flag was written. The length stayed 0
3898    // until here, so an error does not drop an uninitialised bool.
3899    unsafe {
3900        xyz.set_len(n.saturating_mul(3));
3901        ids.set_len(n);
3902        flags.set_len(n);
3903    }
3904    let old = xyz.as_ptr();
3905    let positions = crate::storage_dtype::FloatArray2::from_f64_row_major(n, 3, xyz);
3906    let mut atoms = owned_initialized_atoms(atoms, n);
3907    crate::types::mark_uniform_symbol_drop(&atoms);
3908    if let Some(slice) = positions.f64_slice() {
3909        let new_ptr = slice.as_ptr() as *mut f64;
3910        if new_ptr != old as *mut f64 {
3911            for (index, atom) in atoms.iter_mut().enumerate() {
3912                atom.retarget(unsafe { new_ptr.add(index * 3) });
3913            }
3914        }
3915    }
3916    let frame = crate::types::con_frame_coords_only_with_ids(header, atoms, positions, ids);
3917    Ok(Some((vec![frame], alloc_s, scope_s)))
3918}
3919
3920/// `raw` is `n` initialized atoms in a buffer that may be larger than `n`.
3921///
3922/// The spare reused across frames keeps its real capacity. Passing `n` as
3923/// the capacity frees that allocation with the wrong layout.
3924#[cfg(feature = "parallel")]
3925fn owned_initialized_atoms(
3926    mut raw: Vec<std::mem::MaybeUninit<types::AtomDatum>>,
3927    n: usize,
3928) -> Vec<types::AtomDatum> {
3929    let cap = raw.capacity();
3930    debug_assert!(cap >= n);
3931    let ptr = raw.as_mut_ptr() as *mut types::AtomDatum;
3932    std::mem::forget(raw);
3933    unsafe { Vec::from_raw_parts(ptr, n, cap) }
3934}
3935
3936#[cfg(all(test, feature = "parallel"))]
3937mod spare_capacity {
3938    #[test]
3939    fn a_reused_spare_keeps_its_capacity() {
3940        let mut raw = crate::types::take_atom_spare(32);
3941        let cap = raw.capacity();
3942        assert!(cap >= 32);
3943        unsafe { raw.set_len(4) };
3944        for index in 0..4 {
3945            raw[index].write(crate::types::AtomDatum::coords(
3946                "Cu",
3947                0.0,
3948                0.0,
3949                0.0,
3950                [false, false, false],
3951                index as u64,
3952            ));
3953        }
3954        let atoms = super::owned_initialized_atoms(raw, 4);
3955        assert_eq!(atoms.capacity(), cap);
3956    }
3957}
3958
3959#[cfg(feature = "parallel")]
3960fn stock_header(
3961    bytes: &[u8],
3962) -> Option<(types::FrameHeader, Vec<(std::sync::Arc<str>, usize)>, usize)> {
3963    // The member is several megabytes. The header is the first few dozen
3964    // lines; scanning the whole member as UTF-8 sat on the critical path.
3965    let header_region = {
3966        let mut seen = 0usize;
3967        let mut end = bytes.len();
3968        for (index, byte) in bytes.iter().enumerate() {
3969            if *byte == b'\n' {
3970                seen += 1;
3971                if seen == 64 {
3972                    end = index + 1;
3973                    break;
3974                }
3975            }
3976        }
3977        &bytes[..end]
3978    };
3979    let text = std::str::from_utf8(header_region).ok()?;
3980    let mut lines = text.lines();
3981    let header = crate::parser::parse_frame_header(&mut lines).ok()?;
3982    if header.natm_types != 1 || header.natms_per_type.len() != 1 {
3983        return None;
3984    }
3985    let mut symbols = Vec::with_capacity(header.natm_types);
3986    for &count in &header.natms_per_type {
3987        let symbol = lines.next()?.trim();
3988        let _label = lines.next()?;
3989        if symbol.is_empty() || count == 0 {
3990            return None;
3991        }
3992        symbols.push((std::sync::Arc::from(symbol), count));
3993    }
3994    let header_lines = 9 + header.natm_types * 2;
3995    let mut seen = 0usize;
3996    let mut origin = 0usize;
3997    for index in memchr::memchr_iter(b'\n', bytes) {
3998        seen += 1;
3999        if seen == header_lines {
4000            origin = index + 1;
4001            break;
4002        }
4003    }
4004    if seen != header_lines {
4005        return None;
4006    }
4007    Some((header, symbols, origin))
4008}
4009
4010#[cfg(feature = "parallel")]
4011fn symbol_covering(
4012    symbols: &[(std::sync::Arc<str>, usize)],
4013    row: usize,
4014) -> (&std::sync::Arc<str>, usize) {
4015    let mut acc = 0usize;
4016    for (symbol, count) in symbols {
4017        let end = acc + *count;
4018        if row < end {
4019            return (symbol, end);
4020        }
4021        acc = end;
4022    }
4023    let (symbol, count) = symbols.last().expect("species");
4024    (symbol, acc + *count)
4025}
4026
4027fn read_compressed_phased(
4028    path: &Path,
4029    num_threads: Option<usize>,
4030) -> Result<PhasedFrames, Box<dyn std::error::Error>> {
4031    let t_open = std::time::Instant::now();
4032    let file = std::fs::read(path)?;
4033    let open_s = t_open.elapsed().as_secs_f64();
4034    let t_all = std::time::Instant::now();
4035    let piped_threads = match num_threads {
4036        Some(n) if n >= 2 => n,
4037        _ => {
4038            let budget = crate::compression::decode_thread_budget();
4039            if budget >= 2 { budget } else { 1 }
4040        }
4041    };
4042    #[cfg(feature = "parallel")]
4043    if piped_threads >= 2 {
4044        if let Some((frames, alloc_s, scope_s)) = piped_stock_frames(&file, piped_threads)? {
4045            let elapsed = t_all.elapsed().as_secs_f64();
4046            let n_frames = frames.len() as u64;
4047            return Ok(PhasedFrames {
4048                frames,
4049                n_bytes: file.len(),
4050                chunk_s: vec![scope_s],
4051                scan_kind: SCAN_SERIAL,
4052                worker_minflt: Vec::new(),
4053                share_frames: vec![n_frames],
4054                share_bytes: Vec::new(),
4055                phases: ReadPhaseTimes {
4056                    open_mmap_s: open_s,
4057                    slot_alloc_s: alloc_s,
4058                    worker_parse_s: scope_s,
4059                    worker_sum_s: scope_s,
4060                    worker_max_s: scope_s,
4061                    finish_s: (elapsed - alloc_s - scope_s).max(0.0),
4062                    ..ReadPhaseTimes::default()
4063                },
4064                pool: None,
4065            });
4066        }
4067    }
4068    crate::compression::with_inflated_plain(&file, |plain| {
4069        let open_mmap_s = open_s + t_all.elapsed().as_secs_f64();
4070        let n_bytes = plain.len();
4071        let phased = frames_from_bytes_phased_located(plain, num_threads)
4072            .map_err(|err| Box::new(err) as Box<dyn std::error::Error>)?;
4073        let elapsed = t_all.elapsed().as_secs_f64();
4074        let parse = phased.worker_parse_s
4075            + phased.scan_s
4076            + phased.slot_alloc_s
4077            + phased.finish_s
4078            + phased.pool_s
4079            + phased.pool_join_s;
4080        let schedule_s = (elapsed - open_mmap_s - parse).max(0.0);
4081        let parse_minflt = phased.worker_minflt.iter().sum();
4082        Ok(PhasedFrames {
4083            frames: phased.frames,
4084            n_bytes,
4085            chunk_s: phased.chunk_s,
4086            scan_kind: phased.scan_kind,
4087            worker_minflt: phased.worker_minflt,
4088            share_frames: phased.share_frames,
4089            share_bytes: phased.share_bytes,
4090            phases: ReadPhaseTimes {
4091                open_mmap_s,
4092                pool_s: phased.pool_s,
4093                pool_join_s: phased.pool_join_s,
4094                scan_s: phased.scan_s,
4095                slot_alloc_s: phased.slot_alloc_s,
4096                worker_parse_s: phased.worker_parse_s,
4097                finish_s: phased.finish_s,
4098                worker_sum_s: phased.worker_sum_s,
4099                worker_max_s: phased.worker_max_s,
4100                schedule_s,
4101                scan_minflt: phased.scan_minflt,
4102                parse_minflt,
4103                llc_misses: phased.llc_misses,
4104                numa_remote: phased.numa_remote,
4105                ..ReadPhaseTimes::default()
4106            },
4107            #[cfg(feature = "parallel")]
4108            pool: phased.pool,
4109        })
4110    })
4111}
4112
4113fn path_is_compressed(path: &Path) -> bool {
4114    let mut file = match std::fs::File::open(path) {
4115        Ok(file) => file,
4116        Err(_) => return false,
4117    };
4118    let mut magic = [0u8; 4];
4119    let n = std::io::Read::read(&mut file, &mut magic).unwrap_or(0);
4120    !matches!(
4121        crate::compression::detect_compression(&magic[..n]),
4122        crate::compression::Compression::None
4123    )
4124}
4125
4126pub fn read_all_frames_phased(
4127    path: &Path,
4128    num_threads: Option<usize>,
4129) -> Result<PhasedFrames, Box<dyn std::error::Error>> {
4130    if path_is_compressed(path) {
4131        return read_compressed_phased(path, num_threads);
4132    }
4133    if !matches!(num_threads, Some(n) if n >= 2) {
4134        let t_all = std::time::Instant::now();
4135        let frames = stream_frames(path).map_err(public_load_error)?;
4136        let elapsed = t_all.elapsed().as_secs_f64();
4137        let n_frames = frames.len() as u64;
4138        return Ok(PhasedFrames {
4139            frames,
4140            n_bytes: 0,
4141            chunk_s: vec![elapsed],
4142            scan_kind: SCAN_SERIAL,
4143            worker_minflt: Vec::new(),
4144            share_frames: vec![n_frames],
4145            share_bytes: Vec::new(),
4146            phases: ReadPhaseTimes {
4147                worker_parse_s: elapsed,
4148                worker_sum_s: elapsed,
4149                worker_max_s: elapsed,
4150                ..ReadPhaseTimes::default()
4151            },
4152            #[cfg(feature = "parallel")]
4153            pool: None,
4154        });
4155    }
4156    let t_all = std::time::Instant::now();
4157    let t_open = std::time::Instant::now();
4158    let contents = crate::compression::read_file_contents(path)?;
4159    let open_mmap_s = t_open.elapsed().as_secs_f64();
4160    let mapped = matches!(contents, crate::compression::FileContents::Mapped(_));
4161    let n_bytes = match &contents {
4162        crate::compression::FileContents::Owned(text) => text.len(),
4163        crate::compression::FileContents::Mapped(map) => map.len(),
4164    };
4165    let bytes: &[u8] = match &contents {
4166        crate::compression::FileContents::Owned(text) => text.as_bytes(),
4167        crate::compression::FileContents::Mapped(map) => map,
4168    };
4169    let phased = frames_from_bytes_phased_located(bytes, num_threads)?;
4170    let workers = match num_threads {
4171        Some(n) if n >= 2 => n,
4172        _ => 1,
4173    };
4174    let t_close = std::time::Instant::now();
4175    if let crate::compression::FileContents::Mapped(map) = &contents {
4176        // The serial unmap of a fully resident file is the multi-worker
4177        // tail. Advise the pages away on the read's pool first.
4178        // One worker unmaps directly, so a serial read does not walk
4179        // the pages twice.
4180        if workers >= 2 {
4181            #[cfg(feature = "parallel")]
4182            {
4183                if let Some(pool) = phased.pool.as_ref() {
4184                    pool.install(|| {
4185                        crate::compression::advise_dontneed_parallel(map, workers);
4186                    });
4187                }
4188            }
4189            #[cfg(not(feature = "parallel"))]
4190            {
4191                let _ = (map, workers);
4192            }
4193        }
4194    }
4195    drop(contents);
4196    let close_s = t_close.elapsed().as_secs_f64();
4197    let elapsed = t_all.elapsed().as_secs_f64();
4198    let named = open_mmap_s
4199        + phased.pool_s
4200        + phased.pool_join_s
4201        + phased.scan_s
4202        + phased.slot_alloc_s
4203        + phased.worker_parse_s
4204        + phased.finish_s
4205        + close_s;
4206    let schedule_s = (elapsed - named).max(0.0);
4207    let parse_minflt = phased.worker_minflt.iter().sum();
4208    Ok(PhasedFrames {
4209        frames: phased.frames,
4210        n_bytes,
4211        chunk_s: phased.chunk_s,
4212        scan_kind: phased.scan_kind,
4213        worker_minflt: phased.worker_minflt,
4214        share_frames: phased.share_frames,
4215        share_bytes: phased.share_bytes,
4216        phases: ReadPhaseTimes {
4217            open_mmap_s,
4218            pool_s: phased.pool_s,
4219            pool_join_s: phased.pool_join_s,
4220            scan_s: phased.scan_s,
4221            slot_alloc_s: phased.slot_alloc_s,
4222            worker_parse_s: phased.worker_parse_s,
4223            finish_s: phased.finish_s,
4224            worker_sum_s: phased.worker_sum_s,
4225            worker_max_s: phased.worker_max_s,
4226            close_s,
4227            schedule_s,
4228            scan_minflt: phased.scan_minflt,
4229            parse_minflt,
4230            llc_misses: phased.llc_misses,
4231            numa_remote: phased.numa_remote,
4232            mapped,
4233            ..ReadPhaseTimes::default()
4234        },
4235        #[cfg(feature = "parallel")]
4236        pool: phased.pool,
4237    })
4238}
4239
4240/// [`frames_from_bytes`] with scan, slot allocation, and worker-parse times.
4241///
4242/// `Some(n)` with `n >= 2` uses that pool. Every other count, including
4243/// `None`, stays on the calling thread. That is the `read_con` policy:
4244/// an omitted count does not start a pool.
4245pub struct PhasedByteTimes {
4246    pub frames: Vec<types::ConFrame>,
4247    pub pool_s: f64,
4248    pub pool_join_s: f64,
4249    pub scan_s: f64,
4250    pub slot_alloc_s: f64,
4251    pub worker_parse_s: f64,
4252    pub finish_s: f64,
4253    pub worker_sum_s: f64,
4254    pub worker_max_s: f64,
4255    /// Per-chunk parse seconds, in claim order.
4256    pub chunk_s: Vec<f64>,
4257    /// [`SCAN_SERIAL`], [`SCAN_INDEXED`], or [`SCAN_EQUAL_SPAN`] when
4258    /// `parallel` is on. Zero otherwise.
4259    pub scan_kind: u8,
4260    /// Minor faults during the scan.
4261    pub scan_minflt: i64,
4262    /// Minor faults on each parse worker, in worker order.
4263    pub worker_minflt: Vec<i64>,
4264    pub share_frames: Vec<u64>,
4265    pub share_bytes: Vec<u64>,
4266    /// LLC read misses, or `-1` when the guest refuses the counter.
4267    pub llc_misses: i64,
4268    /// Pages on a node other than node 0.
4269    pub numa_remote: i64,
4270    /// Pool kept for the slab, the touch, and frame teardown.
4271    #[cfg(feature = "parallel")]
4272    pub pool: Option<std::sync::Arc<rayon::ThreadPool>>,
4273}
4274
4275/// The first frame declares at least 4096 atoms and the file is too
4276/// small to hold a second copy of that frame.
4277///
4278/// The row parser then owns the threads. A frame split would walk the
4279/// same lines and leave every atom on one worker.
4280#[cfg(feature = "parallel")]
4281fn declared_wide_frame(bytes: &[u8]) -> bool {
4282    let mut pos = 0usize;
4283    let mut natypes = 0usize;
4284    for line_no in 0..8 {
4285        if pos >= bytes.len() {
4286            return false;
4287        }
4288        let end = memchr::memchr(b'\n', &bytes[pos..])
4289            .map(|rel| pos + rel)
4290            .unwrap_or(bytes.len());
4291        let line = &bytes[pos..end];
4292        if line_no == 6 {
4293            natypes = match first_header_usize(line) {
4294                Some(n) if (1..=64).contains(&n) => n,
4295                _ => return false,
4296            };
4297        } else if line_no == 7 {
4298            let mut sum = 0usize;
4299            let mut seen = 0usize;
4300            for field in line
4301                .split(|byte| byte.is_ascii_whitespace())
4302                .filter(|field| !field.is_empty())
4303            {
4304                let Some(n) = first_header_usize(field) else {
4305                    return false;
4306                };
4307                if n == 0 {
4308                    return false;
4309                }
4310                sum = sum.saturating_add(n);
4311                seen += 1;
4312            }
4313            if seen != natypes || sum < 4096 {
4314                return false;
4315            }
4316            return bytes.len() <= sum.saturating_mul(64).saturating_add(4096);
4317        }
4318        pos = end.saturating_add(1);
4319    }
4320    false
4321}
4322
4323#[cfg(feature = "parallel")]
4324fn first_header_usize(line: &[u8]) -> Option<usize> {
4325    let field = line
4326        .split(|byte| byte.is_ascii_whitespace())
4327        .find(|field| !field.is_empty())?;
4328    std::str::from_utf8(field).ok()?.parse().ok()
4329}
4330
4331#[cfg(feature = "parallel")]
4332fn wide_frame_phased(
4333    bytes: &[u8],
4334    workers: usize,
4335) -> Result<PhasedByteTimes, error::LocatedParseError> {
4336    struct Reset;
4337    impl Drop for Reset {
4338        fn drop(&mut self) {
4339            crate::parser::set_wide_workers(1);
4340        }
4341    }
4342    crate::parser::set_wide_workers(workers);
4343    let _reset = Reset;
4344    let flt0 = thread_minflt();
4345    let t_work = std::time::Instant::now();
4346    let frames = collect_bytes_located(bytes)?;
4347    let worker_parse_s = t_work.elapsed().as_secs_f64();
4348    let worker_flt = thread_minflt().saturating_sub(flt0);
4349    let n_frames = frames.len() as u64;
4350    Ok(PhasedByteTimes {
4351        frames,
4352        pool_s: 0.0,
4353        pool_join_s: 0.0,
4354        scan_s: 0.0,
4355        slot_alloc_s: 0.0,
4356        worker_parse_s,
4357        finish_s: 0.0,
4358        worker_sum_s: worker_parse_s,
4359        worker_max_s: worker_parse_s,
4360        chunk_s: vec![worker_parse_s],
4361        scan_kind: SCAN_SERIAL,
4362        scan_minflt: 0,
4363        worker_minflt: vec![worker_flt],
4364        share_frames: vec![n_frames],
4365        share_bytes: vec![bytes.len() as u64],
4366        llc_misses: sample_llc_misses(),
4367        numa_remote: numa_remote_pages(),
4368        pool: None,
4369    })
4370}
4371
4372pub fn frames_from_bytes_phased(
4373    bytes: &[u8],
4374    num_threads: Option<usize>,
4375) -> Result<PhasedByteTimes, error::ParseError> {
4376    frames_from_bytes_phased_located(bytes, num_threads).map_err(|err| err.error)
4377}
4378
4379fn frames_from_bytes_phased_located(
4380    bytes: &[u8],
4381    num_threads: Option<usize>,
4382) -> Result<PhasedByteTimes, error::LocatedParseError> {
4383    #[cfg(feature = "parallel")]
4384    {
4385        // One thread walks the file once. A scan plus a second parse made
4386        // a short file slower than the plain iterator.
4387        if !matches!(num_threads, Some(n) if n >= 2) {
4388            let flt0 = thread_minflt();
4389            let t_work = std::time::Instant::now();
4390            let frames = collect_bytes_located(bytes)?;
4391            let worker_parse_s = t_work.elapsed().as_secs_f64();
4392            let worker_flt = thread_minflt().saturating_sub(flt0);
4393            let n_frames = frames.len() as u64;
4394            return Ok(PhasedByteTimes {
4395                frames,
4396                pool_s: 0.0,
4397                pool_join_s: 0.0,
4398                scan_s: 0.0,
4399                slot_alloc_s: 0.0,
4400                worker_parse_s,
4401                finish_s: 0.0,
4402                worker_sum_s: worker_parse_s,
4403                worker_max_s: worker_parse_s,
4404                chunk_s: vec![worker_parse_s],
4405                scan_kind: SCAN_SERIAL,
4406                scan_minflt: 0,
4407                worker_minflt: vec![worker_flt],
4408                share_frames: vec![n_frames],
4409                share_bytes: vec![bytes.len() as u64],
4410                llc_misses: sample_llc_misses(),
4411                numa_remote: numa_remote_pages(),
4412                pool: None,
4413            });
4414        }
4415        // One wide frame has nothing for a frame split to divide. The
4416        // boundary walk would read every atom line and then the parse
4417        // would read them again, on a second pool. Parse it once, with
4418        // the row workers on the pinned pool.
4419        if let Some(workers) = num_threads.filter(|n| *n >= 2 && declared_wide_frame(bytes)) {
4420            return wide_frame_phased(bytes, workers);
4421        }
4422        let requested = Some(num_threads.expect("workers"));
4423        let timed = parse_bytes_timed(bytes, requested);
4424        let t_finish = std::time::Instant::now();
4425        let frames = if timed.slots.iter().any(|item| item.is_err()) {
4426            collect_located_slots(timed.slots, &timed.fail_lines)?
4427        } else {
4428            finish_results(timed.pool.as_deref(), timed.slots)
4429                .map_err(|error| error::LocatedParseError::new(error, 0, 0))?
4430        };
4431        let finish_s = t_finish.elapsed().as_secs_f64();
4432        Ok(PhasedByteTimes {
4433            frames,
4434            pool_s: timed.pool_s,
4435            pool_join_s: timed.pool_join_s,
4436            scan_s: timed.scan_s,
4437            slot_alloc_s: timed.slot_alloc_s,
4438            worker_parse_s: timed.worker_parse_s,
4439            finish_s,
4440            worker_sum_s: timed.worker_sum_s,
4441            worker_max_s: timed.worker_max_s,
4442            chunk_s: timed.chunk_s,
4443            scan_kind: timed.scan_kind,
4444            scan_minflt: timed.scan_minflt,
4445            worker_minflt: timed.worker_minflt,
4446            share_frames: timed.share_frames,
4447            share_bytes: timed.share_bytes,
4448            llc_misses: timed.llc_misses,
4449            numa_remote: timed.numa_remote,
4450            pool: timed.pool,
4451        })
4452    }
4453    #[cfg(not(feature = "parallel"))]
4454    {
4455        let _ = num_threads;
4456        let t_work = std::time::Instant::now();
4457        let frames = collect_bytes_located(bytes)?;
4458        let worker_parse_s = t_work.elapsed().as_secs_f64();
4459        Ok(PhasedByteTimes {
4460            frames,
4461            pool_s: 0.0,
4462            pool_join_s: 0.0,
4463            scan_s: 0.0,
4464            slot_alloc_s: 0.0,
4465            worker_parse_s,
4466            finish_s: 0.0,
4467            worker_sum_s: worker_parse_s,
4468            worker_max_s: worker_parse_s,
4469            chunk_s: vec![worker_parse_s],
4470            scan_kind: 0,
4471            scan_minflt: 0,
4472            worker_minflt: Vec::new(),
4473            share_frames: Vec::new(),
4474            share_bytes: Vec::new(),
4475            llc_misses: -1,
4476            numa_remote: 0,
4477        })
4478    }
4479}
4480
4481/// Copy every frame's coordinates into one row-major buffer.
4482///
4483/// `None` means the frames do not share one atom count. `workers >= 2`
4484/// writes disjoint frame runs. The copy is bit-identical to reading
4485/// each frame's `f64` positions in order.
4486pub fn pack_position_slab(frames: &[types::ConFrame], workers: usize) -> Option<Vec<f64>> {
4487    if frames.is_empty() {
4488        return None;
4489    }
4490    let n_atoms = frames[0].positions.nrows();
4491    if frames
4492        .iter()
4493        .any(|frame| frame.positions.nrows() != n_atoms)
4494    {
4495        return None;
4496    }
4497    let stride = n_atoms.saturating_mul(3);
4498    let total = frames.len().saturating_mul(stride);
4499    let mut values = Vec::<f64>::with_capacity(total);
4500    if total > 0 {
4501        // The parallel writers are the first touch of these pages.
4502        // A zeroed allocation would fault the whole slab on this thread.
4503        // SAFETY: every index is overwritten before the buffer is read.
4504        unsafe {
4505            values.set_len(total);
4506        }
4507    }
4508    if stride == 0 {
4509        return Some(values);
4510    }
4511    let write = |frame: &types::ConFrame, dest: &mut [f64]| {
4512        if let Some(src) = frame.positions.f64_slice() {
4513            dest.copy_from_slice(src);
4514        } else {
4515            for (row, slot) in dest.as_chunks_mut::<3>().0.iter_mut().enumerate() {
4516                slot.copy_from_slice(&frame.positions.as_f64_row(row));
4517            }
4518        }
4519    };
4520    #[cfg(feature = "parallel")]
4521    {
4522        if workers >= 2 {
4523            let chunk_frames = frames.len().div_ceil(workers).max(1);
4524            let chunk_vals = chunk_frames.saturating_mul(stride);
4525            let copy_chunks = |dest_all: &mut [f64]| {
4526                use rayon::prelude::*;
4527                dest_all
4528                    .par_chunks_mut(chunk_vals)
4529                    .enumerate()
4530                    .for_each(|(chunk_index, dest)| {
4531                        let base = chunk_index * chunk_frames;
4532                        for (offset, slot) in dest.chunks_exact_mut(stride).enumerate() {
4533                            let index = base + offset;
4534                            if index < frames.len() {
4535                                write(&frames[index], slot);
4536                            }
4537                        }
4538                    });
4539            };
4540            // `par_chunks_mut` follows the installed pool. Outside one,
4541            // a private scope of `workers` threads keeps the global pool
4542            // out of this phase.
4543            if rayon::current_thread_index().is_some() {
4544                first_touch_f64(&mut values, workers);
4545                copy_chunks(&mut values);
4546            } else {
4547                first_touch_f64(&mut values, workers);
4548                std::thread::scope(|scope| {
4549                    let mut rest = &mut values[..];
4550                    let mut base = 0usize;
4551                    let mut handles = Vec::new();
4552                    while base < frames.len() {
4553                        let take_frames = chunk_frames.min(frames.len() - base);
4554                        let take_vals = take_frames * stride;
4555                        let (dest, tail) = rest.split_at_mut(take_vals);
4556                        rest = tail;
4557                        let frame_slice = &frames[base..base + take_frames];
4558                        handles.push(scope.spawn(move || {
4559                            for (frame, slot) in
4560                                frame_slice.iter().zip(dest.chunks_exact_mut(stride))
4561                            {
4562                                write(frame, slot);
4563                            }
4564                        }));
4565                        base += take_frames;
4566                    }
4567                    for handle in handles {
4568                        handle.join().expect("slab worker");
4569                    }
4570                });
4571            }
4572            return Some(values);
4573        }
4574    }
4575    #[cfg(not(feature = "parallel"))]
4576    {
4577        let _ = workers;
4578    }
4579    for (frame, dest) in frames.iter().zip(values.chunks_exact_mut(stride)) {
4580        write(frame, dest);
4581    }
4582    Some(values)
4583}
4584
4585fn mix_touch(hash: u64, value: u64) -> u64 {
4586    hash.wrapping_mul(0x9E3779B185EBCA87) ^ value
4587}
4588
4589fn one_frame_touch(frame: &types::ConFrame, coords: &[f64], index: u64) -> u64 {
4590    let mut hash = index;
4591    for value in frame.header.boxl {
4592        hash = mix_touch(hash, value.to_bits());
4593    }
4594    for atom in &frame.atom_data {
4595        hash = mix_touch(hash, atom.symbol.len() as u64);
4596        for byte in atom.symbol.as_bytes() {
4597            hash = mix_touch(hash, u64::from(*byte));
4598        }
4599    }
4600    for value in coords {
4601        hash = mix_touch(hash, value.to_bits());
4602    }
4603    hash
4604}
4605
4606/// XOR of positions, cell lengths, and atom-type symbols.
4607///
4608/// `positions` is frame-major `[frames, atoms, 3]`. `None` means the
4609/// slice does not match the frames. `workers >= 2` folds disjoint
4610/// frame runs on that many threads. Each frame's hash includes its
4611/// index, and the returned word is the XOR of those hashes.
4612pub fn touch_frame_checksum(
4613    frames: &[types::ConFrame],
4614    positions: &[f64],
4615    workers: usize,
4616) -> Option<u64> {
4617    if frames.is_empty() {
4618        return Some(0);
4619    }
4620    let n_atoms = frames[0].atom_data.len();
4621    let stride = n_atoms.saturating_mul(3);
4622    if stride == 0 || positions.len() != frames.len().saturating_mul(stride) {
4623        return None;
4624    }
4625    if frames.iter().any(|frame| frame.atom_data.len() != n_atoms) {
4626        return None;
4627    }
4628    let one = |index: usize| {
4629        let start = index * stride;
4630        one_frame_touch(
4631            &frames[index],
4632            &positions[start..start + stride],
4633            index as u64,
4634        )
4635    };
4636    Some(reduce_touch(frames.len(), workers, one))
4637}
4638
4639/// [`touch_frame_checksum`] for frames still sitting in optional slots.
4640///
4641/// `None` means a slot is empty or the position slice does not match.
4642pub fn touch_option_checksum(
4643    frames: &[Option<types::ConFrame>],
4644    positions: &[f64],
4645    workers: usize,
4646) -> Option<u64> {
4647    if frames.is_empty() {
4648        return Some(0);
4649    }
4650    if frames.iter().any(Option::is_none) {
4651        return None;
4652    }
4653    let n_atoms = frames[0].as_ref()?.atom_data.len();
4654    let stride = n_atoms.saturating_mul(3);
4655    if stride == 0 || positions.len() != frames.len().saturating_mul(stride) {
4656        return None;
4657    }
4658    if frames
4659        .iter()
4660        .any(|frame| frame.as_ref().map(|item| item.atom_data.len()) != Some(n_atoms))
4661    {
4662        return None;
4663    }
4664    let one = |index: usize| {
4665        let start = index * stride;
4666        one_frame_touch(
4667            frames[index].as_ref().expect("slot"),
4668            &positions[start..start + stride],
4669            index as u64,
4670        )
4671    };
4672    Some(reduce_touch(frames.len(), workers, one))
4673}
4674
4675fn reduce_touch(n: usize, workers: usize, one: impl Fn(usize) -> u64 + Sync) -> u64 {
4676    #[cfg(feature = "parallel")]
4677    if workers >= 2 {
4678        let fold_range = |first: usize, last: usize| {
4679            let mut acc = 0u64;
4680            for index in first..last {
4681                acc ^= one(index);
4682            }
4683            acc
4684        };
4685        let chunk = n.div_ceil(workers).max(1);
4686        // Reuse the installed read pool. A fresh pool per touch is the
4687        // build this phase used to pay on every call.
4688        if rayon::current_thread_index().is_some() {
4689            use rayon::prelude::*;
4690            return (0..workers)
4691                .into_par_iter()
4692                .map(|worker| {
4693                    let first = worker * chunk;
4694                    let last = (first + chunk).min(n);
4695                    fold_range(first, last)
4696                })
4697                .reduce(|| 0u64, |left, right| left ^ right);
4698        }
4699        let mut acc = 0u64;
4700        std::thread::scope(|scope| {
4701            let mut handles = Vec::with_capacity(workers);
4702            for worker in 0..workers {
4703                handles.push(scope.spawn(move || {
4704                    let first = worker * chunk;
4705                    let last = (first + chunk).min(n);
4706                    fold_range(first, last)
4707                }));
4708            }
4709            for handle in handles {
4710                acc ^= handle.join().unwrap_or(0);
4711            }
4712        });
4713        return acc;
4714    }
4715    #[cfg(not(feature = "parallel"))]
4716    {
4717        let _ = workers;
4718    }
4719    (0..n).map(one).fold(0, |acc, item| acc ^ item)
4720}
4721
4722/// Time a newline scan of `text`.
4723///
4724/// `chunked` with `threads >= 2` splits the buffer into that many
4725/// chunks, stitches the newline index, and walks frame starts. The
4726/// pool is built before the timer starts. The serial walk is
4727/// [`frame_start_offsets_serial`]. The returned offsets are the frame
4728/// starts. The caller chooses the worker count. File length does not.
4729pub fn measure_newline_scan(text: &str, threads: usize, chunked: bool) -> (Vec<usize>, f64) {
4730    #[cfg(feature = "parallel")]
4731    {
4732        if chunked && threads >= 2 {
4733            let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build();
4734            let t_scan = std::time::Instant::now();
4735            let offsets = match pool {
4736                Ok(pool) => pool.install(|| {
4737                    let newlines = newline_positions(text.as_bytes(), threads);
4738                    frame_start_offsets_indexed(text, newlines)
4739                }),
4740                Err(_) => {
4741                    let newlines = newline_positions(text.as_bytes(), threads);
4742                    frame_start_offsets_indexed(text, newlines)
4743                }
4744            };
4745            return (offsets, t_scan.elapsed().as_secs_f64());
4746        }
4747    }
4748    #[cfg(not(feature = "parallel"))]
4749    {
4750        let _ = (threads, chunked);
4751    }
4752    let t_scan = std::time::Instant::now();
4753    let offsets = frame_start_offsets_serial(text);
4754    (offsets, t_scan.elapsed().as_secs_f64())
4755}
4756
4757/// Parallel parse that keeps the file line of the first error.
4758/// Workers return the error. They do not write thread-local error state.
4759#[cfg(feature = "parallel")]
4760fn collect_parallel_located(
4761    file_contents: &str,
4762    num_threads: Option<usize>,
4763) -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
4764    use rayon::prelude::*;
4765
4766    let boundaries = frame_start_offsets(file_contents);
4767    let parse_chunks = || -> Result<Vec<types::ConFrame>, error::LocatedParseError> {
4768        let num_frames = boundaries.len();
4769        let parts: Vec<Result<types::ConFrame, error::LocatedParseError>> = (0..num_frames)
4770            .into_par_iter()
4771            .map(|i| {
4772                let start = boundaries[i];
4773                let end = if i + 1 < num_frames {
4774                    boundaries[i + 1]
4775                } else {
4776                    file_contents.len()
4777                };
4778                let chunk = &file_contents[start..end];
4779                let mut iter = ConFrameIterator::new(chunk);
4780                match iter.next() {
4781                    Some(Ok(frame)) => Ok(frame),
4782                    Some(Err(e)) => {
4783                        let local = iter.lines.line_no();
4784                        let base = file_line_at(file_contents, start);
4785                        let cursor = if local == 0 { base } else { base + local - 1 };
4786                        Err(error::LocatedParseError::new(e, cursor, i as u64))
4787                    }
4788                    None => Err(error::LocatedParseError::new(
4789                        error::ParseError::IncompleteFrame,
4790                        file_line_at(file_contents, start),
4791                        i as u64,
4792                    )),
4793                }
4794            })
4795            .collect();
4796        let mut frames = Vec::with_capacity(parts.len());
4797        for part in parts {
4798            frames.push(part?);
4799        }
4800        Ok(frames)
4801    };
4802    match num_threads {
4803        None => parse_chunks(),
4804        Some(n) => {
4805            let n = n.max(1);
4806            match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
4807                Ok(pool) => pool.install(parse_chunks),
4808                Err(_) => parse_chunks(),
4809            }
4810        }
4811    }
4812}
4813
4814#[cfg(all(test, feature = "parallel"))]
4815mod parallel_strong_scale_tests {
4816    use super::*;
4817    use std::path::PathBuf;
4818
4819    fn multi_frame_fixture() -> String {
4820        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con");
4821        let one = std::fs::read_to_string(p).expect("fixture");
4822        // Enough frames for >1 worker to exercise the pool.
4823        one.repeat(8)
4824    }
4825
4826    fn sequential_frames(text: &str) -> Vec<types::ConFrame> {
4827        ConFrameIterator::new(text)
4828            .map(|r| r.expect("seq frame"))
4829            .collect()
4830    }
4831
4832    fn frames_payload_key(f: &types::ConFrame) -> (usize, Vec<(String, f64, f64, f64)>) {
4833        let atoms: Vec<_> = f
4834            .atom_data
4835            .iter()
4836            .map(|a| (a.symbol.to_string(), a.x(), a.y(), a.z()))
4837            .collect();
4838        (f.atom_data.len(), atoms)
4839    }
4840
4841    #[test]
4842    fn parallel_workers_match_sequential_payloads() {
4843        let text = multi_frame_fixture();
4844        let seq = sequential_frames(&text);
4845        assert!(seq.len() >= 8);
4846        let seq_keys: Vec<_> = seq.iter().map(frames_payload_key).collect();
4847
4848        for workers in [1usize, 2, 4] {
4849            let par = parse_frames_parallel_with_threads(&text, Some(workers));
4850            assert_eq!(par.len(), seq.len(), "workers={workers}");
4851            let par_keys: Vec<_> = par
4852                .into_iter()
4853                .map(|r| frames_payload_key(&r.expect("par frame")))
4854                .collect();
4855            assert_eq!(par_keys, seq_keys, "workers={workers} frame payloads");
4856        }
4857
4858        // Global pool path agrees too.
4859        let par_default = parse_frames_parallel(&text);
4860        let def_keys: Vec<_> = par_default
4861            .into_iter()
4862            .map(|r| frames_payload_key(&r.expect("par")))
4863            .collect();
4864        assert_eq!(def_keys, seq_keys);
4865    }
4866
4867    fn live_threads() -> usize {
4868        std::fs::read_to_string("/proc/self/status")
4869            .unwrap()
4870            .lines()
4871            .find(|line| line.starts_with("Threads:"))
4872            .unwrap()
4873            .split_whitespace()
4874            .nth(1)
4875            .unwrap()
4876            .parse()
4877            .unwrap()
4878    }
4879
4880    /// A file past the old map and pool gates stays on this thread.
4881    #[test]
4882    fn default_read_of_a_wide_file_stays_on_the_calling_thread() {
4883        const CHILD: &str = "READCON_DEFAULT_THREAD_CHILD";
4884        const NAME: &str = "iterators::parallel_strong_scale_tests::default_read_of_a_wide_file_stays_on_the_calling_thread";
4885        if std::env::var_os(CHILD).is_none() {
4886            let out = std::process::Command::new(std::env::current_exe().unwrap())
4887                .args([NAME, "--exact", "--test-threads=1"])
4888                .env(CHILD, "1")
4889                .output()
4890                .unwrap();
4891            let stdout = String::from_utf8_lossy(&out.stdout);
4892            assert!(
4893                out.status.success() && stdout.contains("test result: ok. 1 passed;"),
4894                "{stdout}{}",
4895                String::from_utf8_lossy(&out.stderr)
4896            );
4897            return;
4898        }
4899        let dir = tempfile::tempdir().unwrap();
4900        let path = dir.path().join("wide.con");
4901        let one = std::fs::read_to_string(
4902            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con"),
4903        )
4904        .unwrap();
4905        std::fs::write(&path, one.repeat(200)).unwrap();
4906        assert!(std::fs::metadata(&path).unwrap().len() > 64 * 1024);
4907        let before = live_threads();
4908        let frames = super::read_all_frames(&path).unwrap();
4909        let after = live_threads();
4910        assert_eq!(before, after, "default read started worker threads");
4911        assert!(frames.len() > 1);
4912        let text = std::fs::read_to_string(&path).unwrap();
4913        let sequential = super::frames_from_text(&text, None).unwrap();
4914        assert_eq!(sequential.len(), frames.len());
4915        assert_eq!(
4916            sequential[0].atom_data[0].x(),
4917            frames[0].atom_data[0].x(),
4918            "default read changed a coordinate"
4919        );
4920    }
4921}
4922
4923#[cfg(all(test, feature = "parallel"))]
4924mod parallel_header_scan_tests {
4925    use super::*;
4926    use std::path::PathBuf;
4927
4928    fn fixture(name: &str) -> String {
4929        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4930            .join("resources/test")
4931            .join(name);
4932        std::fs::read_to_string(path).expect(name)
4933    }
4934
4935    /// A cut on the byte between `\r` and `\n`. The `\n` is in the next chunk.
4936    fn assert_crlf_gap_cut(text: &str) {
4937        let bytes = text.as_bytes();
4938        let mut found = 0usize;
4939        let mut i = 0usize;
4940        while i + 1 < bytes.len() && found < 6 {
4941            if bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
4942                let cut = i + 1;
4943                assert_eq!(bytes[cut - 1], b'\r');
4944                assert_eq!(bytes[cut], b'\n');
4945                assert_cuts_match(text, &[0, cut, bytes.len()]);
4946                found += 1;
4947                i += 2;
4948                continue;
4949            }
4950            i += 1;
4951        }
4952        assert!(found > 0, "fixture has no CRLF pair");
4953    }
4954
4955    fn assert_cuts_match(text: &str, cuts: &[usize]) {
4956        let serial_nl: Vec<usize> = memchr::memchr_iter(b'\n', text.as_bytes()).collect();
4957        let stitched = newline_positions_at_cuts(text.as_bytes(), cuts);
4958        assert_eq!(stitched, serial_nl, "newline stitch at {cuts:?}");
4959        let serial = frame_start_offsets_serial(text);
4960        let parallel = frame_start_offsets_at_cuts(text, cuts);
4961        assert_eq!(parallel, serial, "frame offsets at {cuts:?}");
4962    }
4963
4964    fn header_straddle_cuts(text: &str) -> Vec<Vec<usize>> {
4965        let serial = frame_start_offsets_serial(text);
4966        let bytes = text.as_bytes();
4967        let mut out = Vec::new();
4968        // Equal splits, plus a cut forced into a header line of every frame.
4969        for n in [2usize, 3, 4, 8] {
4970            out.push(equal_cuts(bytes.len(), n));
4971        }
4972        for (index, &start) in serial.iter().enumerate() {
4973            let next = serial.get(index + 1).copied().unwrap_or(bytes.len());
4974            // The header ends at the coordinate label. The window is that
4975            // header, not a fixed count of bytes past the frame start.
4976            let window = super::header_window_end(bytes, start, next);
4977            let mut line = start;
4978            while line < window {
4979                let Some(rel) = memchr::memchr(b'\n', &bytes[line..window]) else {
4980                    break;
4981                };
4982                let end = line + rel;
4983                if end - line > 1 {
4984                    let mid = line + (end - line) / 2;
4985                    // mid is inside the line, so the header byte sits across two chunks.
4986                    assert!(mid > line && mid < end);
4987                    out.push(vec![0, mid, bytes.len()]);
4988                }
4989                line = end + 1;
4990            }
4991        }
4992        out
4993    }
4994
4995    fn assert_slab(text: &str) {
4996        assert!(!text.is_empty());
4997        for cuts in header_straddle_cuts(text) {
4998            assert_cuts_match(text, &cuts);
4999        }
5000        let bare = text.trim_end_matches(['\n', '\r']);
5001        if !bare.is_empty() {
5002            assert_cuts_match(bare, &equal_cuts(bare.len(), 4));
5003            let mid = bare.len() / 2;
5004            let line_start = bare[..mid].rfind('\n').map(|i| i + 1).unwrap_or(0);
5005            let line_end = bare[mid..]
5006                .find('\n')
5007                .map(|i| mid + i)
5008                .unwrap_or(bare.len());
5009            if line_end - line_start > 1 {
5010                let cut = line_start + (line_end - line_start) / 2;
5011                assert_cuts_match(bare, &[0, cut, bare.len()]);
5012            }
5013        }
5014        let tailed = format!("{text}\n   \n\t\n");
5015        assert_cuts_match(
5016            &tailed,
5017            &[0, 3, tailed.len() / 2, tailed.len() - 1, tailed.len()],
5018        );
5019        let crlf = text.replace('\n', "\r\n");
5020        assert_cuts_match(&crlf, &equal_cuts(crlf.len(), 5));
5021        assert_crlf_gap_cut(&crlf);
5022        let serial = frame_start_offsets_serial(text);
5023        if serial.len() >= 2 {
5024            let second = serial[1];
5025            let next = serial.get(2).copied().unwrap_or(text.len());
5026            let broken_at = second + (next - second) / 2;
5027            let broken = &text[..broken_at.max(1)];
5028            assert_cuts_match(broken, &[0, 1.max(broken.len() / 3), broken.len()]);
5029        }
5030    }
5031
5032    #[test]
5033    fn chunked_header_scan_matches_serial_offsets() {
5034        assert_eq!(frame_start_offsets_serial(""), frame_start_offsets(""));
5035        assert_cuts_match("\n", &[0, 1]);
5036        assert_cuts_match("   \n\n", &[0, 2, 5]);
5037        for name in [
5038            "tiny_cuh2.con",
5039            "tiny_cuh2.convel",
5040            "tiny_cuh2_forces.con",
5041            "tiny_cuh2_vel_forces.con",
5042            "cuh2.con",
5043        ] {
5044            let one = fixture(name);
5045            assert_slab(&one);
5046            assert_slab(&one.repeat(3));
5047        }
5048        // The bytes parser (the read_con path) uses the same stitch.
5049        // The single frame is the short file. The repeat is a wider file.
5050        // Both use the requested worker count. There is no length cutoff.
5051        let one = fixture("cuh2.con");
5052        for wide in [one.clone(), one.repeat(8)] {
5053            let serial = frame_start_offsets_serial(&wide);
5054            for workers in [2usize, 3, 4, 8] {
5055                let parallel = frame_start_offsets_with_threads(&wide, Some(workers));
5056                assert_eq!(parallel, serial, "workers={workers} bytes={}", wide.len());
5057                let parsed =
5058                    frames_from_bytes(wide.as_bytes(), Some(workers)).expect("bytes parse");
5059                assert_eq!(parsed.len(), serial.len());
5060                let seq = frames_from_text(&wide, Some(1)).expect("serial parse");
5061                assert_eq!(parsed.len(), seq.len());
5062                assert_eq!(parsed[0].atom_data[0].x(), seq[0].atom_data[0].x());
5063                assert_eq!(
5064                    parsed.last().unwrap().atom_data.last().unwrap().z(),
5065                    seq.last().unwrap().atom_data.last().unwrap().z()
5066                );
5067            }
5068        }
5069    }
5070}
5071
5072/// End of the header window used by straddle cuts.
5073///
5074/// The header runs up to the line that starts the coordinate block.
5075/// When that label is absent the window is the next frame start.
5076#[cfg(all(test, feature = "parallel"))]
5077fn header_window_end(bytes: &[u8], start: usize, limit: usize) -> usize {
5078    let limit = limit.min(bytes.len());
5079    let marker = b"Coordinates";
5080    let mut line = start;
5081    while line < limit {
5082        let rest = &bytes[line..limit];
5083        let rel = memchr::memchr(b'\n', rest).unwrap_or(rest.len());
5084        let content_end = line + rel;
5085        let content = &bytes[line..content_end];
5086        let trimmed = match content.iter().position(|byte| !byte.is_ascii_whitespace()) {
5087            Some(index) => &content[index..],
5088            None => b"",
5089        };
5090        if trimmed.starts_with(marker) {
5091            return line;
5092        }
5093        if content_end >= limit {
5094            break;
5095        }
5096        line = content_end + 1;
5097    }
5098    limit
5099}
5100
5101#[cfg(all(test, feature = "parallel"))]
5102mod parallel_frame_parse_tests {
5103    use super::*;
5104    use std::path::PathBuf;
5105
5106    const ZERO_ATOM: &str = "\
5107zero-atom frame
5108{\"con_spec_version\":3,\"con_spec_minor\":1,\"units\":{\"length\":\"angstrom\",\"energy\":\"eV\"}}
510910.0 10.0 10.0
511090.0 90.0 90.0
5111
5112
51130
5114
5115
5116";
5117
5118    fn fixture(name: &str) -> String {
5119        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5120            .join("resources/test")
5121            .join(name);
5122        std::fs::read_to_string(path).expect(name)
5123    }
5124
5125    fn serial_results(text: &str) -> Vec<Result<types::ConFrame, error::ParseError>> {
5126        ConFrameIterator::new(text).collect()
5127    }
5128
5129    fn written(frame: &types::ConFrame) -> Vec<u8> {
5130        let mut out = Vec::new();
5131        {
5132            let mut writer = crate::writer::ConFrameWriter::new(&mut out);
5133            writer.write_frame(frame).expect("write");
5134            writer.flush().expect("flush");
5135        }
5136        out
5137    }
5138
5139    fn assert_same_results(
5140        label: &str,
5141        serial: Vec<Result<types::ConFrame, error::ParseError>>,
5142        parallel: Vec<Result<types::ConFrame, error::ParseError>>,
5143    ) {
5144        assert_eq!(serial.len(), parallel.len(), "{label} count");
5145        for (i, (left, right)) in serial.into_iter().zip(parallel).enumerate() {
5146            match (left, right) {
5147                (Ok(a), Ok(b)) => {
5148                    assert_eq!(written(&a), written(&b), "{label} bytes frame {i}");
5149                    assert_eq!(a, b, "{label} fields frame {i}");
5150                }
5151                (Err(a), Err(b)) => {
5152                    assert_eq!(
5153                        format!("{a:?}"),
5154                        format!("{b:?}"),
5155                        "{label} error frame {i}"
5156                    );
5157                }
5158                (Ok(_), Err(err)) => panic!("{label} frame {i} serial ok parallel {err:?}"),
5159                (Err(err), Ok(_)) => panic!("{label} frame {i} serial {err:?} parallel ok"),
5160            }
5161        }
5162    }
5163
5164    fn assert_workers(label: &str, text: &str) {
5165        let serial_ok = serial_results(text).iter().all(|item| item.is_ok());
5166        for workers in [1usize, 2, 3, 4, 8] {
5167            let parallel = parse_frames_parallel_with_threads(text, Some(workers));
5168            assert_same_results(
5169                &format!("{label} workers {workers}"),
5170                serial_results(text),
5171                parallel,
5172            );
5173            let bounds = collect_frame_starts(ConFrameIterator::new(text), true);
5174            let at = parse_frames_at_offsets(text, &bounds, Some(workers));
5175            assert_same_results(
5176                &format!("{label} offsets workers {workers}"),
5177                serial_results(text),
5178                at,
5179            );
5180        }
5181        if serial_ok {
5182            for workers in [2usize, 3, 8] {
5183                let bytes = frames_from_bytes(text.as_bytes(), Some(workers)).expect(label);
5184                let seq = frames_from_text(text, Some(1)).expect(label);
5185                assert_eq!(bytes.len(), seq.len(), "{label} byte count");
5186                for (i, (a, b)) in bytes.iter().zip(seq.iter()).enumerate() {
5187                    assert_eq!(written(a), written(b), "{label} byte path frame {i}");
5188                    assert_eq!(a, b, "{label} byte path fields frame {i}");
5189                }
5190            }
5191        }
5192    }
5193
5194    fn crlf_gap_offsets(text: &str) -> Vec<usize> {
5195        let bytes = text.as_bytes();
5196        let mut cut = None;
5197        let mut i = 0usize;
5198        while i + 1 < bytes.len() {
5199            if bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
5200                cut = Some(i + 1);
5201                break;
5202            }
5203            i += 1;
5204        }
5205        let cut = cut.expect("crlf");
5206        frame_start_offsets_at_cuts(text, &[0, cut, bytes.len()])
5207    }
5208
5209    #[test]
5210    fn parallel_slots_match_serial_frames() {
5211        assert_workers("empty", "");
5212        assert_workers("blank", "\n");
5213        assert_workers("space", "   \n\n");
5214        assert_workers("zero", ZERO_ATOM);
5215        assert_workers("zero-repeat", &ZERO_ATOM.repeat(5));
5216        for name in [
5217            "tiny_cuh2.con",
5218            "tiny_cuh2.convel",
5219            "tiny_cuh2_forces.con",
5220            "tiny_cuh2_vel_forces.con",
5221            "tiny_cuh2_charges_spins_magmoms.con",
5222            "tiny_cuh2_displacements.con",
5223            "tiny_cuh2_spreads.con",
5224            "cuh2.con",
5225        ] {
5226            let one = fixture(name);
5227            assert_workers(name, &one);
5228            assert_workers(&format!("{name} x5"), &one.repeat(5));
5229            let bare = one.trim_end_matches(['\n', '\r']);
5230            assert_workers(&format!("{name} bare"), bare);
5231            let tailed = format!("{one}\n   \n\t\n");
5232            assert_workers(&format!("{name} tail"), &tailed);
5233            let crlf = one.replace('\n', "\r\n");
5234            assert_workers(&format!("{name} crlf"), &crlf);
5235            let gap = crlf_gap_offsets(&crlf);
5236            assert_eq!(
5237                gap,
5238                frame_start_offsets_serial(&crlf),
5239                "{name} crlf gap offsets"
5240            );
5241            let parsed = parse_frames_at_offsets(&crlf, &gap, Some(4));
5242            assert_same_results(
5243                &format!("{name} crlf gap parse"),
5244                serial_results(&crlf),
5245                parsed,
5246            );
5247            let serial_offs = frame_start_offsets_serial(&one);
5248            let broken: &str = if serial_offs.len() >= 2 {
5249                let second = serial_offs[1];
5250                let next = serial_offs.get(2).copied().unwrap_or(one.len());
5251                &one[..second + (next - second) / 2]
5252            } else if one.len() > 8 {
5253                &one[..one.len() / 2]
5254            } else {
5255                ""
5256            };
5257            if !broken.is_empty() {
5258                let serial_err = frames_from_text(broken, Some(1))
5259                    .err()
5260                    .map(|e| format!("{e:?}"));
5261                assert!(serial_err.is_some(), "{name} chopped still parsed");
5262                for workers in [1usize, 2, 4, 8] {
5263                    let parallel_err = frames_from_text(broken, Some(workers))
5264                        .err()
5265                        .map(|e| format!("{e:?}"));
5266                    assert_eq!(parallel_err, serial_err, "{name} chopped workers {workers}");
5267                    let byte_err = frames_from_bytes(broken.as_bytes(), Some(workers))
5268                        .err()
5269                        .map(|e| format!("{e:?}"));
5270                    assert_eq!(
5271                        byte_err, serial_err,
5272                        "{name} chopped bytes workers {workers}"
5273                    );
5274                }
5275            }
5276        }
5277    }
5278}
5279
5280#[cfg(all(test, feature = "parallel"))]
5281mod phased_read_tests {
5282    use super::*;
5283    use std::path::PathBuf;
5284
5285    fn fixture(name: &str) -> String {
5286        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5287            .join("resources/test")
5288            .join(name);
5289        std::fs::read_to_string(path).expect(name)
5290    }
5291
5292    #[test]
5293    fn phased_bytes_match_serial_frames() {
5294        let text = fixture("cuh2.con").repeat(4);
5295        let serial = frames_from_text(&text, Some(1)).expect("serial");
5296        for workers in [1usize, 2, 4, 8] {
5297            let phased = frames_from_bytes_phased(text.as_bytes(), Some(workers)).expect("phased");
5298            let parallel = phased.frames;
5299            assert_eq!(parallel.len(), serial.len(), "workers={workers}");
5300            assert!(
5301                phased.scan_s >= 0.0 && phased.slot_alloc_s >= 0.0 && phased.worker_parse_s >= 0.0
5302            );
5303            assert_eq!(parallel[0], serial[0], "workers={workers} first");
5304            assert_eq!(
5305                parallel.last().unwrap(),
5306                serial.last().unwrap(),
5307                "workers={workers} last"
5308            );
5309            let slab = pack_position_slab(&serial, workers).expect("slab");
5310            let stride = serial[0].positions.nrows() * 3;
5311            let src = serial[0].positions.f64_slice().expect("f64");
5312            assert_eq!(&slab[..stride], src);
5313            let (chunked, _) = measure_newline_scan(&text, workers.max(2), true);
5314            let (plain, _) = measure_newline_scan(&text, 1, false);
5315            if workers >= 2 {
5316                assert_eq!(chunked, plain);
5317            }
5318        }
5319    }
5320
5321    #[test]
5322    fn phased_file_matches_serial_and_maps_a_wide_file() {
5323        let text = fixture("cuh2.con").repeat(5);
5324        let dir = std::env::temp_dir();
5325        let path = dir.join("readcon-phased-wide.con");
5326        std::fs::write(&path, &text).expect("write");
5327        let phased = read_all_frames_phased(&path, Some(4)).expect("phased");
5328        let serial = frames_from_text(&text, Some(1)).expect("serial");
5329        assert_eq!(phased.frames, serial);
5330        assert!(phased.phases.mapped, "a file past 64 KiB is mapped");
5331        assert!(phased.phases.worker_parse_s >= 0.0);
5332        assert_eq!(phased.n_bytes, text.len());
5333        let _ = std::fs::remove_file(&path);
5334    }
5335
5336    #[test]
5337    fn equal_span_boundaries_match_the_serial_walk() {
5338        let text = fixture("cuh2.con").repeat(8);
5339        let bytes = text.as_bytes();
5340        let got = equal_span_boundaries(bytes, 4).expect("equal span");
5341        let expect = collect_frame_starts(ConFrameIterator::from_bytes(bytes), true);
5342        assert_eq!(got, expect);
5343        assert_eq!(got.len(), 8);
5344        let phased = frames_from_bytes_phased(bytes, Some(4)).expect("phased");
5345        assert_eq!(phased.scan_kind, SCAN_EQUAL_SPAN);
5346        let wide = fixture("cuh2.con").repeat(32);
5347        let ranges = frames_from_bytes_phased(wide.as_bytes(), Some(4)).expect("ranges");
5348        assert_eq!(ranges.frames.len(), 32);
5349        assert_eq!(
5350            ranges.chunk_s.len(),
5351            4,
5352            "equal frames are one range per worker, so the shell stash is one burst"
5353        );
5354        assert_eq!(
5355            ranges.frames,
5356            frames_from_text(&wide, Some(1)).expect("wide serial")
5357        );
5358        assert_eq!(
5359            phased.frames,
5360            frames_from_text(&text, Some(1)).expect("serial")
5361        );
5362        let tailed = format!("{text}\n\n  \n");
5363        let tail_phased = frames_from_bytes_phased(tailed.as_bytes(), Some(4)).expect("tail");
5364        assert_eq!(tail_phased.scan_kind, SCAN_EQUAL_SPAN);
5365        assert_eq!(
5366            tail_phased.frames,
5367            frames_from_text(&tailed, Some(1)).expect("tail serial")
5368        );
5369    }
5370
5371    #[test]
5372    fn a_wide_frame_matches_the_serial_parse() {
5373        let n = 5000usize;
5374        let mut text = String::new();
5375        text.push_str(
5376            "grid\n{\"con_spec_version\":2}\n10.0 10.0 10.0\n90.0 90.0 90.0\n0 0\n0 0\n1\n",
5377        );
5378        text.push_str(&format!("{n}\n63.546\nCu\nCoordinates of Component 1\n"));
5379        for i in 0..n {
5380            text.push_str(&format!("{i}.125000 0.250000 0.500000 0 {i}\n"));
5381        }
5382        let serial = frames_from_text(&text, Some(1)).expect("serial");
5383        let parallel = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("parallel");
5384        assert_eq!(parallel.frames, serial);
5385    }
5386
5387    fn one_species_frame(n: usize) -> String {
5388        let mut text = String::new();
5389        text.push_str(
5390            "grid\n{\"con_spec_version\":2}\n10.0 10.0 10.0\n90.0 90.0 90.0\n0 0\n0 0\n1\n",
5391        );
5392        text.push_str(&format!("{n}\n63.546\nCu\nCoordinates of Component 1\n"));
5393        for i in 0..n {
5394            text.push_str(&format!("{i}.125000 0.250000 0.500000 0 {i}\n"));
5395        }
5396        text
5397    }
5398
5399    fn two_species_frame(n_cu: usize, n_h: usize) -> String {
5400        let mut text = String::new();
5401        text.push_str(
5402            "grid\n{\"con_spec_version\":2}\n10.0 10.0 10.0\n90.0 90.0 90.0\n0 0\n0 0\n2\n",
5403        );
5404        text.push_str(&format!("{n_cu} {n_h}\n63.546 1.008\n"));
5405        text.push_str("Cu\nCoordinates of Component 1\n");
5406        for i in 0..n_cu {
5407            text.push_str(&format!("{i}.125000 0.250000 0.500000 0 {i}\n"));
5408        }
5409        text.push_str("H\nCoordinates of Component 2\n");
5410        for i in 0..n_h {
5411            text.push_str(&format!("1.500000 2.250000 {i}.125000 0 {}\n", n_cu + i));
5412        }
5413        text
5414    }
5415
5416    /// Drop the coordinate-row table from a gzip RC extra field.
5417    ///
5418    /// The plain lengths stay, which is the index written before the row
5419    /// spans existed. Member starts move back by the bytes removed.
5420    fn strip_gzip_row_table(bytes: &mut Vec<u8>) {
5421        assert_eq!(&bytes[0..2], b"\x1f\x8b");
5422        let xlen = u16::from_le_bytes([bytes[10], bytes[11]]) as usize;
5423        assert_eq!(&bytes[12..14], b"RC");
5424        let sub = u16::from_le_bytes([bytes[14], bytes[15]]) as usize;
5425        assert!(bytes[16..].starts_with(b"RCZ1"));
5426        let count = u16::from_le_bytes([bytes[20], bytes[21]]) as usize;
5427        let row_bytes = 8 * count;
5428        let payload_at = 16usize;
5429        assert_eq!(sub, xlen - 4);
5430        assert!(sub >= 6 + 16 * count + row_bytes);
5431        let row_at = payload_at + sub - row_bytes;
5432        bytes.drain(row_at..row_at + row_bytes);
5433        let new_sub = (sub - row_bytes) as u16;
5434        let new_xlen = (xlen - row_bytes) as u16;
5435        bytes[10..12].copy_from_slice(&new_xlen.to_le_bytes());
5436        bytes[14..16].copy_from_slice(&new_sub.to_le_bytes());
5437        for index in 1..count {
5438            let at = payload_at + 6 + 8 * index;
5439            let start = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap());
5440            let start = start - row_bytes as u64;
5441            bytes[at..at + 8].copy_from_slice(&start.to_le_bytes());
5442        }
5443    }
5444
5445    #[test]
5446    fn indexed_gzip_read_matches_the_serial_frame() {
5447        let text = one_species_frame(5000);
5448        let serial = frames_from_text(&text, Some(1)).expect("serial");
5449        let dir = tempfile::tempdir().unwrap();
5450        let path = dir.path().join("wide.con.gz");
5451        let members = crate::compression::write_gzip_members_n(&path, text.as_bytes(), 4).unwrap();
5452        assert!(members > 1);
5453        let piped = read_all_frames_phased(&path, Some(4)).expect("piped");
5454        assert_eq!(piped.frames, serial);
5455        let mixed = two_species_frame(3000, 2000);
5456        let mixed_serial = frames_from_text(&mixed, Some(1)).expect("mixed serial");
5457        let mixed_path = dir.path().join("mixed.con.gz");
5458        crate::compression::write_gzip_members_n(&mixed_path, mixed.as_bytes(), 4).unwrap();
5459        let mixed_read = read_all_frames_phased(&mixed_path, Some(4)).expect("mixed");
5460        assert_eq!(mixed_read.frames, mixed_serial);
5461        let mut old = std::fs::read(&path).unwrap();
5462        strip_gzip_row_table(&mut old);
5463        let old_path = dir.path().join("old.con.gz");
5464        std::fs::write(&old_path, &old).unwrap();
5465        let fallback = read_all_frames_phased(&old_path, Some(4)).expect("old index");
5466        assert_eq!(fallback.frames, serial);
5467    }
5468
5469    #[cfg(feature = "zstd")]
5470    #[test]
5471    fn indexed_zstd_read_matches_the_serial_frame() {
5472        let text = one_species_frame(5000);
5473        let serial = frames_from_text(&text, Some(1)).expect("serial");
5474        let dir = tempfile::tempdir().unwrap();
5475        let path = dir.path().join("wide.con.zst");
5476        let members = crate::compression::write_zstd_members_n(&path, text.as_bytes(), 4).unwrap();
5477        assert!(members > 1);
5478        let piped = read_all_frames_phased(&path, Some(4)).expect("piped");
5479        assert_eq!(piped.frames, serial);
5480    }
5481
5482    #[test]
5483    fn unequal_byte_spans_use_the_indexed_header_walk() {
5484        let one = fixture("cuh2.con");
5485        let mut longer = one.clone();
5486        longer.insert_str(0, "pad ");
5487        let text = format!("{one}{longer}{one}");
5488        let serial = frames_from_text(&text, Some(1)).expect("serial");
5489        let phased = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("phased");
5490        assert_eq!(phased.scan_kind, SCAN_INDEXED);
5491        assert_eq!(phased.frames, serial);
5492        assert!(equal_span_boundaries(text.as_bytes(), 4).is_none());
5493    }
5494
5495    #[test]
5496    fn a_whitespace_tail_uses_the_equal_span_walk() {
5497        let mut text = fixture("cuh2.con").repeat(3);
5498        text.push('\n');
5499        assert!(equal_span_boundaries(text.as_bytes(), 4).is_some());
5500        let serial = frames_from_text(&text, Some(1)).expect("serial");
5501        let parallel = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("phased");
5502        assert_eq!(parallel.scan_kind, SCAN_EQUAL_SPAN);
5503        assert_eq!(parallel.frames, serial);
5504    }
5505
5506    #[test]
5507    fn touch_checksum_matches_across_worker_counts() {
5508        let text = fixture("cuh2.con").repeat(4);
5509        let serial = frames_from_bytes_phased(text.as_bytes(), Some(1)).expect("serial");
5510        let parallel = frames_from_bytes_phased(text.as_bytes(), Some(4)).expect("parallel");
5511        let slab_s = pack_position_slab(&serial.frames, 1).expect("slab");
5512        let slab_p = pack_position_slab(&parallel.frames, 4).expect("slab");
5513        let left = touch_frame_checksum(&serial.frames, &slab_s, 1).expect("touch");
5514        let right = touch_frame_checksum(&parallel.frames, &slab_p, 4).expect("touch");
5515        assert_eq!(left, right);
5516        assert_ne!(left, 0);
5517    }
5518}
5519
5520#[cfg(test)]
5521mod later_frame_location_tests {
5522    use super::*;
5523    use std::fs;
5524
5525    fn one_atom(coord: &str) -> String {
5526        format!(
5527            "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"
5528        )
5529    }
5530
5531    fn bad_count_frame() -> String {
5532        "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()
5533    }
5534
5535    fn two_frames() -> String {
5536        format!("{}{}", one_atom("0 0 0 0 0"), bad_count_frame())
5537    }
5538
5539    fn assert_later_count(
5540        err: &error::ParseError,
5541        line: u32,
5542        frame: u64,
5543        iter: &ConFrameIterator<'_>,
5544    ) {
5545        assert_eq!(err.class_name(), "InvalidCount", "{err}");
5546        assert_eq!(iter.lines.line_no(), line, "{err}");
5547        assert_eq!(iter.frame_index(), frame, "{err}");
5548    }
5549
5550    fn assert_first_pass(err: Box<dyn std::error::Error>) {
5551        let located = err
5552            .downcast_ref::<error::LocatedParseError>()
5553            .unwrap_or_else(|| panic!("first pass lost the location: {err}"));
5554        assert_eq!(located.error.class_name(), "InvalidCount", "{located:?}");
5555        assert_eq!(located.line, 20, "{located:?}");
5556        assert_eq!(located.frame_index, 1, "{located:?}");
5557    }
5558
5559    fn take_err<T>(result: Result<T, Box<dyn std::error::Error>>) -> Box<dyn std::error::Error> {
5560        match result {
5561            Err(err) => err,
5562            Ok(_) => panic!("expected a parse error"),
5563        }
5564    }
5565
5566    /// The batch readers report the later frame from the parse that failed.
5567    /// A second open of the path is not required.
5568    #[test]
5569    fn batch_readers_keep_the_later_frame_on_the_first_pass() {
5570        let path = std::env::temp_dir().join(format!(
5571            "readcon-later-frame-first-{}.con",
5572            std::process::id()
5573        ));
5574        fs::write(&path, two_frames()).unwrap();
5575        for threads in [None, Some(1), Some(2)] {
5576            assert_first_pass(take_err(read_all_frames_with_threads(&path, threads)));
5577            assert_first_pass(take_err(read_all_frames_phased(&path, threads)));
5578            assert_first_pass(take_err(read_positions_with_threads(&path, threads)));
5579        }
5580        let _ = fs::remove_file(&path);
5581    }
5582
5583    /// An error in the second frame keeps that frame's index and file line.
5584    #[test]
5585    fn next_forward_and_skip_locate_the_later_frame() {
5586        let text = two_frames();
5587
5588        let mut iter = ConFrameIterator::new(&text);
5589        assert!(iter.next().unwrap().is_ok());
5590        let err = iter.next().unwrap().unwrap_err();
5591        assert_later_count(&err, 20, 1, &iter);
5592
5593        let mut iter = ConFrameIterator::new(&text);
5594        assert!(iter.forward().unwrap().is_ok());
5595        let err = iter.forward().unwrap().unwrap_err();
5596        assert_later_count(&err, 20, 1, &iter);
5597
5598        let mut iter = ConFrameIterator::new(&text);
5599        let err = iter.skip_frames(3).unwrap_err();
5600        assert_later_count(&err, 20, 1, &iter);
5601    }
5602
5603    /// The blank line that opens a velocity block is a file line. A later
5604    /// frame's error must include it. 12 coordinate lines, the separator,
5605    /// and 3 velocity lines put the next frame's line 8 at file line 24.
5606    #[test]
5607    fn skip_after_a_velocity_frame_keeps_the_separator_line() {
5608        let text = format!(
5609            "{}\nH\nVelocities of Component 1\n0 0 0 0 0\n{}",
5610            one_atom("0 0 0 0 0"),
5611            bad_count_frame()
5612        );
5613        let mut iter = ConFrameIterator::new(&text);
5614        assert!(iter.forward().unwrap().is_ok());
5615        let err = iter.forward().unwrap().unwrap_err();
5616        assert_later_count(&err, 24, 1, &iter);
5617    }
5618
5619    #[test]
5620    fn count_frames_locates_the_later_frame() {
5621        let path = std::env::temp_dir().join(format!(
5622            "readcon-later-frame-count-{}.con",
5623            std::process::id()
5624        ));
5625        fs::write(&path, two_frames()).unwrap();
5626        let err = count_frames(&path).unwrap_err();
5627        let _ = fs::remove_file(&path);
5628        match err {
5629            LoadError::Parse(located) => {
5630                assert_eq!(located.error.class_name(), "InvalidCount");
5631                assert_eq!(located.line, 20, "{located}");
5632                assert_eq!(located.frame_index, 1, "{located}");
5633            }
5634            other => panic!("expected a located parse error, got {other}"),
5635        }
5636    }
5637
5638    /// Metadata on line 2 of the second frame is file line 14, not line 2.
5639    /// This is the serial contract the collective reader matches.
5640    #[test]
5641    fn metadata_in_a_later_frame_reports_that_frames_line() {
5642        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";
5643        let text = format!("{}{}", one_atom("0 0 0 0 0"), bad);
5644        for threads in [Some(1usize), Some(2)] {
5645            let err = frames_from_text_located(&text, threads).unwrap_err();
5646            assert_eq!(
5647                err.error.class_name(),
5648                "InvalidMetadataJson",
5649                "{threads:?}: {err}"
5650            );
5651            assert_eq!(err.frame_index, 1, "{threads:?}");
5652            assert_eq!(err.line, 14, "{threads:?}: {err}");
5653        }
5654    }
5655
5656    /// Several spans after the first frame fail. Serial collection keeps
5657    /// the earliest slot. The parallel position reader must report that
5658    /// same line and frame index.
5659    ///
5660    /// The first frame is large. A later worker can fail before the
5661    /// calling thread reaches the earliest bad span, which is the case
5662    /// that used to return the later slot.
5663    #[test]
5664    fn parallel_positions_report_the_earliest_failing_slot() {
5665        const SLOW_ATOMS: usize = 20_000;
5666        let slow = counted_frame(SLOW_ATOMS);
5667        let bad = counted_frame(1);
5668        let text = format!("{slow}{bad}{bad}{bad}");
5669        let earliest_line = 1 + slow.bytes().filter(|byte| *byte == b'\n').count() as u32;
5670        let path =
5671            std::env::temp_dir().join(format!("readcon-earliest-slot-{}.con", std::process::id()));
5672        fs::write(&path, &text).unwrap();
5673        assert_earliest_slot(
5674            take_err(read_positions_with_threads(&path, Some(1))),
5675            earliest_line,
5676        );
5677        assert_earliest_slot(
5678            take_err(read_positions_with_threads(&path, Some(2))),
5679            earliest_line,
5680        );
5681        let _ = fs::remove_file(&path);
5682    }
5683
5684    fn counted_frame(natoms: usize) -> String {
5685        let mut body = String::with_capacity(natoms * 10 + 96);
5686        body.push_str("seed\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\n0 0\n0 0 0\n1\n");
5687        body.push_str(&natoms.to_string());
5688        body.push_str("\n1.0\nH\nCoordinates of Component 1\n");
5689        for _ in 0..natoms {
5690            body.push_str("0 0 0 0 0\n");
5691        }
5692        body
5693    }
5694
5695    fn assert_earliest_slot(err: Box<dyn std::error::Error>, line: u32) {
5696        let located = err
5697            .downcast_ref::<error::LocatedParseError>()
5698            .unwrap_or_else(|| panic!("position read lost the location: {err}"));
5699        assert_eq!(located.error.class_name(), "InvalidCount", "{located:?}");
5700        assert_eq!(located.frame_index, 1, "{located:?}");
5701        assert_eq!(located.line, line, "{located:?}");
5702    }
5703}
5704
5705#[cfg(test)]
5706mod frame_equality_tests {
5707    use super::ConFrameIterator;
5708
5709    fn sample_con(frames: usize, long: bool) -> String {
5710        let mut out = String::new();
5711        for f in 0..frames {
5712            let shift = (f as f64) * 1.0e-4;
5713            out.push_str("Generated by benchmark\n");
5714            out.push_str("{\"con_spec_version\":2}\n");
5715            out.push_str("15.3456 21.702 100.0\n");
5716            out.push_str("90.0 90.0 90.0\n");
5717            out.push_str("0 0\n");
5718            out.push_str(&format!("{f} 0 1\n"));
5719            out.push_str("2\n6 2\n63.546 1.00793\n");
5720            out.push_str("Cu\nCoordinates of Component 1\n");
5721            for i in 0..6 {
5722                let x = (i as f64) * 2.557 + 0.6394 + shift;
5723                let y = 0.9045 + shift * 0.5;
5724                let z = (f as f64) * 1.0e-6 + if i == 3 { -1.25 } else { 0.0 };
5725                if long {
5726                    out.push_str(&format!("   {x:.17}    {y:.17}    {z:.17} 0    {i}\n"));
5727                } else {
5728                    out.push_str(&format!("   {x:.6}    {y:.6}    {z:.6} 0    {i}\n"));
5729                }
5730            }
5731            out.push_str("H\nCoordinates of Component 2\n");
5732            out.push_str("   8.6823    9.947   11.733 0  6\n");
5733            out.push_str(&format!("  -7.9421    9.947   {shift:.6} 0  7\n"));
5734        }
5735        out
5736    }
5737
5738    fn sample_convel() -> String {
5739        let mut out = String::new();
5740        for f in 0..3 {
5741            let shift = (f as f64) * 1.0e-4;
5742            out.push_str("Generated by benchmark\n");
5743            out.push_str("{\"con_spec_version\":2}\n");
5744            out.push_str("15.3456 21.702 100.0\n");
5745            out.push_str("90.0 90.0 90.0\n");
5746            out.push_str("0 0\n");
5747            out.push_str(&format!("{f} 0 1\n"));
5748            out.push_str("2\n4 1\n63.546 1.00793\n");
5749            out.push_str("Cu\nCoordinates of Component 1\n");
5750            for i in 0..4 {
5751                let x = (i as f64) * 2.557 + shift;
5752                out.push_str(&format!("   {x:.17}    0.9045    1.0 0    {i}\n"));
5753            }
5754            out.push_str("H\nCoordinates of Component 2\n");
5755            out.push_str("   1.5    2.5    3.5 0  4\n");
5756            out.push_str("\nCu\nVelocities of Component 1\n");
5757            for i in 0..4 {
5758                let vx = (i as f64) * 1.0e-4 - shift;
5759                out.push_str(&format!("   {vx:.17}    -0.5    0.25 0    {i}\n"));
5760            }
5761            out.push_str("H\nVelocities of Component 2\n");
5762            out.push_str("   0.1    -0.2    0.3 0  4\n");
5763        }
5764        out
5765    }
5766
5767    fn reference_f64(token: &str) -> f64 {
5768        let (val, n) = fast_float2::parse_partial::<f64, _>(token).expect(token);
5769        assert_eq!(n, token.len(), "{token}");
5770        val
5771    }
5772
5773    fn assert_rows_match(text: &str, positions: bool) {
5774        let frames: Vec<_> = ConFrameIterator::new(text)
5775            .map(|frame| frame.expect("parse"))
5776            .collect();
5777        assert!(frames.len() >= 2);
5778        let mut frame_i = 0usize;
5779        let mut row = 0usize;
5780        let mut in_section = false;
5781        for line in text.lines() {
5782            let token = line.trim();
5783            if token.is_empty() {
5784                in_section = true;
5785                row = 0;
5786                continue;
5787            }
5788            if token.starts_with("Generated") {
5789                if row > 0 || in_section {
5790                    frame_i += 1;
5791                }
5792                row = 0;
5793                in_section = false;
5794                continue;
5795            }
5796            let cols: Vec<&str> = token.split_whitespace().collect();
5797            if cols.len() < 4 || reference_ok(cols[0]).is_none() {
5798                continue;
5799            }
5800            if cols[3].parse::<u64>().is_err() {
5801                continue;
5802            }
5803            let got = if in_section {
5804                frames[frame_i].atom_data[row].velocity().expect("velocity")
5805            } else if positions {
5806                let p = frames[frame_i].positions.as_f64_row(row);
5807                [p[0], p[1], p[2]]
5808            } else {
5809                continue;
5810            };
5811            for (k, token) in cols.iter().take(3).enumerate() {
5812                assert_eq!(
5813                    got[k].to_bits(),
5814                    reference_f64(token).to_bits(),
5815                    "frame {frame_i} row {row} col {k} token {token} section {in_section}"
5816                );
5817            }
5818            let aos = if in_section {
5819                frames[frame_i].atom_data[row].velocity().unwrap()
5820            } else {
5821                let atom = &frames[frame_i].atom_data[row];
5822                [atom.x(), atom.y(), atom.z()]
5823            };
5824            for k in 0..3 {
5825                assert_eq!(aos[k].to_bits(), got[k].to_bits());
5826            }
5827            row += 1;
5828        }
5829    }
5830
5831    fn reference_ok(token: &str) -> Option<f64> {
5832        let (val, n) = fast_float2::parse_partial::<f64, _>(token).ok()?;
5833        (n == token.len()).then_some(val)
5834    }
5835
5836    #[test]
5837    fn frames_match_general_decimal_parser() {
5838        assert_rows_match(&sample_con(4, true), true);
5839        assert_rows_match(&sample_con(4, false), true);
5840        let text = sample_convel();
5841        let frames: Vec<_> = ConFrameIterator::new(&text)
5842            .map(|frame| frame.expect("convel"))
5843            .collect();
5844        assert_eq!(frames.len(), 3);
5845        assert!(
5846            frames
5847                .iter()
5848                .all(|frame| frame.atom_data[0].velocity().is_some())
5849        );
5850        assert_eq!(frames[0].positions.nrows(), 5);
5851        // Every stored coordinate and velocity bit matches the general parser.
5852        let mut frame_i = 0usize;
5853        let mut coord_row = 0usize;
5854        let mut vel_row = 0usize;
5855        let mut velocities = false;
5856        for line in text.lines() {
5857            let token = line.trim();
5858            if token.starts_with("Generated") && (coord_row > 0 || velocities) {
5859                frame_i += 1;
5860                coord_row = 0;
5861                vel_row = 0;
5862                velocities = false;
5863            }
5864            if token.starts_with("Velocities") {
5865                velocities = true;
5866                continue;
5867            }
5868            let cols: Vec<&str> = token.split_whitespace().collect();
5869            if cols.len() < 5 {
5870                continue;
5871            }
5872            if reference_ok(cols[0]).is_none() || cols[3].parse::<u64>().is_err() {
5873                continue;
5874            }
5875            let row = if velocities { vel_row } else { coord_row };
5876            let got = if velocities {
5877                frames[frame_i].atom_data[row].velocity().unwrap()
5878            } else {
5879                let p = frames[frame_i].positions.as_f64_row(row);
5880                [p[0], p[1], p[2]]
5881            };
5882            for (k, col) in cols.iter().take(3).enumerate() {
5883                assert_eq!(got[k].to_bits(), reference_f64(col).to_bits(), "{token}");
5884            }
5885            if velocities {
5886                let soa = frames[frame_i].velocities.as_f64_row(row);
5887                for k in 0..3 {
5888                    assert_eq!(soa[k].to_bits(), got[k].to_bits());
5889                }
5890                vel_row += 1;
5891            } else {
5892                coord_row += 1;
5893            }
5894        }
5895        assert_eq!(frame_i, 2);
5896        assert_eq!(vel_row, 5);
5897    }
5898}
5899
5900#[cfg(test)]
5901mod newline_skip_tests {
5902    use super::ConFrameIterator;
5903
5904    /// The per-line search [`ConFrameIterator::advance_lines`] used before
5905    /// the counted skip. Positions and errors are the oracle.
5906    fn naive(bytes: &[u8], n: usize) -> Result<usize, ()> {
5907        let mut pos = 0usize;
5908        for i in 0..n {
5909            let rest = &bytes[pos..];
5910            match memchr::memchr(b'\n', rest) {
5911                Some(at) => pos += at + 1,
5912                None => {
5913                    if rest.is_empty() {
5914                        return Err(());
5915                    }
5916                    pos = bytes.len();
5917                    if i + 1 == n {
5918                        return Ok(pos);
5919                    }
5920                    return Err(());
5921                }
5922            }
5923        }
5924        Ok(pos)
5925    }
5926
5927    #[test]
5928    fn counted_skip_matches_per_line_memchr() {
5929        let mut samples = vec![
5930            String::new(),
5931            "a".into(),
5932            "a\n".into(),
5933            "a\nb".into(),
5934            "\n".into(),
5935            "\n\n".into(),
5936            "abc\ndef\nghi".into(),
5937            "a\r\nb\r\n".into(),
5938            "x".repeat(100) + "\n" + &"y".repeat(40),
5939        ];
5940        let path =
5941            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/cuh2.con");
5942        let one = std::fs::read_to_string(&path).unwrap();
5943        samples.push(one.repeat(2));
5944        let bare = one.trim_end_matches(['\n', '\r']).to_string();
5945        samples.push(bare);
5946        for text in samples {
5947            let bytes = text.as_bytes();
5948            for n in [0usize, 1, 2, 3, 6, 7, 9, 40, 200, 450, 1000, 5000] {
5949                let expect = naive(bytes, n);
5950                let mut it = ConFrameIterator::from_bytes(bytes);
5951                let got = it.advance_lines(n).map(|_| it.byte_pos()).map_err(|_| ());
5952                assert_eq!(got, expect, "n={n} len={}", bytes.len());
5953            }
5954        }
5955    }
5956}
5957
5958#[cfg(test)]
5959mod cached_unit_table_read_paths {
5960    use super::*;
5961
5962    const FRAME: &str = "\
5963v3
5964{\"con_spec_version\":3,\"con_spec_minor\":1,\"units\":{\"length\":\"angstrom\",\"energy\":\"eV\"}}
596510 10 10
596690 90 90
5967
5968
59691
59701
59711.0
5972H
5973Coordinates of Component 1
59740.5 0.25 0.125 0 7
5975";
5976
5977    fn coords(frame: &types::ConFrame) -> [f64; 3] {
5978        frame.positions.as_f64_row(0)
5979    }
5980
5981    /// The serial iterator, the byte reader behind the C API, and the phased
5982    /// reader behind `read_con` all validate units through the one table.
5983    #[test]
5984    fn read_paths_share_the_cached_unit_table() {
5985        let addr = crate::units::cached_unit_table_addr();
5986        let serial = ConFrameIterator::new(FRAME)
5987            .next()
5988            .expect("frame")
5989            .expect("parse");
5990        let from_bytes = frames_from_bytes(FRAME.as_bytes(), Some(1)).expect("bytes");
5991        let phased = frames_from_bytes_phased(FRAME.as_bytes(), Some(1)).expect("phased");
5992        assert_eq!(from_bytes.len(), 1);
5993        assert_eq!(phased.frames.len(), 1);
5994        assert_eq!(coords(&from_bytes[0]), coords(&serial));
5995        assert_eq!(coords(&phased.frames[0]), coords(&serial));
5996        assert_eq!(crate::units::cached_unit_table_addr(), addr);
5997        let bad = FRAME.replace("angstrom", "not-a-unit");
5998        assert!(ConFrameIterator::new(&bad).next().unwrap().is_err());
5999        assert!(frames_from_bytes(bad.as_bytes(), Some(1)).is_err());
6000        assert!(frames_from_bytes_phased(bad.as_bytes(), Some(1)).is_err());
6001        assert_eq!(crate::units::cached_unit_table_addr(), addr);
6002    }
6003}