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