Skip to main content

readcon_core/
parser.rs

1use crate::error::ParseError;
2use crate::helpers::symbol_to_atomic_number;
3use crate::types::{
4    AtomDatum, ConFrame, FrameHeader, PreboxHeader, SECTION_CHARGES, SECTION_DISPLACEMENTS,
5    SECTION_ENERGIES, SECTION_FORCES, SECTION_MAGMOMS, SECTION_SPINS, SECTION_SPREADS,
6    SECTION_VELOCITIES, meta,
7};
8use serde_json::Value;
9use std::iter::Peekable;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicUsize, Ordering};
12
13/// Line source with peek for section detection (blank-line separators).
14///
15/// Implemented by the memchr cursor on the hot iterator path and by
16/// `Peekable<I>` for tests / ad-hoc callers.
17/// Rows of one coordinate block, in file order.
18///
19/// `atoms` is empty unless the caller asked for per-atom shells. The
20/// numeric columns are the bytes a serial `next_coord_row` loop would
21/// have stored.
22#[doc(hidden)]
23pub struct CoordRows {
24    pub xyz: Vec<f64>,
25    pub ids: Vec<u64>,
26    pub flags: Vec<[bool; 3]>,
27    pub atoms: Vec<crate::types::AtomDatum>,
28    pub consumed: usize,
29}
30
31/// Worker count for a coordinate block that is too wide to hand to one task.
32///
33/// One means the calling thread parses the block. The frame iterator sets
34/// this only for a single wide frame, then puts it back.
35pub(crate) fn wide_workers() -> usize {
36    WIDE_WORKERS.with(|slot| slot.get())
37}
38
39pub(crate) fn set_wide_workers(n: usize) {
40    WIDE_WORKERS.with(|slot| slot.set(n.max(1)));
41}
42
43std::thread_local! {
44    static WIDE_WORKERS: std::cell::Cell<usize> = const { std::cell::Cell::new(1) };
45}
46
47/// Fill `n` coordinate rows.
48///
49/// `breaks[i]` is the absolute index of that row's `\n`. Row 0 begins at
50/// `row0`. `xyz` receives `n * 3` values. `ids` and `flags` may be null
51/// when the caller does not want them. `flags` is the packed column-4 mask.
52#[cfg(feature = "parallel")]
53pub(crate) fn fill_rows_from_breaks(
54    bytes: &[u8],
55    breaks: &[usize],
56    row0: usize,
57    spec_version: u32,
58    start_id: u64,
59    workers: usize,
60    xyz: *mut f64,
61    ids: *mut u64,
62    flags: *mut u8,
63) -> Result<(), ParseError> {
64    let n = breaks.len();
65    if n == 0 {
66        return Ok(());
67    }
68    let chunk = n.div_ceil(workers.max(1)).max(1);
69    let groups = n.div_ceil(chunk);
70    let xyz_ptr = xyz as usize;
71    let ids_ptr = ids as usize;
72    let flags_ptr = flags as usize;
73    let fill = || {
74        use rayon::prelude::*;
75        (0..groups).into_par_iter().try_for_each(|group| {
76            let first = group * chunk;
77            let last = (first + chunk).min(n);
78            for row_index in first..last {
79                let start = if row_index == 0 {
80                    row0
81                } else {
82                    breaks[row_index - 1] + 1
83                };
84                let end = breaks[row_index] + 1;
85                if end > bytes.len() || start > end {
86                    return Err(ParseError::IncompleteFrame);
87                }
88                let default_id = start_id + row_index as u64;
89                let ((row, fixed, atom_id), _next) =
90                    parse_coord_row_at(&bytes[start..end], 0, spec_version, default_id)?;
91                unsafe {
92                    std::ptr::copy_nonoverlapping(
93                        row.as_ptr(),
94                        (xyz_ptr as *mut f64).add(row_index * 3),
95                        3,
96                    );
97                    if ids_ptr != 0 {
98                        *((ids_ptr as *mut u64).add(row_index)) = atom_id;
99                    }
100                    if flags_ptr != 0 {
101                        let mask = u8::from(fixed[0])
102                            | (u8::from(fixed[1]) << 1)
103                            | (u8::from(fixed[2]) << 2);
104                        *((flags_ptr as *mut u8).add(row_index)) = mask;
105                    }
106                }
107            }
108            Ok(())
109        })
110    };
111    on_pool(workers, fill)
112}
113
114/// Run `body` on a pool of `workers` threads.
115///
116/// The pool for a given size is kept for the process. Building one per
117/// call was a serial pause on the wide-frame read and the row formatter.
118#[cfg(feature = "parallel")]
119pub(crate) fn on_pool<T: Send>(workers: usize, body: impl FnOnce() -> T + Send) -> T {
120    if workers < 2 {
121        return body();
122    }
123    // The cached pool is leaked for the process. Miri rejects a thread
124    // that is still running when the test binary exits, so it builds a
125    // pool, runs `body`, and joins.
126    #[cfg(miri)]
127    {
128        return match rayon::ThreadPoolBuilder::new()
129            .num_threads(workers)
130            .build()
131        {
132            Ok(pool) => pool.install(body),
133            Err(_) => body(),
134        };
135    }
136    #[cfg(not(miri))]
137    {
138    let pool = {
139        use std::sync::{Mutex, OnceLock};
140        static POOLS: OnceLock<Mutex<Vec<(usize, &'static rayon::ThreadPool)>>> = OnceLock::new();
141        let pools = POOLS.get_or_init(|| Mutex::new(Vec::new()));
142        let mut guard = pools.lock().unwrap_or_else(|err| err.into_inner());
143        if let Some((_, pool)) = guard.iter().find(|(size, _)| *size == workers) {
144            *pool
145        } else {
146            let next_core = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
147            let built = match rayon::ThreadPoolBuilder::new()
148                .num_threads(workers)
149                .spawn_handler(move |thread| {
150                    // One worker per physical core. Leaving the placement to
151                    // the kernel put two of these on one core and left
152                    // another idle, which is the whole 8-thread gap.
153                    let core = next_core.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
154                    std::thread::Builder::new().spawn(move || {
155                        pin_worker(core);
156                        thread.run();
157                    })?;
158                    Ok(())
159                })
160                .build()
161            {
162                Ok(pool) => pool,
163                Err(_) => return body(),
164            };
165            let pool: &'static rayon::ThreadPool = Box::leak(Box::new(built));
166            guard.push((workers, pool));
167            pool
168        }
169    };
170    pool.install(body)
171    }
172}
173
174pub(crate) fn pin_worker(core: usize) {
175    // `sched_setaffinity` is Linux. Pinning is a performance hint.
176    #[cfg(all(not(miri), target_os = "linux"))]
177    unsafe {
178        let mut set: libc::cpu_set_t = std::mem::zeroed();
179        libc::CPU_ZERO(&mut set);
180        libc::CPU_SET(core, &mut set);
181        libc::sched_setaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &set);
182    }
183    #[cfg(any(miri, not(target_os = "linux")))]
184    {
185        let _ = core;
186    }
187}
188
189/// Coordinate rows in `bytes`, which begins at the first row.
190///
191/// `n` rows are required. The returned `consumed` count is the number of
192/// bytes those rows occupy, including their newlines. `symbol` builds the
193/// per-atom shells; `None` leaves `atoms` empty. A structural miss returns
194/// `Err` and the caller keeps the serial loop.
195#[cfg(feature = "parallel")]
196pub(crate) fn parallel_coord_rows(
197    bytes: &[u8],
198    n: usize,
199    spec_version: u32,
200    start_id: u64,
201    workers: usize,
202    symbol: Option<&std::sync::Arc<str>>,
203) -> Result<CoordRows, ParseError> {
204    if n == 0 {
205        return Ok(CoordRows {
206            xyz: Vec::new(),
207            ids: Vec::new(),
208            flags: Vec::new(),
209            atoms: Vec::new(),
210            consumed: 0,
211        });
212    }
213    // Same chunking as the column reader: count newlines, then each worker
214    // walks its own run. An offset per line was a serial pass the plain
215    // read does not pay.
216    let (chunks, consumed) = plan_coord_chunks(bytes, 0, n, workers.max(1))?;
217    let mut xyz = Vec::<f64>::with_capacity(n.saturating_mul(3));
218    let mut ids = Vec::<u64>::with_capacity(n);
219    let mut flags = Vec::<[bool; 3]>::with_capacity(n);
220    let mut atoms_raw: Vec<std::mem::MaybeUninit<crate::types::AtomDatum>> = Vec::new();
221    if symbol.is_some() {
222        atoms_raw = Vec::with_capacity(n);
223        unsafe { atoms_raw.set_len(n) };
224    }
225    let xyz_ptr = xyz.as_mut_ptr() as usize;
226    let ids_ptr = ids.as_mut_ptr() as usize;
227    let flags_ptr = flags.as_mut_ptr() as usize;
228    let atoms_ptr = atoms_raw.as_mut_ptr() as usize;
229    let mut row0 = Vec::with_capacity(chunks.len());
230    let mut acc = 0usize;
231    for (_, nrows) in &chunks {
232        row0.push(acc);
233        acc += *nrows;
234    }
235    let fill_chunk = |index: usize| -> Result<(), ParseError> {
236        let (start, nrows) = chunks[index];
237        let mut row = row0[index];
238        let mut pos = start;
239        let mut shares = symbol.map(|symbol| symbol_shares(symbol, nrows));
240        for _ in 0..nrows {
241            let ((xyz, fixed, atom_id), next) =
242                parse_coord_row_at(bytes, pos, spec_version, start_id + row as u64)?;
243            unsafe {
244                std::ptr::copy_nonoverlapping(xyz.as_ptr(), (xyz_ptr as *mut f64).add(row * 3), 3);
245                *((ids_ptr as *mut u64).add(row)) = atom_id;
246                *((flags_ptr as *mut [bool; 3]).add(row)) = fixed;
247                if let Some(shares) = shares.as_mut() {
248                    let slot = (xyz_ptr as *mut f64).add(row * 3);
249                    let atom =
250                        crate::types::AtomDatum::at_slot(shares.take(), slot, fixed, atom_id);
251                    (*(atoms_ptr as *mut std::mem::MaybeUninit<crate::types::AtomDatum>).add(row))
252                        .write(atom);
253                }
254            }
255            pos = next;
256            row += 1;
257        }
258        Ok(())
259    };
260    let built = if workers < 2 || chunks.len() < 2 {
261        (0..chunks.len()).try_for_each(fill_chunk)
262    } else {
263        on_pool(workers, || {
264            use rayon::prelude::*;
265            (0..chunks.len()).into_par_iter().try_for_each(fill_chunk)
266        })
267    };
268    if let Err(err) = built {
269        std::mem::forget(atoms_raw);
270        return Err(err);
271    }
272    // SAFETY: every row was written before `built` returned Ok. The
273    // length stays 0 on the error path above.
274    unsafe {
275        xyz.set_len(n * 3);
276        ids.set_len(n);
277        flags.set_len(n);
278    }
279    let atoms = if symbol.is_some() {
280        let mut raw = std::mem::ManuallyDrop::new(atoms_raw);
281        unsafe {
282            Vec::from_raw_parts(
283                raw.as_mut_ptr() as *mut crate::types::AtomDatum,
284                n,
285                raw.capacity(),
286            )
287        }
288    } else {
289        Vec::new()
290    };
291    Ok(CoordRows {
292        xyz,
293        ids,
294        flags,
295        atoms,
296        consumed,
297    })
298}
299
300/// Walk `nrows` coordinate lines starting at byte `start`.
301///
302/// `row_base` is the first row's index in `xyz` / `ids` / `flags` / `atoms`.
303/// This is the per-member kernel of [`fill_rows_from_chunks`], so a worker
304/// can parse a slice as soon as that slice has been inflated.
305#[cfg(feature = "parallel")]
306pub(crate) fn parse_coord_run(
307    bytes: &[u8],
308    start: usize,
309    nrows: usize,
310    spec_version: u32,
311    row_base: usize,
312    symbol: Option<&std::sync::Arc<str>>,
313    xyz: *mut f64,
314    ids: *mut u64,
315    flags: *mut [bool; 3],
316    atoms: *mut std::mem::MaybeUninit<crate::types::AtomDatum>,
317) -> Result<usize, ParseError> {
318    let mut pos = start;
319    let mut shares = symbol.map(|symbol| symbol_shares(symbol, nrows));
320    for step in 0..nrows {
321        let row = row_base + step;
322        let ((xyz_row, fixed, atom_id), next) =
323            parse_coord_row_at(bytes, pos, spec_version, row as u64)?;
324        unsafe {
325            let dest = xyz.add(row * 3);
326            std::ptr::copy_nonoverlapping(xyz_row.as_ptr(), dest, 3);
327            *ids.add(row) = atom_id;
328            *flags.add(row) = fixed;
329            if let Some(shares) = shares.as_mut() {
330                let atom = crate::types::AtomDatum::at_slot(shares.take(), dest, fixed, atom_id);
331                (*atoms.add(row)).write(atom);
332            }
333        }
334        pos = next;
335    }
336    Ok(pos)
337}
338
339#[cfg(feature = "parallel")]
340#[allow(dead_code)]
341fn coord_line_ranges(
342    bytes: &[u8],
343    n: usize,
344    workers: usize,
345) -> Result<Vec<(usize, usize)>, ParseError> {
346    let newlines = newline_index(bytes, workers);
347    let mut ranges = Vec::with_capacity(n);
348    let mut start = 0usize;
349    for index in 0..n {
350        if start > bytes.len() {
351            return Err(ParseError::IncompleteFrame);
352        }
353        let end = if index < newlines.len() {
354            newlines[index] + 1
355        } else if index == newlines.len() && start < bytes.len() {
356            bytes.len()
357        } else {
358            return Err(ParseError::IncompleteFrame);
359        };
360        if end < start {
361            return Err(ParseError::IncompleteFrame);
362        }
363        ranges.push((start, end));
364        start = end;
365    }
366    Ok(ranges)
367}
368
369/// Newline positions in `bytes`.
370///
371/// Chunks are cut on byte boundaries, then each interior cut that lands
372/// inside a line is moved to the next newline so a row is not split.
373/// Workers count those newlines, a prefix sum places each run, and the
374/// same workers write the offsets. The offset array is not zeroed, so
375/// the writer is the first touch. `workers < 2` scans on the calling thread.
376#[cfg(feature = "parallel")]
377pub(crate) fn newline_index(bytes: &[u8], workers: usize) -> Vec<usize> {
378    newline_index_at(bytes, 0, workers)
379}
380
381/// [`newline_index`] with `base` added to every stored offset.
382#[cfg(feature = "parallel")]
383pub(crate) fn newline_index_at(bytes: &[u8], base: usize, workers: usize) -> Vec<usize> {
384    if bytes.is_empty() || workers < 2 || bytes.len() < (1 << 20) {
385        return memchr::memchr_iter(b'\n', bytes)
386            .map(|index| base + index)
387            .collect();
388    }
389    let cuts = newline_cuts(bytes, workers);
390    newline_index_exact(&cuts, bytes, base)
391}
392
393/// One cache line, so adjacent chunk counters do not share a line.
394#[cfg(feature = "parallel")]
395#[derive(Clone)]
396#[repr(C, align(64))]
397struct ChunkTally {
398    n: usize,
399}
400
401#[cfg(feature = "parallel")]
402pub(crate) fn newline_cuts(bytes: &[u8], workers: usize) -> Vec<usize> {
403    let workers = workers.min(bytes.len()).max(1);
404    let mut cuts = Vec::with_capacity(workers + 1);
405    cuts.push(0usize);
406    for index in 1..workers {
407        let mut at = index * bytes.len() / workers;
408        // A cut in the middle of a row belongs to the chunk that holds
409        // the newline. Move it to the first byte after that newline.
410        if at > 0 && at < bytes.len() && bytes[at - 1] != b'\n' {
411            if let Some(rel) = memchr::memchr(b'\n', &bytes[at..]) {
412                at += rel + 1;
413            } else {
414                at = bytes.len();
415            }
416        }
417        if cuts.last().copied() != Some(at) {
418            cuts.push(at);
419        }
420    }
421    if cuts.last().copied() != Some(bytes.len()) {
422        cuts.push(bytes.len());
423    }
424    cuts
425}
426
427/// Count newlines in each `cuts` window. One store per chunk, so the
428/// counters do not bounce a cache line during the scan.
429#[cfg(feature = "parallel")]
430fn count_newline_chunks(bytes: &[u8], cuts: &[usize]) -> Vec<usize> {
431    let nchunks = cuts.len().saturating_sub(1);
432    let mut tallies = vec![ChunkTally { n: 0 }; nchunks];
433    if nchunks == 0 {
434        return Vec::new();
435    }
436    let ptr = tallies.as_mut_ptr() as usize;
437    let count = || {
438        use rayon::prelude::*;
439        (0..nchunks).into_par_iter().for_each(|index| {
440            let n = memchr::memchr_iter(b'\n', &bytes[cuts[index]..cuts[index + 1]]).count();
441            unsafe {
442                (*(ptr as *mut ChunkTally).add(index)).n = n;
443            }
444        });
445    };
446    if nchunks < 2 {
447        count();
448    } else {
449        on_pool(nchunks, count);
450    }
451    tallies.into_iter().map(|tally| tally.n).collect()
452}
453
454/// Byte start and row count for each worker that parses `n_rows`
455/// coordinate lines beginning at `start`.
456///
457/// The newline *count* places the rows. Workers then parse their own
458/// slice in order, so the read does not materialise an offset per line.
459/// The returned end is the first byte after the `n_rows`-th newline.
460#[cfg(feature = "parallel")]
461pub(crate) fn plan_coord_chunks(
462    bytes: &[u8],
463    start: usize,
464    n_rows: usize,
465    workers: usize,
466) -> Result<(Vec<(usize, usize)>, usize), ParseError> {
467    if n_rows == 0 {
468        return Ok((Vec::new(), start));
469    }
470    if start >= bytes.len() {
471        return Err(ParseError::IncompleteFrame);
472    }
473    let mut window = n_rows.saturating_mul(96).max(1);
474    loop {
475        let end = start.saturating_add(window).min(bytes.len());
476        let region = &bytes[start..end];
477        let cuts = newline_cuts(region, workers);
478        let counts = count_newline_chunks(region, &cuts);
479        let total: usize = counts.iter().sum();
480        if total >= n_rows || end == bytes.len() {
481            if total < n_rows {
482                return Err(ParseError::IncompleteFrame);
483            }
484            return chunks_from_counts(bytes, start, &cuts, &counts, n_rows);
485        }
486        window = window.saturating_mul(2);
487    }
488}
489
490#[cfg(feature = "parallel")]
491fn chunks_from_counts(
492    bytes: &[u8],
493    start: usize,
494    cuts: &[usize],
495    counts: &[usize],
496    n_rows: usize,
497) -> Result<(Vec<(usize, usize)>, usize), ParseError> {
498    let mut need = n_rows;
499    let mut chunks = Vec::new();
500    let mut end_abs = start;
501    for index in 0..counts.len() {
502        if need == 0 {
503            break;
504        }
505        let have = counts[index];
506        if have == 0 {
507            continue;
508        }
509        let from = start + cuts[index];
510        let to = start + cuts[index + 1];
511        let take = have.min(need);
512        let chunk_end = if take == have && to > from && bytes[to - 1] == b'\n' {
513            to
514        } else {
515            let mut seen = 0usize;
516            let mut found = None;
517            for rel in memchr::memchr_iter(b'\n', &bytes[from..to]) {
518                seen += 1;
519                if seen == take {
520                    found = Some(from + rel + 1);
521                    break;
522                }
523            }
524            found.ok_or(ParseError::IncompleteFrame)?
525        };
526        chunks.push((from, take));
527        end_abs = chunk_end;
528        need -= take;
529    }
530    if need != 0 {
531        return Err(ParseError::IncompleteFrame);
532    }
533    Ok((chunks, end_abs))
534}
535
536/// Parse coordinate rows described by [`plan_coord_chunks`].
537///
538/// Each chunk is a contiguous run of lines. The worker walks that run
539/// and writes `nrows` coordinates at the prefix-summed row.
540#[cfg(feature = "parallel")]
541pub(crate) fn fill_rows_from_chunks(
542    bytes: &[u8],
543    chunks: &[(usize, usize)],
544    spec_version: u32,
545    start_id: u64,
546    workers: usize,
547    xyz: *mut f64,
548    ids: *mut u64,
549    flags: *mut u8,
550) -> Result<(), ParseError> {
551    if chunks.is_empty() {
552        return Ok(());
553    }
554    let mut row0 = Vec::with_capacity(chunks.len());
555    let mut acc = 0usize;
556    for (_, nrows) in chunks {
557        row0.push(acc);
558        acc += *nrows;
559    }
560    let xyz_ptr = xyz as usize;
561    let ids_ptr = ids as usize;
562    let flags_ptr = flags as usize;
563    let fill = || {
564        use rayon::prelude::*;
565        chunks
566            .par_iter()
567            .zip(row0.par_iter().copied())
568            .try_for_each(|(&(start, nrows), mut row)| {
569                let mut pos = start;
570                for _ in 0..nrows {
571                    let ((xyz, fixed, atom_id), next) =
572                        parse_coord_row_at(bytes, pos, spec_version, start_id + row as u64)?;
573                    unsafe {
574                        std::ptr::copy_nonoverlapping(
575                            xyz.as_ptr(),
576                            (xyz_ptr as *mut f64).add(row * 3),
577                            3,
578                        );
579                        if ids_ptr != 0 {
580                            *((ids_ptr as *mut u64).add(row)) = atom_id;
581                        }
582                        if flags_ptr != 0 {
583                            let mask = u8::from(fixed[0])
584                                | (u8::from(fixed[1]) << 1)
585                                | (u8::from(fixed[2]) << 2);
586                            *((flags_ptr as *mut u8).add(row)) = mask;
587                        }
588                    }
589                    pos = next;
590                    row += 1;
591                }
592                Ok(())
593            })
594    };
595    if workers < 2 {
596        let mut row_at = 0usize;
597        for &(start, nrows) in chunks {
598            let mut pos = start;
599            for _ in 0..nrows {
600                let ((row_xyz, fixed, atom_id), next) =
601                    parse_coord_row_at(bytes, pos, spec_version, start_id + row_at as u64)?;
602                unsafe {
603                    std::ptr::copy_nonoverlapping(row_xyz.as_ptr(), xyz.add(row_at * 3), 3);
604                    if ids_ptr != 0 {
605                        *((ids_ptr as *mut u64).add(row_at)) = atom_id;
606                    }
607                    if flags_ptr != 0 {
608                        let mask = u8::from(fixed[0])
609                            | (u8::from(fixed[1]) << 1)
610                            | (u8::from(fixed[2]) << 2);
611                        *((flags_ptr as *mut u8).add(row_at)) = mask;
612                    }
613                }
614                pos = next;
615                row_at += 1;
616            }
617        }
618        Ok(())
619    } else if chunks.len() < 2 {
620        fill()
621    } else {
622        on_pool(workers, fill)
623    }
624}
625
626/// Exact index: count, prefix sum, then store into an unzeroed buffer.
627#[cfg(feature = "parallel")]
628fn newline_index_exact(cuts: &[usize], bytes: &[u8], base: usize) -> Vec<usize> {
629    let nchunks = cuts.len() - 1;
630    let counts = count_newline_chunks(bytes, cuts);
631    let mut slots = vec![0usize; nchunks];
632    let mut total = 0usize;
633    for index in 0..nchunks {
634        slots[index] = total;
635        total += counts[index];
636    }
637    let mut out = Vec::<usize>::with_capacity(total);
638    let ptr = out.as_mut_ptr() as usize;
639    let store = || {
640        use rayon::prelude::*;
641        (0..nchunks).into_par_iter().for_each(|index| {
642            let from = cuts[index];
643            let mut at = slots[index];
644            for rel in memchr::memchr_iter(b'\n', &bytes[from..cuts[index + 1]]) {
645                unsafe {
646                    *((ptr as *mut usize).add(at)) = base + from + rel;
647                }
648                at += 1;
649            }
650        });
651    };
652    if nchunks < 2 {
653        store();
654    } else {
655        on_pool(nchunks.max(1), store);
656    }
657    // SAFETY: the store wrote every index in `0..total`.
658    unsafe { out.set_len(total) };
659    out
660}
661
662#[cfg(feature = "parallel")]
663#[allow(dead_code)]
664fn write_coord_group(
665    bytes: &[u8],
666    group: &[(usize, usize)],
667    row0: usize,
668    spec_version: u32,
669    start_id: u64,
670    symbol: Option<&std::sync::Arc<str>>,
671    xyz_ptr: usize,
672    ids_ptr: usize,
673    flags_ptr: usize,
674    atoms_ptr: usize,
675) -> Result<(), ParseError> {
676    let mut shares = symbol.map(|symbol| symbol_shares(symbol, group.len()));
677    for (offset, &(start, end)) in group.iter().enumerate() {
678        let row_index = row0 + offset;
679        let default_id = start_id + row_index as u64;
680        let ((row, fixed, atom_id), _next) =
681            parse_coord_row_at(&bytes[start..end], 0, spec_version, default_id)?;
682        unsafe {
683            std::ptr::copy_nonoverlapping(
684                row.as_ptr(),
685                (xyz_ptr as *mut f64).add(row_index * 3),
686                3,
687            );
688            *((ids_ptr as *mut u64).add(row_index)) = atom_id;
689            *((flags_ptr as *mut [bool; 3]).add(row_index)) = fixed;
690            if let Some(shares) = shares.as_mut() {
691                let slot = (xyz_ptr as *mut f64).add(row_index * 3);
692                let atom = crate::types::AtomDatum::at_slot(shares.take(), slot, fixed, atom_id);
693                (*(atoms_ptr as *mut std::mem::MaybeUninit<crate::types::AtomDatum>)
694                    .add(row_index))
695                .write(atom);
696            }
697        }
698    }
699    Ok(())
700}
701
702pub trait LineStream<'a> {
703    fn next_line(&mut self) -> Option<&'a str>;
704    fn peek_line(&mut self) -> Option<&'a str>;
705    /// Take `n` coordinate rows starting at the cursor, or `None` to keep
706    /// the one-row loop. The cursor moves only when this returns `Some`.
707    fn try_take_coord_block(
708        &mut self,
709        _n: usize,
710        _spec_version: u32,
711        _start_id: u64,
712        _symbol: &std::sync::Arc<str>,
713        _workers: usize,
714    ) -> Option<CoordRows> {
715        None
716    }
717    /// True when every remaining line, including the current peek, is
718    /// empty or whitespace. Default is false so a one-line peek cannot
719    /// hide a following section.
720    fn remainder_is_whitespace(&mut self) -> bool {
721        false
722    }
723    /// Called while a blank line is peeked. True when that blank begins a
724    /// section: the next line is a symbol and the line after it is a
725    /// component label. A blank that does not is line 1 of the next frame.
726    fn blank_line_opens_section(&mut self) -> bool {
727        false
728    }
729
730    /// With a blank line peeked, true when [`Self::blank_line_opens_section`]
731    /// is true. A stream that cannot look ahead keeps the legacy rule and
732    /// returns true.
733    fn blank_opens_velocity_block(&mut self) -> bool {
734        true
735    }
736
737    /// One coordinate row, fused with the line scan when the stream can do it.
738    ///
739    /// The default pulls a `&str` line. [`crate::iterators::MemchrLines`]
740    /// parses the row directly from the byte cursor so tokenize and numeric
741    /// parse are one pass.
742    fn next_coord_row(
743        &mut self,
744        spec_version: u32,
745        default_id: u64,
746    ) -> Result<([f64; 3], [bool; 3], u64), ParseError> {
747        let line = self.next_line().ok_or(ParseError::IncompleteFrame)?;
748        parse_coord_row(line, spec_version, default_id)
749    }
750
751    /// One vector-section row (xyz, flag, required id), fused with the line
752    /// scan when the stream can do it. End of input is [`ParseError::IncompleteFrame`];
753    /// callers that are inside a named section map that to `IncompleteSection`.
754    fn next_vector_row(
755        &mut self,
756        spec_version: u32,
757    ) -> Result<([f64; 3], [bool; 3], u64), ParseError> {
758        let line = self.next_line().ok_or(ParseError::IncompleteFrame)?;
759        parse_vector_section_row(line, spec_version)
760    }
761}
762
763fn take_vector_row<'a>(
764    lines: &mut impl LineStream<'a>,
765    spec_version: u32,
766    section: &str,
767) -> Result<([f64; 3], [bool; 3], u64), ParseError> {
768    match lines.next_vector_row(spec_version) {
769        Ok(row) => Ok(row),
770        Err(ParseError::IncompleteFrame) => Err(ParseError::IncompleteSection(section.to_string())),
771        Err(err) => Err(err),
772    }
773}
774
775impl<'a, I> LineStream<'a> for Peekable<I>
776where
777    I: Iterator<Item = &'a str> + Clone,
778{
779    #[inline]
780    fn next_line(&mut self) -> Option<&'a str> {
781        Iterator::next(self)
782    }
783    #[inline]
784    fn peek_line(&mut self) -> Option<&'a str> {
785        Peekable::peek(self).copied()
786    }
787    fn blank_line_opens_section(&mut self) -> bool {
788        let mut probe = self.clone();
789        let Some(blank) = probe.next_line() else {
790            return false;
791        };
792        if !blank.trim().is_empty() {
793            return false;
794        }
795        section_follows(&mut probe)
796    }
797
798    fn blank_opens_velocity_block(&mut self) -> bool {
799        match self.peek_line() {
800            Some(line) if line.trim().is_empty() => self.blank_line_opens_section(),
801            _ => true,
802        }
803    }
804}
805
806/// True when `line` has the words `of` and `Component` in that order.
807pub(crate) fn line_has_component_label(line: &str) -> bool {
808    let mut words = line.split_ascii_whitespace();
809    while let Some(word) = words.next() {
810        if word.eq_ignore_ascii_case("of")
811            && words
812                .next()
813                .is_some_and(|next| next.eq_ignore_ascii_case("component"))
814        {
815            return true;
816        }
817    }
818    false
819}
820
821fn section_follows<'a>(lines: &mut impl LineStream<'a>) -> bool {
822    let Some(symbol) = lines.next_line() else {
823        return false;
824    };
825    if symbol.trim().is_empty() || symbol.trim_start().starts_with('{') {
826        return false;
827    }
828    let Some(label) = lines.next_line() else {
829        return false;
830    };
831    line_has_component_label(label)
832}
833
834/// Length of the stack buffer of [`parse_line_of_range_f64_stack`]: the most
835/// columns an atom line has, which are x, y, z, the flag and the optional id
836/// (spec "Coordinates").
837pub const NUMERIC_STACK_WIDTH: usize = COORD_ROW_MIN_COLUMNS + 1;
838
839/// Hot-path: parse up to [`NUMERIC_STACK_WIDTH`] whitespace-separated f64s
840/// into a stack buffer.
841/// Returns count of tokens actually present (before padding).
842/// Pads `out[found..max]` from `defaults` when `found < max` and `found >= min`.
843///
844/// Single-pass over the line bytes: skip ASCII whitespace, then
845/// [`fast_float2::parse_partial`] (Eisel–Lemire / SIMD-class decimal kernel)
846/// with a token-boundary check. No `SplitWhitespace`, no per-token `&str`,
847/// no heap `Vec`. Prefer this over allocating [`parse_line_of_n_f64`] on atom lines.
848#[inline]
849pub fn parse_line_of_range_f64_stack(
850    line: &str,
851    min: usize,
852    max: usize,
853    defaults: &[f64],
854    out: &mut [f64; NUMERIC_STACK_WIDTH],
855) -> Result<usize, ParseError> {
856    debug_assert!(max <= NUMERIC_STACK_WIDTH && min <= max && defaults.len() >= max);
857    let bytes = line.as_bytes();
858    let n = bytes.len();
859    let mut i = 0usize;
860    let mut found = 0usize;
861    while found < max {
862        while i < n && bytes[i].is_ascii_whitespace() {
863            i += 1;
864        }
865        if i >= n {
866            break;
867        }
868        let end = bytes[i..]
869            .iter()
870            .position(|b| b.is_ascii_whitespace())
871            .map(|k| i + k)
872            .unwrap_or(n);
873        let token = std::str::from_utf8(&bytes[i..end]).unwrap_or("");
874        if is_nonfinite_spelling(token) {
875            return Err(ParseError::NonFiniteValue(token.to_string()));
876        }
877        let (val, consumed) = fast_float2::parse_partial::<f64, _>(&bytes[i..])
878            .map_err(|_| ParseError::InvalidNumberFormat(format!("invalid float: {token}")))?;
879        let next = i + consumed;
880        // Reject partial tokens like "1.2abc" (must end at whitespace or EOS).
881        if next != end {
882            return Err(ParseError::InvalidNumberFormat(format!(
883                "invalid float: {token}"
884            )));
885        }
886        if !val.is_finite() {
887            return Err(ParseError::NonFiniteValue(token.to_string()));
888        }
889        out[found] = val;
890        found += 1;
891        i = next;
892    }
893    while i < n && bytes[i].is_ascii_whitespace() {
894        i += 1;
895    }
896    // Reject trailing extra tokens (same contract as the old split loop).
897    if i < n {
898        return Err(ParseError::InvalidVectorLength {
899            expected: max,
900            found: found + 1,
901        });
902    }
903    if found < min || found > max {
904        return Err(ParseError::InvalidVectorLength {
905            expected: max,
906            found,
907        });
908    }
909    while found < max {
910        out[found] = defaults[found];
911        found += 1;
912    }
913    Ok(found)
914}
915
916/// Parses a line of whitespace-separated f64 values using fast-float2.
917///
918/// This is the hot-path parser for coordinate and velocity lines. It uses
919/// `fast_float2::parse` instead of `str::parse::<f64>()` for better throughput
920/// on the numeric-heavy atom data lines. Fixed-width atom lines use
921/// [`parse_line_of_range_f64_stack`] to avoid a heap `Vec` per line.
922///
923/// # Arguments
924///
925/// * `line` - A string slice representing a single line of data.
926/// * `n` - The exact number of f64 values expected on the line.
927pub fn parse_line_of_n_f64(line: &str, n: usize) -> Result<Vec<f64>, ParseError> {
928    if n <= NUMERIC_STACK_WIDTH {
929        let defaults = [0.0f64; NUMERIC_STACK_WIDTH];
930        let mut buf = [0.0f64; NUMERIC_STACK_WIDTH];
931        parse_line_of_range_f64_stack(line, n, n, &defaults, &mut buf)?;
932        return Ok(buf[..n].to_vec());
933    }
934    let mut values = Vec::with_capacity(n.min(max_tokens_on_line(line)));
935    for token in line.split_ascii_whitespace() {
936        values.push(parse_finite_float_token(token)?);
937    }
938    if values.len() == n {
939        Ok(values)
940    } else {
941        Err(ParseError::InvalidVectorLength {
942            expected: n,
943            found: values.len(),
944        })
945    }
946}
947
948/// Parses a line of whitespace-separated f64 values, accepting between `min`
949/// and `max` values (inclusive). Returns a vector of exactly `max` elements,
950/// padding with values from `defaults` when fewer than `max` are present.
951///
952/// Used for atom lines where column 5 (atom_index) is optional.
953/// Prefer [`parse_line_of_range_f64_stack`] on the atom hot path
954/// (`max <= NUMERIC_STACK_WIDTH`).
955pub fn parse_line_of_range_f64(
956    line: &str,
957    min: usize,
958    max: usize,
959    defaults: &[f64],
960) -> Result<Vec<f64>, ParseError> {
961    if max <= NUMERIC_STACK_WIDTH {
962        let mut buf = [0.0f64; NUMERIC_STACK_WIDTH];
963        parse_line_of_range_f64_stack(line, min, max, defaults, &mut buf)?;
964        return Ok(buf[..max].to_vec());
965    }
966    let mut values = Vec::with_capacity(max);
967    for token in line.split_ascii_whitespace() {
968        values.push(parse_finite_float_token(token)?);
969    }
970    if values.len() < min || values.len() > max {
971        return Err(ParseError::InvalidVectorLength {
972            expected: max,
973            found: values.len(),
974        });
975    }
976    while values.len() < max {
977        let idx = values.len();
978        values.push(defaults[idx]);
979    }
980    Ok(values)
981}
982
983/// CON-num.1 spellings. A leading sign is optional. Case is ignored.
984/// Digit and dot tokens return before any letter compare so the atom
985/// hot path does not allocate.
986fn is_nonfinite_spelling(token: &str) -> bool {
987    let rest = token
988        .strip_prefix('+')
989        .or_else(|| token.strip_prefix('-'))
990        .unwrap_or(token);
991    match rest.as_bytes().first() {
992        Some(b) if b.is_ascii_digit() || *b == b'.' => return false,
993        None => return false,
994        _ => {}
995    }
996    rest.eq_ignore_ascii_case("nan")
997        || rest.eq_ignore_ascii_case("inf")
998        || rest.eq_ignore_ascii_case("infinity")
999}
1000
1001fn parse_finite_float_token(token: &str) -> Result<f64, ParseError> {
1002    if is_nonfinite_spelling(token) {
1003        return Err(ParseError::NonFiniteValue(token.to_string()));
1004    }
1005    let (val, consumed) = fast_float2::parse_partial::<f64, _>(token.as_bytes())
1006        .map_err(|_| ParseError::InvalidNumberFormat(format!("invalid float: {token}")))?;
1007    if consumed != token.len() {
1008        return Err(ParseError::InvalidNumberFormat(format!(
1009            "invalid float: {token}"
1010        )));
1011    }
1012    if !val.is_finite() {
1013        return Err(ParseError::NonFiniteValue(token.to_string()));
1014    }
1015    Ok(val)
1016}
1017
1018fn skip_ascii_ws(bytes: &[u8], mut i: usize) -> usize {
1019    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1020        i += 1;
1021    }
1022    i
1023}
1024
1025fn take_token(line: &str, i: usize) -> Option<(&str, usize)> {
1026    let bytes = line.as_bytes();
1027    if i >= bytes.len() {
1028        return None;
1029    }
1030    let mut j = i;
1031    while j < bytes.len() && !bytes[j].is_ascii_whitespace() {
1032        j += 1;
1033    }
1034    Some((&line[i..j], j))
1035}
1036
1037/// Column 4. A sign, a decimal point, or a value outside the spec's set
1038/// is `InvalidFixedFlag` in every mode (CON-mask.1, CON-mask.5, D8).
1039fn parse_fixed_flag(token: &str, spec_version: u32) -> Result<[bool; 3], ParseError> {
1040    let bad = || {
1041        ParseError::InvalidFixedFlag(format!(
1042            "column 4 flag {token:?} is not a legal integer for spec {spec_version}"
1043        ))
1044    };
1045    if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
1046        return Err(bad());
1047    }
1048    let val: u64 = token.parse::<u64>().map_err(|_| bad())?;
1049    if spec_version < 2 {
1050        return match val {
1051            0 => Ok([false, false, false]),
1052            1 => Ok([true, true, true]),
1053            _ => Err(bad()),
1054        };
1055    }
1056    if val > 7 {
1057        return Err(bad());
1058    }
1059    let v = val as u8;
1060    Ok([v & 1 != 0, v & 2 != 0, v & 4 != 0])
1061}
1062
1063/// Column 5. Digits only, parsed as `u64`, never through `f64` (CON-id.1, D9).
1064/// A sign or a decimal point is `InvalidNumberFormat` at the token level.
1065fn parse_atom_id_token(token: &str) -> Result<u64, ParseError> {
1066    if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
1067        return Err(ParseError::InvalidNumberFormat(format!(
1068            "invalid integer: {token}"
1069        )));
1070    }
1071    token.parse::<u64>().map_err(|_| {
1072        ParseError::InvalidNumberFormat(format!("integer out of range 0..=2^64-1: {token}"))
1073    })
1074}
1075
1076/// Coordinate row: three finite floats, an integer flag, and an optional id.
1077/// Four columns take `default_id`, the 0-based position in the frame
1078/// (CON-id.9). Any other width is `InvalidVectorLength` (CON-num.9).
1079fn parse_coord_row(
1080    line: &str,
1081    spec_version: u32,
1082    default_id: u64,
1083) -> Result<([f64; 3], [bool; 3], u64), ParseError> {
1084    parse_vector_row(line, spec_version, Some(default_id))
1085}
1086
1087/// Vector section row. The flag and id columns are required (exact width 5).
1088fn parse_vector_section_row(
1089    line: &str,
1090    spec_version: u32,
1091) -> Result<([f64; 3], [bool; 3], u64), ParseError> {
1092    parse_vector_row(line, spec_version, None)
1093}
1094
1095fn parse_vector_row(
1096    line: &str,
1097    spec_version: u32,
1098    default_id: Option<u64>,
1099) -> Result<([f64; 3], [bool; 3], u64), ParseError> {
1100    let (row, end) = parse_coord_fields(line.as_bytes(), 0, spec_version, default_id)?;
1101    if end != line.len() {
1102        let found = 5 + count_tokens_on_line(line.as_bytes(), end);
1103        return Err(ParseError::InvalidVectorLength { expected: 5, found });
1104    }
1105    Ok(row)
1106}
1107
1108/// Field separator inside a coordinate row. Newline ends the row.
1109#[inline(always)]
1110fn is_field_ws(b: u8) -> bool {
1111    matches!(b, b' ' | b'\t' | b'\r' | 0x0c)
1112}
1113
1114#[inline(always)]
1115fn skip_field_ws(bytes: &[u8], mut i: usize) -> usize {
1116    while i < bytes.len() && is_field_ws(bytes[i]) {
1117        i += 1;
1118    }
1119    i
1120}
1121
1122fn token_on_line(bytes: &[u8], i: usize) -> String {
1123    let mut j = i;
1124    while j < bytes.len() && !is_field_ws(bytes[j]) && bytes[j] != b'\n' {
1125        j += 1;
1126    }
1127    String::from_utf8_lossy(&bytes[i..j]).into_owned()
1128}
1129
1130fn count_tokens_on_line(bytes: &[u8], mut i: usize) -> usize {
1131    let mut n = 0usize;
1132    loop {
1133        i = skip_field_ws(bytes, i);
1134        if i >= bytes.len() || bytes[i] == b'\n' {
1135            break;
1136        }
1137        n += 1;
1138        while i < bytes.len() && !is_field_ws(bytes[i]) && bytes[i] != b'\n' {
1139            i += 1;
1140        }
1141    }
1142    n
1143}
1144
1145/// Invalid UTF-8 in a numeric row, otherwise a normal number-format error.
1146fn utf8_or_number(bytes: &[u8], line_start: usize, i: usize) -> ParseError {
1147    let end = bytes[line_start..]
1148        .iter()
1149        .position(|b| *b == b'\n')
1150        .map(|p| line_start + p)
1151        .unwrap_or(bytes.len());
1152    if std::str::from_utf8(&bytes[line_start..end]).is_err() {
1153        return ParseError::ValidationError("invalid UTF-8".into());
1154    }
1155    ParseError::InvalidNumberFormat(format!("invalid float: {}", token_on_line(bytes, i)))
1156}
1157
1158#[inline(always)]
1159fn parse_f64_at(bytes: &[u8], i: usize, line_start: usize) -> Result<(f64, usize), ParseError> {
1160    if i >= bytes.len() || bytes[i] == b'\n' {
1161        return Err(ParseError::InvalidVectorLength {
1162            expected: 5,
1163            found: 0,
1164        });
1165    }
1166    if bytes[i] >= 0x80 {
1167        return Err(utf8_or_number(bytes, line_start, i));
1168    }
1169    if looks_like_nonfinite(bytes, i) {
1170        let token = token_on_line(bytes, i);
1171        if is_nonfinite_spelling(&token) {
1172            return Err(ParseError::NonFiniteValue(token));
1173        }
1174    }
1175    let (val, consumed) = fast_float2::parse_partial::<f64, _>(&bytes[i..]).map_err(|_| {
1176        ParseError::InvalidNumberFormat(format!("invalid float: {}", token_on_line(bytes, i)))
1177    })?;
1178    let next = i + consumed;
1179    let boundary = next >= bytes.len() || is_field_ws(bytes[next]) || bytes[next] == b'\n';
1180    if !boundary || !val.is_finite() {
1181        let token = token_on_line(bytes, i);
1182        if is_nonfinite_spelling(&token) || !val.is_finite() {
1183            return Err(ParseError::NonFiniteValue(token));
1184        }
1185        return Err(ParseError::InvalidNumberFormat(format!(
1186            "invalid float: {token}"
1187        )));
1188    }
1189    Ok((val, next))
1190}
1191
1192#[inline(always)]
1193fn parse_flag_at(
1194    bytes: &[u8],
1195    i: usize,
1196    spec_version: u32,
1197) -> Result<([bool; 3], usize), ParseError> {
1198    if i >= bytes.len() || bytes[i] == b'\n' {
1199        return Err(ParseError::InvalidVectorLength {
1200            expected: 5,
1201            found: 3,
1202        });
1203    }
1204    let bad = || {
1205        ParseError::InvalidFixedFlag(format!(
1206            "column 4 flag {:?} is not a legal integer for spec {spec_version}",
1207            token_on_line(bytes, i)
1208        ))
1209    };
1210    if !bytes[i].is_ascii_digit() {
1211        return Err(bad());
1212    }
1213    let mut j = i;
1214    let mut val: u64 = 0;
1215    while j < bytes.len() && bytes[j].is_ascii_digit() {
1216        let d = u64::from(bytes[j] - b'0');
1217        val = val
1218            .checked_mul(10)
1219            .and_then(|v| v.checked_add(d))
1220            .ok_or_else(bad)?;
1221        j += 1;
1222    }
1223    if j < bytes.len() && !is_field_ws(bytes[j]) && bytes[j] != b'\n' {
1224        return Err(bad());
1225    }
1226    let fixed = if spec_version < 2 {
1227        match val {
1228            0 => [false, false, false],
1229            1 => [true, true, true],
1230            _ => return Err(bad()),
1231        }
1232    } else if val <= 7 {
1233        let v = val as u8;
1234        [v & 1 != 0, v & 2 != 0, v & 4 != 0]
1235    } else {
1236        return Err(bad());
1237    };
1238    Ok((fixed, j))
1239}
1240
1241#[inline(always)]
1242fn parse_id_at(bytes: &[u8], i: usize) -> Result<(u64, usize), ParseError> {
1243    if i >= bytes.len() || bytes[i] == b'\n' || !bytes[i].is_ascii_digit() {
1244        return Err(ParseError::InvalidNumberFormat(format!(
1245            "invalid integer: {}",
1246            token_on_line(bytes, i)
1247        )));
1248    }
1249    let mut j = i;
1250    let mut val: u64 = 0;
1251    while j < bytes.len() && bytes[j].is_ascii_digit() {
1252        let d = u64::from(bytes[j] - b'0');
1253        val = val
1254            .checked_mul(10)
1255            .and_then(|v| v.checked_add(d))
1256            .ok_or_else(|| {
1257                ParseError::InvalidNumberFormat(format!(
1258                    "integer out of range 0..=2^64-1: {}",
1259                    token_on_line(bytes, i)
1260                ))
1261            })?;
1262        j += 1;
1263    }
1264    if j < bytes.len() && !is_field_ws(bytes[j]) && bytes[j] != b'\n' {
1265        return Err(ParseError::InvalidNumberFormat(format!(
1266            "invalid integer: {}",
1267            token_on_line(bytes, i)
1268        )));
1269    }
1270    Ok((val, j))
1271}
1272
1273/// One parsed coordinate row: xyz, fixed flags, and the atom id.
1274type ParsedCoord = ([f64; 3], [bool; 3], u64);
1275
1276/// One coordinate or vector row starting at `i`.
1277///
1278/// Stops at newline or end of `bytes` and returns the index just past the
1279/// row (after the newline, when one was present). Floats, the flag, and the
1280/// id are parsed in that single scan. The success path does not build error
1281/// strings; those stay on the cold fallback.
1282#[inline(always)]
1283fn parse_coord_fields(
1284    bytes: &[u8],
1285    i: usize,
1286    spec_version: u32,
1287    default_id: Option<u64>,
1288) -> Result<(ParsedCoord, usize), ParseError> {
1289    if let Some(parsed) = parse_coord_fields_fast(bytes, i, spec_version, default_id) {
1290        return Ok(parsed);
1291    }
1292    parse_coord_fields_detailed(bytes, i, spec_version, default_id)
1293}
1294
1295/// Common coordinate row: three finite decimals, a small flag, an optional id.
1296#[inline(always)]
1297fn parse_coord_fields_fast(
1298    bytes: &[u8],
1299    i: usize,
1300    spec_version: u32,
1301    default_id: Option<u64>,
1302) -> Option<(ParsedCoord, usize)> {
1303    let mut i = skip_field_ws(bytes, i);
1304    let mut xyz = [0.0f64; 3];
1305    for dest in &mut xyz {
1306        if i >= bytes.len() || bytes[i] == b'\n' || bytes[i] >= 0x80 {
1307            return None;
1308        }
1309        let (val, next) = crate::fast_dec::parse_f64_hot(bytes, i)?;
1310        if next < bytes.len() && !is_field_ws(bytes[next]) && bytes[next] != b'\n' {
1311            return None;
1312        }
1313        *dest = val;
1314        i = skip_field_ws(bytes, next);
1315    }
1316    let (fixed, next) = parse_flag_fast(bytes, i, spec_version)?;
1317    i = skip_field_ws(bytes, next);
1318    let (atom_id, next) = if i >= bytes.len() || bytes[i] == b'\n' {
1319        (default_id?, i)
1320    } else {
1321        parse_u64_fast(bytes, i)?
1322    };
1323    i = skip_field_ws(bytes, next);
1324    if i < bytes.len() && bytes[i] != b'\n' {
1325        return None;
1326    }
1327    if i < bytes.len() && bytes[i] == b'\n' {
1328        i += 1;
1329    }
1330    Some(((xyz, fixed, atom_id), i))
1331}
1332
1333#[inline(always)]
1334fn parse_u64_fast(bytes: &[u8], i: usize) -> Option<(u64, usize)> {
1335    if i >= bytes.len() || !bytes[i].is_ascii_digit() {
1336        return None;
1337    }
1338    let mut j = i;
1339    let mut val: u64 = 0;
1340    let mut digits = 0u32;
1341    while j < bytes.len() && bytes[j].is_ascii_digit() {
1342        digits += 1;
1343        // 19 digits can overflow u64; the detailed parser reports that.
1344        if digits > 18 {
1345            return None;
1346        }
1347        val = val * 10 + u64::from(bytes[j] - b'0');
1348        j += 1;
1349    }
1350    if j < bytes.len() && !is_field_ws(bytes[j]) && bytes[j] != b'\n' {
1351        return None;
1352    }
1353    Some((val, j))
1354}
1355
1356#[inline(always)]
1357fn parse_flag_fast(bytes: &[u8], i: usize, spec_version: u32) -> Option<([bool; 3], usize)> {
1358    let (val, j) = parse_u64_fast(bytes, i)?;
1359    let fixed = if spec_version < 2 {
1360        match val {
1361            0 => [false, false, false],
1362            1 => [true, true, true],
1363            _ => return None,
1364        }
1365    } else if val <= 7 {
1366        let v = val as u8;
1367        [v & 1 != 0, v & 2 != 0, v & 4 != 0]
1368    } else {
1369        return None;
1370    };
1371    Some((fixed, j))
1372}
1373
1374#[cold]
1375#[inline(never)]
1376fn parse_coord_fields_detailed(
1377    bytes: &[u8],
1378    i: usize,
1379    spec_version: u32,
1380    default_id: Option<u64>,
1381) -> Result<(ParsedCoord, usize), ParseError> {
1382    let line_start = i;
1383    let mut i = skip_field_ws(bytes, i);
1384    if i >= bytes.len() || bytes[i] == b'\n' {
1385        return Err(ParseError::InvalidVectorLength {
1386            expected: 5,
1387            found: 0,
1388        });
1389    }
1390    let mut xyz = [0.0f64; 3];
1391    for (slot, dest) in xyz.iter_mut().enumerate() {
1392        if i >= bytes.len() || bytes[i] == b'\n' {
1393            return Err(ParseError::InvalidVectorLength {
1394                expected: 5,
1395                found: slot,
1396            });
1397        }
1398        if bytes[i] >= 0x80 {
1399            return Err(utf8_or_number(bytes, line_start, i));
1400        }
1401        let (val, next) = parse_f64_at(bytes, i, line_start)?;
1402        *dest = val;
1403        i = skip_field_ws(bytes, next);
1404    }
1405    let (fixed, next) = parse_flag_at(bytes, i, spec_version)?;
1406    i = skip_field_ws(bytes, next);
1407    let (atom_id, next) = if i >= bytes.len() || bytes[i] == b'\n' {
1408        (
1409            default_id.ok_or(ParseError::InvalidVectorLength {
1410                expected: 5,
1411                found: 4,
1412            })?,
1413            i,
1414        )
1415    } else {
1416        parse_id_at(bytes, i)?
1417    };
1418    i = skip_field_ws(bytes, next);
1419    if i < bytes.len() && bytes[i] != b'\n' {
1420        let found = 5 + count_tokens_on_line(bytes, i);
1421        return Err(ParseError::InvalidVectorLength { expected: 5, found });
1422    }
1423    if i < bytes.len() && bytes[i] == b'\n' {
1424        i += 1;
1425    }
1426    Ok(((xyz, fixed, atom_id), i))
1427}
1428
1429/// Coordinate row at byte offset `i` in a multi-line buffer.
1430///
1431/// The returned index is the start of the next line. ASCII rows are valid
1432/// UTF-8 without a separate scan; a non-ASCII byte is checked only then.
1433pub(crate) fn parse_coord_row_at(
1434    bytes: &[u8],
1435    i: usize,
1436    spec_version: u32,
1437    default_id: u64,
1438) -> Result<(ParsedCoord, usize), ParseError> {
1439    if i >= bytes.len() {
1440        return Err(ParseError::IncompleteFrame);
1441    }
1442    parse_coord_fields(bytes, i, spec_version, Some(default_id))
1443}
1444
1445/// Vector-section row at `i`. The id column is required.
1446pub(crate) fn parse_vector_row_at(
1447    bytes: &[u8],
1448    i: usize,
1449    spec_version: u32,
1450) -> Result<(ParsedCoord, usize), ParseError> {
1451    if i >= bytes.len() {
1452        return Err(ParseError::IncompleteFrame);
1453    }
1454    parse_coord_fields(bytes, i, spec_version, None)
1455}
1456
1457/// Scalar section row: one finite float, a flag, and an id. Exact width 3.
1458fn parse_scalar_section_row(
1459    line: &str,
1460    spec_version: u32,
1461) -> Result<(f64, [bool; 3], u64), ParseError> {
1462    let bytes = line.as_bytes();
1463    let i = skip_ascii_ws(bytes, 0);
1464    let (value, i) = parse_finite_at(line, i)?;
1465    let i = skip_ascii_ws(bytes, i);
1466    let (flag_tok, i) = take_token(line, i).ok_or(ParseError::InvalidVectorLength {
1467        expected: 3,
1468        found: 1,
1469    })?;
1470    let fixed = parse_fixed_flag(flag_tok, spec_version)?;
1471    let i = skip_ascii_ws(bytes, i);
1472    let (id_tok, i) = take_token(line, i).ok_or(ParseError::InvalidVectorLength {
1473        expected: 3,
1474        found: 2,
1475    })?;
1476    let atom_id = parse_atom_id_token(id_tok)?;
1477    let i = skip_ascii_ws(bytes, i);
1478    if i < bytes.len() {
1479        let found = 3 + line[i..].split_ascii_whitespace().count();
1480        return Err(ParseError::InvalidVectorLength { expected: 3, found });
1481    }
1482    Ok((value, fixed, atom_id))
1483}
1484
1485fn parse_finite_at(line: &str, i: usize) -> Result<(f64, usize), ParseError> {
1486    let bytes = line.as_bytes();
1487    if i >= bytes.len() {
1488        return Err(ParseError::InvalidVectorLength {
1489            expected: 3,
1490            found: 0,
1491        });
1492    }
1493    // "infinity" parses as the prefix "inf" in fast_float2. Name that
1494    // spelling before the numeric kernel so the leftover is not a format error.
1495    if looks_like_nonfinite(bytes, i) {
1496        let token = take_token(line, i).map(|(tok, _)| tok).unwrap_or("");
1497        if is_nonfinite_spelling(token) {
1498            return Err(ParseError::NonFiniteValue(token.to_string()));
1499        }
1500    }
1501    let (val, consumed) = fast_float2::parse_partial::<f64, _>(&bytes[i..]).map_err(|_| {
1502        let token = take_token(line, i).map(|(tok, _)| tok).unwrap_or("");
1503        ParseError::InvalidNumberFormat(format!("invalid float: {token}"))
1504    })?;
1505    let next = i + consumed;
1506    let boundary = next == bytes.len() || bytes[next].is_ascii_whitespace();
1507    if !boundary || !val.is_finite() {
1508        let token = take_token(line, i).map(|(tok, _)| tok).unwrap_or("");
1509        if is_nonfinite_spelling(token) || !val.is_finite() {
1510            return Err(ParseError::NonFiniteValue(token.to_string()));
1511        }
1512        return Err(ParseError::InvalidNumberFormat(format!(
1513            "invalid float: {token}"
1514        )));
1515    }
1516    Ok((val, next))
1517}
1518
1519/// True when the token may be a non-finite spelling. Digit and dot
1520/// tokens, the common atom-line case, are false after one byte.
1521fn looks_like_nonfinite(bytes: &[u8], i: usize) -> bool {
1522    let mut j = i;
1523    if bytes[j] == b'+' || bytes[j] == b'-' {
1524        j += 1;
1525        if j >= bytes.len() {
1526            return false;
1527        }
1528    }
1529    let b = bytes[j];
1530    !b.is_ascii_digit() && b != b'.'
1531}
1532
1533/// Case-insensitive, after collapsing each run of spaces (CON-file.5, D17).
1534fn labels_equal(found: &str, expected: &str) -> bool {
1535    let mut found_words = found.split_ascii_whitespace();
1536    let mut expected_words = expected.split_ascii_whitespace();
1537    loop {
1538        match (found_words.next(), expected_words.next()) {
1539            (Some(a), Some(b)) => {
1540                if !a.eq_ignore_ascii_case(b) {
1541                    return false;
1542                }
1543            }
1544            (None, None) => return true,
1545            _ => return false,
1546        }
1547    }
1548}
1549
1550/// True when `line`, after the same folding, starts with every word of `prefix`.
1551fn label_has_prefix(line: &str, prefix: &str) -> bool {
1552    let mut found_words = line.split_ascii_whitespace();
1553    for word in prefix.split_ascii_whitespace() {
1554        match found_words.next() {
1555            Some(got) if got.eq_ignore_ascii_case(word) => {}
1556            _ => return false,
1557        }
1558    }
1559    true
1560}
1561
1562fn token_count(line: &str) -> usize {
1563    line.split_ascii_whitespace().count()
1564}
1565
1566/// Most whitespace-separated tokens `line` can hold.
1567///
1568/// `k` tokens take at least one byte each and one separator between each
1569/// pair, `2k - 1` bytes in all.
1570fn max_tokens_on_line(line: &str) -> usize {
1571    line.len().div_ceil(2)
1572}
1573
1574fn json_u64(value: &Value) -> Option<u64> {
1575    value.as_u64()
1576}
1577
1578fn validate_section_arity_value(value: &Value) -> Result<(), ParseError> {
1579    let obj = value.as_object().ok_or_else(|| {
1580        metadata_json_error("section_arity must be an object mapping section names to integers")
1581    })?;
1582    for (name, entry) in obj {
1583        let n = json_u64(entry).ok_or_else(|| {
1584            metadata_json_error(format!("section_arity.{name} must be an integer >= 1"))
1585        })?;
1586        if n < 1 {
1587            return Err(ParseError::InvalidCount(format!(
1588                "section_arity.{name} must be >= 1"
1589            )));
1590        }
1591        // Reserved names have a fixed value-field count (CON-section.13).
1592        if let Some(expect) = crate::types::reserved_section_value_arity(name)
1593            && n != expect
1594        {
1595            return Err(ParseError::InvalidVectorLength {
1596                expected: usize::try_from(expect).unwrap_or(usize::MAX),
1597                found: usize::try_from(n).unwrap_or(usize::MAX),
1598            });
1599        }
1600    }
1601    Ok(())
1602}
1603
1604/// Integer counts. A sign, a decimal point, or any other non-digit token
1605/// is `InvalidCount` (CON-error.3). Zero is a legal token here. The caller
1606/// rejects a per-type count of 0 when the type count is 1 or more (D4).
1607pub(crate) fn parse_count_line(line: &str, n: usize) -> Result<Vec<usize>, ParseError> {
1608    let mut values = Vec::with_capacity(n.min(max_tokens_on_line(line)));
1609    for token in line.split_ascii_whitespace() {
1610        if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
1611            return Err(ParseError::InvalidCount(format!(
1612                "count field is not an unsigned integer: {token}"
1613            )));
1614        }
1615        let val: u64 = token.parse::<u64>().map_err(|_| {
1616            ParseError::InvalidCount(format!("count field is out of range: {token}"))
1617        })?;
1618        let val = usize::try_from(val).map_err(|_| {
1619            ParseError::InvalidCount(format!("count field is out of range: {token}"))
1620        })?;
1621        values.push(val);
1622    }
1623    if values.len() == n {
1624        Ok(values)
1625    } else {
1626        Err(ParseError::InvalidVectorLength {
1627            expected: n,
1628            found: values.len(),
1629        })
1630    }
1631}
1632
1633/// True when `line` has no tokens. Spaces and tabs count as empty.
1634pub(crate) fn line_is_whitespace(line: &str) -> bool {
1635    line.split_ascii_whitespace().next().is_none()
1636}
1637
1638/// Lines 8 and 9 of a type count of 0.
1639///
1640/// A token on either line is `InvalidCount`. At end of file, a missing
1641/// line is empty (CON-count.4), and that frame is not `IncompleteHeader`
1642/// (CON-frame.3). Mid-file, both lines are required.
1643pub(crate) fn read_zero_atom_tail<'a>(
1644    mut next_line: impl FnMut() -> Option<&'a str>,
1645) -> Result<(), ParseError> {
1646    let bad = || {
1647        ParseError::InvalidCount("a zero-atom frame requires whitespace-only lines 8 and 9".into())
1648    };
1649    match next_line() {
1650        None => Ok(()),
1651        Some(line) if line_is_whitespace(line) => match next_line() {
1652            None => Ok(()),
1653            Some(line) if line_is_whitespace(line) => Ok(()),
1654            Some(_) => Err(bad()),
1655        },
1656        Some(_) => Err(bad()),
1657    }
1658}
1659
1660fn metadata_json_error(message: impl Into<String>) -> ParseError {
1661    ParseError::InvalidMetadataJson(message.into())
1662}
1663
1664fn validate_metadata_number(key: &str, value: &Value) -> Result<(), ParseError> {
1665    if value.as_f64().is_some() {
1666        Ok(())
1667    } else {
1668        Err(metadata_json_error(format!(
1669            "{key} must be a finite number"
1670        )))
1671    }
1672}
1673
1674fn validate_metadata_integer(key: &str, value: &Value) -> Result<(), ParseError> {
1675    if value.as_u64().is_some() {
1676        Ok(())
1677    } else {
1678        Err(metadata_json_error(format!(
1679            "{key} must be a non-negative integer"
1680        )))
1681    }
1682}
1683
1684/// Validates a parsed metadata JSON object against the spec v2 schema.
1685///
1686/// Type-checks the `validate` and `sections` keys, then runs per-key
1687/// schema checks for the recommended metadata keys. Used by the
1688/// builder/Python `set_metadata_json` paths to fail fast on malformed
1689/// input, and by the parser when the file requested strict validation
1690/// via `"validate": true`.
1691pub fn validate_metadata_schema(
1692    json_obj: &serde_json::Map<String, Value>,
1693) -> Result<(), ParseError> {
1694    let strict_requested = match json_obj.get(meta::VALIDATE) {
1695        Some(Value::Bool(b)) => *b,
1696        Some(_) => return Err(metadata_json_error("validate must be a boolean")),
1697        None => false,
1698    };
1699
1700    match json_obj.get(meta::SECTIONS) {
1701        Some(Value::Array(values)) => {
1702            if values.iter().any(|entry| !entry.is_string()) {
1703                return Err(metadata_json_error("sections must be an array of strings"));
1704            }
1705        }
1706        Some(_) => return Err(metadata_json_error("sections must be an array of strings")),
1707        None if strict_requested => {
1708            return Err(metadata_json_error(
1709                "validate=true requires a sections array, even when empty",
1710            ));
1711        }
1712        None => {}
1713    }
1714
1715    for (key, value) in json_obj {
1716        match key.as_str() {
1717            meta::CON_SPEC_VERSION | meta::SECTIONS | meta::VALIDATE => {}
1718            meta::CON_SPEC_MINOR => {
1719                if json_u64(value).is_none() {
1720                    return Err(metadata_json_error(
1721                        "con_spec_minor must be a JSON integer >= 0",
1722                    ));
1723                }
1724            }
1725            meta::SECTION_ARITY => validate_section_arity_value(value)?,
1726            meta::ENERGY
1727            | meta::TIME
1728            | meta::TIMESTEP
1729            | meta::CONVERGENCE_FMAX
1730            | meta::CONVERGENCE_ENERGY
1731            | meta::FMAX => validate_metadata_number(key, value)?,
1732            meta::FRAME_INDEX | meta::NEB_BEAD | meta::NEB_BAND => {
1733                validate_metadata_integer(key, value)?
1734            }
1735            meta::GENERATOR if !value.is_string() => {
1736                return Err(metadata_json_error("generator must be a string"));
1737            }
1738            meta::GENERATOR => {}
1739            meta::UNITS | meta::POTENTIAL if !value.is_object() => {
1740                return Err(metadata_json_error(format!("{key} must be an object")));
1741            }
1742            meta::POTENTIAL => {
1743                if let Some(potential_type) = value.get("type")
1744                    && !potential_type.is_string()
1745                {
1746                    return Err(metadata_json_error("potential.type must be a string"));
1747                }
1748            }
1749            meta::UNITS => {
1750                // Full v3 checks (required keys) run when con_spec_version >= 3.
1751            }
1752            meta::PBC => validate_pbc_metadata(value)?,
1753            meta::BONDS => validate_bonds_metadata(value)?,
1754            meta::LATTICE_VECTORS => validate_lattice_vectors_metadata(value)?,
1755            meta::CONVERGED if !value.is_boolean() => {
1756                return Err(metadata_json_error("converged must be a boolean"));
1757            }
1758            meta::CONVERGED => {}
1759            _ => {}
1760        }
1761    }
1762
1763    Ok(())
1764}
1765
1766fn validate_pbc_metadata(value: &Value) -> Result<(), ParseError> {
1767    let Some(values) = value.as_array() else {
1768        return Err(metadata_json_error("pbc must be a length-3 boolean array"));
1769    };
1770    if values.len() != 3 || values.iter().any(|entry| !entry.is_boolean()) {
1771        return Err(metadata_json_error("pbc must be a length-3 boolean array"));
1772    }
1773    Ok(())
1774}
1775
1776/// Validate optional `bonds` frame topology metadata.
1777///
1778/// Each element is either a length-2 non-negative integer pair `[i, j]` or an
1779/// object `{"i": i, "j": j, "order"?: integer}`. Indices are 0-based into
1780/// `atom_data` order (parser does not yet know atom count at metadata time;
1781/// bounds are enforced when projecting to chemfiles / selection).
1782fn validate_bonds_metadata(value: &Value) -> Result<(), ParseError> {
1783    let Some(items) = value.as_array() else {
1784        return Err(metadata_json_error("bonds must be an array"));
1785    };
1786    for (idx, item) in items.iter().enumerate() {
1787        if let Some(pair) = item.as_array() {
1788            if pair.len() != 2 {
1789                return Err(metadata_json_error(format!(
1790                    "bonds[{idx}] pair must have exactly two indices"
1791                )));
1792            }
1793            for (k, entry) in pair.iter().enumerate() {
1794                let Some(n) = entry.as_u64() else {
1795                    return Err(metadata_json_error(format!(
1796                        "bonds[{idx}][{k}] must be a non-negative integer"
1797                    )));
1798                };
1799                if n > u32::MAX as u64 {
1800                    return Err(metadata_json_error(format!(
1801                        "bonds[{idx}][{k}] index exceeds u32"
1802                    )));
1803                }
1804            }
1805            continue;
1806        }
1807        if let Some(obj) = item.as_object() {
1808            for key in ["i", "j"] {
1809                let Some(entry) = obj.get(key) else {
1810                    return Err(metadata_json_error(format!(
1811                        "bonds[{idx}] object must include \"{key}\""
1812                    )));
1813                };
1814                let Some(n) = entry.as_u64() else {
1815                    return Err(metadata_json_error(format!(
1816                        "bonds[{idx}].{key} must be a non-negative integer"
1817                    )));
1818                };
1819                if n > u32::MAX as u64 {
1820                    return Err(metadata_json_error(format!(
1821                        "bonds[{idx}].{key} index exceeds u32"
1822                    )));
1823                }
1824            }
1825            if let Some(order) = obj.get("order")
1826                && order.as_i64().is_none()
1827            {
1828                return Err(metadata_json_error(format!(
1829                    "bonds[{idx}].order must be an integer when present"
1830                )));
1831            }
1832            continue;
1833        }
1834        return Err(metadata_json_error(format!(
1835            "bonds[{idx}] must be [i, j] or {{\"i\": i, \"j\": j, \"order\"?: ...}}"
1836        )));
1837    }
1838    Ok(())
1839}
1840
1841fn validate_lattice_vectors_metadata(value: &Value) -> Result<(), ParseError> {
1842    let Some(rows) = value.as_array() else {
1843        return Err(metadata_json_error(
1844            "lattice_vectors must be a 3x3 numeric array",
1845        ));
1846    };
1847    if rows.len() != 3 {
1848        return Err(metadata_json_error(
1849            "lattice_vectors must be a 3x3 numeric array",
1850        ));
1851    }
1852    for row in rows {
1853        let Some(entries) = row.as_array() else {
1854            return Err(metadata_json_error(
1855                "lattice_vectors must be a 3x3 numeric array",
1856            ));
1857        };
1858        if entries.len() != 3 || entries.iter().any(|entry| entry.as_f64().is_none()) {
1859            return Err(metadata_json_error(
1860                "lattice_vectors must be a 3x3 numeric array",
1861            ));
1862        }
1863    }
1864    Ok(())
1865}
1866
1867/// Parses a line of whitespace-separated values into a vector of a specific type.
1868///
1869/// This generic helper function takes a string slice, splits it by whitespace,
1870/// and attempts to parse each substring into the target type `T`. The type `T`
1871/// must implement `std::str::FromStr`.
1872///
1873/// # Arguments
1874///
1875/// * `line` - A string slice representing a single line of data.
1876/// * `n` - The exact number of values expected on the line.
1877///
1878/// # Errors
1879///
1880/// * `ParseError::InvalidVectorLength` if the number of parsed values is not equal to `n`.
1881/// * Propagates any error from the `parse()` method of the target type `T`.
1882///
1883/// # Example
1884///
1885/// ```
1886/// use readcon_core::parser::parse_line_of_n;
1887/// let line = "10.5 20.0 30.5";
1888/// let values: Vec<f64> = parse_line_of_n(line, 3).unwrap();
1889/// assert_eq!(values, vec![10.5, 20.0, 30.5]);
1890///
1891/// let result = parse_line_of_n::<i32>(line, 2);
1892/// assert!(result.is_err());
1893/// ```
1894pub fn parse_line_of_n<T: std::str::FromStr>(line: &str, n: usize) -> Result<Vec<T>, ParseError>
1895where
1896    ParseError: From<<T as std::str::FromStr>::Err>,
1897{
1898    let values: Vec<T> = line
1899        .split_whitespace()
1900        .map(|s| s.parse::<T>())
1901        .collect::<Result<_, _>>()?;
1902
1903    if values.len() == n {
1904        Ok(values)
1905    } else {
1906        Err(ParseError::InvalidVectorLength {
1907            expected: n,
1908            found: values.len(),
1909        })
1910    }
1911}
1912
1913/// Parses the 9-line header of a `.con` file frame from an iterator.
1914///
1915/// This function consumes the next 9 lines from the given line iterator to
1916/// construct a `FrameHeader`. The iterator is advanced by 9 lines on success.
1917///
1918/// # Arguments
1919///
1920/// * `lines` - A mutable reference to an iterator that yields string slices.
1921///
1922/// # Errors
1923///
1924/// * `ParseError::IncompleteHeader` if the iterator has fewer than 9 lines remaining.
1925/// * Propagates any errors from `parse_line_of_n` if the numeric data within
1926///   the header is malformed.
1927///
1928/// # Panics
1929///
1930/// This function will panic if the intermediate vectors for box dimensions or angles,
1931/// after being successfully parsed, cannot be converted into fixed-size arrays.
1932/// This should not happen if `parse_line_of_n` is used correctly with `n=3`.
1933pub fn parse_frame_header<'a>(
1934    lines: &mut impl Iterator<Item = &'a str>,
1935) -> Result<FrameHeader, ParseError> {
1936    let prebox1 = lines
1937        .next()
1938        .ok_or(ParseError::IncompleteHeader)?
1939        .to_string();
1940    // CON-file.2: a leading BOM is rejected. It would sit on line 1.
1941    if prebox1.starts_with('\u{feff}') {
1942        return Err(ParseError::ValidationError(
1943            "file begins with a byte-order mark".into(),
1944        ));
1945    }
1946    let prebox2_raw = lines.next().ok_or(ParseError::IncompleteHeader)?;
1947
1948    // Line 2: if it starts with '{', parse as JSON metadata (spec v2+).
1949    // Otherwise treat as a legacy (pre-v2) file with spec_version = 1.
1950    // One JSON object and nothing else (CON-line2.1). serde_json::from_str
1951    // rejects trailing junk after the value.
1952    let trimmed = prebox2_raw.trim();
1953    let (spec_version, sections, validate, sections_declared) =
1954        if trimmed == "{\"con_spec_version\":2}" {
1955            // The line written by every stock trajectory. Same object the
1956            // JSON parser would build: major 2, no extra keys.
1957            (2, Vec::new(), false, false)
1958        } else if let Some(ver) = simple_metadata_version(trimmed) {
1959            // A header whose only keys are the spec version, an optional
1960            // minor, and a units object. Every byte is still read. Any
1961            // other key falls through to the general parser.
1962            (ver, Vec::new(), false, false)
1963        } else if trimmed.starts_with('{') {
1964            let json_val: serde_json::Value = serde_json::from_str(trimmed)
1965                .map_err(|e| ParseError::InvalidMetadataJson(e.to_string()))?;
1966            let json_obj = json_val.as_object().ok_or_else(|| {
1967                ParseError::InvalidMetadataJson("expected a JSON object".to_string())
1968            })?;
1969            let ver_value = json_obj
1970                .get(meta::CON_SPEC_VERSION)
1971                .ok_or(ParseError::MissingSpecVersion)?;
1972            let ver = json_u64(ver_value).ok_or_else(|| {
1973                ParseError::InvalidMetadataJson(
1974                    "con_spec_version must be a JSON integer, the major version".into(),
1975                )
1976            })?;
1977            if ver > u64::from(u32::MAX) {
1978                return Err(ParseError::UnsupportedVersion(u32::MAX));
1979            }
1980            let ver = ver as u32;
1981            // JSON majors 0 and 1 are UnsupportedVersion (CON-line2.5).
1982            // An unknown major is UnsupportedVersion (CON-line2.6).
1983            // Unknown minors of a known major are accepted (CON-line2.7, CON-status.1).
1984            if ver == 0 || ver == 1 || ver > crate::CON_SPEC_VERSION {
1985                return Err(ParseError::UnsupportedVersion(ver));
1986            }
1987            if ver >= 3 {
1988                match json_obj.get(meta::UNITS) {
1989                    Some(u) => crate::units::validate_v3_units_metadata(u)
1990                        .map_err(|e| ParseError::InvalidUnits(format!("v3 units: {e}")))?,
1991                    None => {
1992                        return Err(ParseError::InvalidUnits(
1993                        "con_spec_version >= 3 requires metadata \"units\" with length and energy"
1994                            .into(),
1995                    ));
1996                    }
1997                }
1998            }
1999
2000            // Single pass over the JSON object: collect sections, capture the
2001            // validate flag, copy the rest into metadata. Folds the previous
2002            // pre-extract get(validate) + re-iterate pattern into one walk.
2003            let mut sections: Vec<String> = Vec::new();
2004            let mut sections_declared = false;
2005            let mut validate = false;
2006            for (k, v) in json_obj {
2007                match k.as_str() {
2008                    meta::CON_SPEC_VERSION => {}
2009                    meta::CON_SPEC_MINOR => {
2010                        if json_u64(v).is_none() {
2011                            return Err(metadata_json_error(
2012                                "con_spec_minor must be a JSON integer >= 0",
2013                            ));
2014                        }
2015                    }
2016                    meta::SECTION_ARITY => {
2017                        validate_section_arity_value(v)?;
2018                    }
2019                    meta::SECTIONS => {
2020                        sections_declared = true;
2021                        let arr = v.as_array().ok_or_else(|| {
2022                            metadata_json_error("sections must be an array of strings")
2023                        })?;
2024                        sections.reserve(arr.len());
2025                        for entry in arr {
2026                            let s = entry.as_str().ok_or_else(|| {
2027                                metadata_json_error("sections must be an array of strings")
2028                            })?;
2029                            sections.push(s.to_string());
2030                        }
2031                    }
2032                    meta::VALIDATE => {
2033                        validate = match v {
2034                            Value::Bool(b) => *b,
2035                            _ => return Err(metadata_json_error("validate must be a boolean")),
2036                        };
2037                    }
2038                    _ => {}
2039                }
2040            }
2041
2042            // Strict-mode schema check fires only when the file requested
2043            // it. Hot-path parses (validate=false) skip the per-key match.
2044            if validate {
2045                validate_metadata_schema(json_obj)?;
2046            }
2047
2048            (ver, sections, validate, sections_declared)
2049        } else {
2050            // Legacy file: no JSON metadata line.
2051            (1_u32, Vec::new(), false, false)
2052        };
2053    let prebox2 = prebox2_raw.to_string();
2054
2055    let boxl_vec = parse_line_of_n_f64(lines.next().ok_or(ParseError::IncompleteHeader)?, 3)?;
2056    let angles_vec = parse_line_of_n_f64(lines.next().ok_or(ParseError::IncompleteHeader)?, 3)?;
2057    let postbox1 = lines
2058        .next()
2059        .ok_or(ParseError::IncompleteHeader)?
2060        .to_string();
2061    let postbox2 = lines
2062        .next()
2063        .ok_or(ParseError::IncompleteHeader)?
2064        .to_string();
2065    let natm_types_line = lines.next().ok_or(ParseError::IncompleteHeader)?;
2066    let natm_types = parse_count_line(natm_types_line, 1)?[0];
2067    // A type count of 0 is a zero-atom frame (CON-count.1, CON-count.4).
2068    // A per-type count of 0 is InvalidCount only when the type count is >= 1.
2069    let (natms_per_type, masses_per_type) = if natm_types == 0 {
2070        read_zero_atom_tail(|| lines.next())?;
2071        (Vec::new(), Vec::new())
2072    } else {
2073        let natms_per_type = parse_count_line(
2074            lines.next().ok_or(ParseError::IncompleteHeader)?,
2075            natm_types,
2076        )?;
2077        if natms_per_type.contains(&0) {
2078            return Err(ParseError::InvalidCount(
2079                "each per-type count must be >= 1".into(),
2080            ));
2081        }
2082        let masses_per_type = parse_line_of_n_f64(
2083            lines.next().ok_or(ParseError::IncompleteHeader)?,
2084            natm_types,
2085        )?;
2086        (natms_per_type, masses_per_type)
2087    };
2088    if validate {
2089        validate_header_geometry(&boxl_vec, &angles_vec)?;
2090        validate_masses(&masses_per_type)?;
2091    }
2092    let shared_line: std::sync::Arc<str> = std::sync::Arc::from(prebox2);
2093    let metadata = crate::types::FrameMeta::from_shared(std::sync::Arc::clone(&shared_line));
2094    Ok(FrameHeader {
2095        prebox_header: PreboxHeader {
2096            user: prebox1,
2097            metadata_line: shared_line,
2098        },
2099        boxl: boxl_vec.try_into().unwrap(),
2100        angles: angles_vec.try_into().unwrap(),
2101        postbox_header: [postbox1, postbox2],
2102        natm_types,
2103        natms_per_type,
2104        masses_per_type,
2105        spec_version,
2106        metadata,
2107        sections,
2108        strict_validation: validate,
2109        sections_declared,
2110    })
2111}
2112
2113/// Spec line limited to `con_spec_version`, optional `con_spec_minor`, and `units`.
2114///
2115/// Returns the major version when the object is exactly that shape and, for
2116/// version 3 or newer, `length` and `energy` are valid units. Anything else
2117/// is `None` so the general JSON parser can accept or reject it.
2118fn simple_metadata_version(line: &str) -> Option<u32> {
2119    let bytes = line.as_bytes();
2120    let mut i = 0usize;
2121    let skip = |i: &mut usize| {
2122        while *i < bytes.len() && bytes[*i].is_ascii_whitespace() {
2123            *i += 1;
2124        }
2125    };
2126    let eat = |i: &mut usize, byte: u8| -> bool {
2127        skip(i);
2128        if *i < bytes.len() && bytes[*i] == byte {
2129            *i += 1;
2130            true
2131        } else {
2132            false
2133        }
2134    };
2135    let quoted = |i: &mut usize| -> Option<&str> {
2136        skip(i);
2137        if *i >= bytes.len() || bytes[*i] != b'"' {
2138            return None;
2139        }
2140        *i += 1;
2141        let start = *i;
2142        while *i < bytes.len() && bytes[*i] != b'"' {
2143            if bytes[*i] == b'\\' {
2144                return None;
2145            }
2146            *i += 1;
2147        }
2148        if *i >= bytes.len() {
2149            return None;
2150        }
2151        let text = std::str::from_utf8(&bytes[start..*i]).ok()?;
2152        *i += 1;
2153        Some(text)
2154    };
2155    let integer = |i: &mut usize| -> Option<u64> {
2156        skip(i);
2157        let start = *i;
2158        if *i < bytes.len() && bytes[*i] == b'-' {
2159            return None;
2160        }
2161        while *i < bytes.len() && bytes[*i].is_ascii_digit() {
2162            *i += 1;
2163        }
2164        if start == *i || (bytes[start] == b'0' && *i > start + 1) {
2165            return None;
2166        }
2167        std::str::from_utf8(&bytes[start..*i]).ok()?.parse().ok()
2168    };
2169    if !eat(&mut i, b'{') {
2170        return None;
2171    }
2172    let mut version = None;
2173    let mut saw_minor = false;
2174    let mut length = None;
2175    let mut energy = None;
2176    let mut extra_unit = false;
2177    loop {
2178        skip(&mut i);
2179        if i < bytes.len() && bytes[i] == b'}' {
2180            i += 1;
2181            break;
2182        }
2183        if version.is_some() || saw_minor || length.is_some() || energy.is_some() || extra_unit {
2184            if !eat(&mut i, b',') {
2185                return None;
2186            }
2187        }
2188        let key = quoted(&mut i)?;
2189        if !eat(&mut i, b':') {
2190            return None;
2191        }
2192        match key {
2193            "con_spec_version" if version.is_none() => version = Some(integer(&mut i)?),
2194            "con_spec_minor" if !saw_minor => {
2195                integer(&mut i)?;
2196                saw_minor = true;
2197            }
2198            "units" if length.is_none() && energy.is_none() && !extra_unit => {
2199                if !eat(&mut i, b'{') {
2200                    return None;
2201                }
2202                loop {
2203                    skip(&mut i);
2204                    if i < bytes.len() && bytes[i] == b'}' {
2205                        i += 1;
2206                        break;
2207                    }
2208                    if length.is_some() || energy.is_some() || extra_unit {
2209                        if !eat(&mut i, b',') {
2210                            return None;
2211                        }
2212                    }
2213                    let ukey = quoted(&mut i)?;
2214                    if !eat(&mut i, b':') {
2215                        return None;
2216                    }
2217                    let value = quoted(&mut i)?;
2218                    match ukey {
2219                        "length" if length.is_none() => length = Some(value),
2220                        "energy" if energy.is_none() => energy = Some(value),
2221                        "mass" | "time" | "velocity" | "force" => {
2222                            if crate::units::validate_unit_for_quantity(ukey, value).is_err() {
2223                                return None;
2224                            }
2225                            extra_unit = true;
2226                        }
2227                        _ => return None,
2228                    }
2229                }
2230            }
2231            _ => return None,
2232        }
2233    }
2234    skip(&mut i);
2235    if i != bytes.len() {
2236        return None;
2237    }
2238    let ver = u32::try_from(version?).ok()?;
2239    if ver == 0 || ver == 1 || ver > crate::CON_SPEC_VERSION {
2240        return None;
2241    }
2242    if ver >= 3 {
2243        let length = length?;
2244        let energy = energy?;
2245        if length.trim().is_empty() || energy.trim().is_empty() {
2246            return None;
2247        }
2248        crate::units::validate_unit_for_quantity("length", length).ok()?;
2249        crate::units::validate_unit_for_quantity("energy", energy).ok()?;
2250    }
2251    Some(ver)
2252}
2253
2254/// Parses a complete frame from a `.con` file, including its header and atomic data.
2255///
2256/// This function first parses the complete frame header and then uses the information within it
2257/// (specifically the number of atom types and atoms per type) to parse the subsequent
2258/// atom coordinate blocks.
2259///
2260/// Each atom takes one line, so the atom vectors reserve no more rows than the
2261/// upper bound of `lines.size_hint()`. With no upper bound, as for `str::lines`,
2262/// they grow as the rows arrive.
2263///
2264/// # Arguments
2265///
2266/// * `lines` - A mutable reference to an iterator that yields string slices for the frame.
2267///
2268/// # Errors
2269///
2270/// * `ParseError::IncompleteFrame` if the iterator ends before all expected
2271///   atomic data has been read.
2272/// * Propagates any errors from the underlying calls to `parse_frame_header` and
2273///   `parse_line_of_n`.
2274///
2275/// # Example
2276///
2277/// ```
2278/// use readcon_core::parser::parse_single_frame;
2279///
2280/// let frame_text = r#"
2281///Generated by test
2282///{"con_spec_version":2}
2283///10.0 10.0 10.0
2284///90.0 90.0 90.0
2285///POSTBOX LINE 1
2286///POSTBOX LINE 2
2287///2
2288///1 1
2289///12.011 1.008
2290///C
2291///Coordinates of Component 1
2292///1.0 1.0 1.0 0 1
2293///H
2294///Coordinates of Component 2
2295///2.0 2.0 2.0 0 2
2296/// "#;
2297///
2298/// let mut lines = frame_text.trim().lines();
2299/// let con_frame = parse_single_frame(&mut lines).unwrap();
2300///
2301/// assert_eq!(con_frame.header.natm_types, 2);
2302/// assert_eq!(con_frame.atom_data.len(), 2);
2303/// assert_eq!(&*con_frame.atom_data[0].symbol, "C");
2304/// assert_eq!(con_frame.atom_data[1].atom_id, 2);
2305/// ```
2306pub fn parse_single_frame<'a>(
2307    lines: &mut impl Iterator<Item = &'a str>,
2308) -> Result<ConFrame, ParseError> {
2309    // The caller owns the iterator and may not be able to clone it. Section
2310    // detection, which needs a lookahead clone, runs later on a `LineStream`
2311    // that can probe (`MemchrLines`, or a `Peekable` whose source is `Clone`).
2312    let max_rows = lines.size_hint().1;
2313    parse_single_frame_ls(&mut IterStream(lines), max_rows)
2314}
2315
2316/// Iterator adapter so [`parse_single_frame`] can satisfy [`LineStream`]
2317/// without requiring [`Clone`].
2318struct IterStream<I>(I);
2319
2320impl<'a, I> Iterator for IterStream<I>
2321where
2322    I: Iterator<Item = &'a str>,
2323{
2324    type Item = &'a str;
2325    fn next(&mut self) -> Option<&'a str> {
2326        self.0.next()
2327    }
2328}
2329
2330impl<'a, I> LineStream<'a> for IterStream<I>
2331where
2332    I: Iterator<Item = &'a str>,
2333{
2334    fn next_line(&mut self) -> Option<&'a str> {
2335        Iterator::next(self)
2336    }
2337    fn peek_line(&mut self) -> Option<&'a str> {
2338        None
2339    }
2340}
2341
2342struct CoordShell {
2343    atoms: Vec<crate::types::AtomDatum>,
2344    coords: Vec<f64>,
2345    ids: Vec<u64>,
2346}
2347
2348thread_local! {
2349    static COORD_SHELLS: std::cell::RefCell<Vec<CoordShell>> =
2350        const { std::cell::RefCell::new(Vec::new()) };
2351}
2352
2353/// Allocate `n_frames` coordinate shells on this thread before the parse.
2354///
2355/// One tight reservation, then each frame takes a shell. Doing the
2356/// reservations inside the parse mixed the allocator with the float
2357/// parse and made eight threads slower than the bytes they write.
2358pub(crate) fn prestash_coord_shells(n_frames: usize, n_atoms: usize) {
2359    // Small frames (the 32-atom strong-scaling files) are cheaper to
2360    // allocate inside the parse. A burst of shells ahead of those frames
2361    // made eight threads slower. Wide atoms, as in the weak-scaling
2362    // fixture, are the case the burst is for.
2363    if n_frames == 0 || n_atoms < 128 {
2364        return;
2365    }
2366    COORD_SHELLS.with(|pool| {
2367        let mut pool = pool.borrow_mut();
2368        pool.reserve(n_frames);
2369        for _ in 0..n_frames {
2370            pool.push(CoordShell {
2371                atoms: Vec::with_capacity(n_atoms),
2372                coords: Vec::with_capacity(n_atoms.saturating_mul(3)),
2373                ids: Vec::with_capacity(n_atoms),
2374            });
2375        }
2376    });
2377}
2378
2379pub(crate) fn clear_coord_shells() {
2380    COORD_SHELLS.with(|pool| pool.borrow_mut().clear());
2381}
2382
2383fn take_coord_shell(n_atoms: usize) -> Option<CoordShell> {
2384    COORD_SHELLS.with(|pool| {
2385        let shell = pool.borrow_mut().pop()?;
2386        if shell.atoms.capacity() >= n_atoms
2387            && shell.coords.capacity() >= n_atoms.saturating_mul(3)
2388            && shell.ids.capacity() >= n_atoms
2389        {
2390            Some(shell)
2391        } else {
2392            None
2393        }
2394    })
2395}
2396
2397/// Columns every atom line has: x, y, z and the flag (spec "Coordinates",
2398/// CON-num.9). The id column is optional (CON-id.9).
2399const COORD_ROW_MIN_COLUMNS: usize = 4;
2400
2401/// Fewest bytes an atom line takes, as in `0 0 0 0`: one byte for each of
2402/// the [`COORD_ROW_MIN_COLUMNS`] columns and one separator between each
2403/// pair. Newlines and the symbol and label lines only add bytes, so `n`
2404/// unread bytes hold at most `n / MIN_COORD_ROW_BYTES` atom lines.
2405pub(crate) const MIN_COORD_ROW_BYTES: usize = 2 * COORD_ROW_MIN_COLUMNS - 1;
2406
2407/// Frame parse over a [`LineStream`].
2408///
2409/// Coordinate rows go through [`LineStream::next_coord_row`] so the memchr
2410/// cursor can fuse the newline scan with numeric parse. Positions, ids, and
2411/// flags land in flat buffers; [`AtomDatum`] values are built from those
2412/// buffers afterwards (one `Vec` allocation, no per-atom heap traffic).
2413///
2414/// `max_rows` bounds the atom lines the rest of the input can hold, and is
2415/// `None` when that is unknown. The buffers reserve no more atoms than it
2416/// allows, so a header that declares more atoms than the input holds
2417/// cannot force a large allocation. The declared counts still decide how
2418/// many rows are read, and so which error is reported.
2419pub(crate) fn parse_single_frame_ls<'a, L>(
2420    lines: &mut L,
2421    max_rows: Option<usize>,
2422) -> Result<ConFrame, ParseError>
2423where
2424    L: LineStream<'a> + Iterator<Item = &'a str>,
2425{
2426    let header = parse_frame_header(lines)?;
2427    // A count the unread input can hold stays in this function, on the same
2428    // reservation and `Arc` bump as before the bound existed. A count that
2429    // cannot fit is a separate cold function so it does not shape this loop.
2430    let Some(total_atoms) = fitting_atom_count(&header.natms_per_type, max_rows) else {
2431        return parse_oversized_frame(lines, header, max_rows);
2432    };
2433    let validate = header.strict_validation;
2434    use crate::storage_dtype::{ElementKind, FloatArray2};
2435    let dt = header.metadata.dtypes();
2436    let f64_positions = dt.positions == ElementKind::Float64;
2437    let shell = take_coord_shell(total_atoms);
2438    let (mut pos_flat, mut ids, mut atom_data) = match shell {
2439        Some(shell) => (shell.coords, shell.ids, shell.atoms),
2440        None => (
2441            Vec::with_capacity(total_atoms.saturating_mul(3)),
2442            Vec::with_capacity(total_atoms),
2443            Vec::with_capacity(total_atoms),
2444        ),
2445    };
2446    let mut positions_other = if f64_positions {
2447        None
2448    } else {
2449        Some(FloatArray2::zeros(dt.positions, total_atoms, 3))
2450    };
2451    let mut global_atom_idx: u64 = 0;
2452    for (type_idx, num_atoms) in header.natms_per_type.iter().enumerate() {
2453        let symbol_line = lines.next().ok_or(ParseError::IncompleteFrame)?;
2454        let symbol: Arc<str> = Arc::from(symbol_line.trim());
2455        let coord_label = lines.next().ok_or(ParseError::IncompleteFrame)?;
2456        if validate {
2457            validate_coordinate_component(type_idx, symbol.as_ref(), coord_label)?;
2458        }
2459        global_atom_idx = ingest_coord_rows(
2460            lines,
2461            *num_atoms,
2462            header.spec_version,
2463            global_atom_idx,
2464            &symbol,
2465            CoordSink {
2466                pos_flat: &mut pos_flat,
2467                ids: &mut ids,
2468                positions_other: &mut positions_other,
2469                atom_data: &mut atom_data,
2470            },
2471        )?;
2472    }
2473    let positions = if f64_positions {
2474        FloatArray2::from_f64_row_major(total_atoms, 3, pos_flat)
2475    } else {
2476        positions_other.expect("non-f64 positions allocated")
2477    };
2478    Ok(crate::types::con_frame_coords_only_with_ids(
2479        header, atom_data, positions, ids,
2480    ))
2481}
2482
2483/// Atom total when it can use [`Vec::with_capacity`] safely.
2484///
2485/// `None` means the counts wrap, exceed `isize::MAX / 4`, or exceed the
2486/// unread atom lines. A `u32::MAX` count with no size hint is `None`: that
2487/// reservation is tens of GiB.
2488#[inline]
2489fn fitting_atom_count(counts: &[usize], max_rows: Option<usize>) -> Option<usize> {
2490    let mut total = 0usize;
2491    for &count in counts {
2492        total = total.checked_add(count)?;
2493    }
2494    if total > (isize::MAX as usize) / 4 {
2495        return None;
2496    }
2497    match max_rows {
2498        Some(rows) if total > rows => None,
2499        None if total > (1 << 20) => None,
2500        _ => Some(total),
2501    }
2502}
2503
2504/// A header declared more atoms than the rest of the input can hold.
2505///
2506/// Reservations stay within `max_rows`. Rows are still read up to the
2507/// declared count, so a short file is `IncompleteFrame`.
2508#[cold]
2509#[inline(never)]
2510fn parse_oversized_frame<'a, L>(
2511    lines: &mut L,
2512    header: FrameHeader,
2513    max_rows: Option<usize>,
2514) -> Result<ConFrame, ParseError>
2515where
2516    L: LineStream<'a> + Iterator<Item = &'a str>,
2517{
2518    let validate = header.strict_validation;
2519    let total_atoms = header
2520        .natms_per_type
2521        .iter()
2522        .fold(0usize, |sum, &n| sum.saturating_add(n));
2523    let reserve_atoms = max_rows.map_or(0, |rows| total_atoms.min(rows));
2524    use crate::storage_dtype::FloatArray2;
2525    let dt = header.metadata.dtypes();
2526    let mut pos_flat = Vec::new();
2527    let _ = pos_flat.try_reserve_exact(reserve_atoms.saturating_mul(3));
2528    let mut ids = Vec::new();
2529    let _ = ids.try_reserve_exact(reserve_atoms);
2530    let mut atom_data: Vec<AtomDatum> = Vec::new();
2531    let _ = atom_data.try_reserve_exact(reserve_atoms);
2532
2533    let mut global_atom_idx: u64 = 0;
2534    for (type_idx, num_atoms) in header.natms_per_type.iter().enumerate() {
2535        let symbol_line = lines.next().ok_or(ParseError::IncompleteFrame)?;
2536        let symbol: Arc<str> = Arc::from(symbol_line.trim());
2537        let coord_label = lines.next().ok_or(ParseError::IncompleteFrame)?;
2538        if validate {
2539            validate_coordinate_component(type_idx, symbol.as_ref(), coord_label)?;
2540        }
2541        global_atom_idx = ingest_coord_rows_cloned(
2542            lines,
2543            *num_atoms,
2544            header.spec_version,
2545            global_atom_idx,
2546            &symbol,
2547            &mut pos_flat,
2548            &mut ids,
2549            &mut atom_data,
2550        )?;
2551    }
2552    let mut positions = FloatArray2::from_f64_row_major(total_atoms, 3, pos_flat);
2553    positions.project_to(dt.positions);
2554    Ok(crate::types::con_frame_coords_only_with_ids(
2555        header, atom_data, positions, ids,
2556    ))
2557}
2558
2559struct CoordSink<'a> {
2560    pos_flat: &'a mut Vec<f64>,
2561    ids: &'a mut Vec<u64>,
2562    positions_other: &'a mut Option<crate::storage_dtype::FloatArray2>,
2563    atom_data: &'a mut Vec<AtomDatum>,
2564}
2565
2566/// Parse `n` coordinate rows into flat SoA buffers.
2567fn ingest_coord_rows<'a, L>(
2568    lines: &mut L,
2569    n: usize,
2570    spec_version: u32,
2571    global_atom_idx: u64,
2572    symbol: &Arc<str>,
2573    sink: CoordSink<'_>,
2574) -> Result<u64, ParseError>
2575where
2576    L: LineStream<'a>,
2577{
2578    let CoordSink {
2579        pos_flat,
2580        ids,
2581        positions_other,
2582        atom_data,
2583    } = sink;
2584    if positions_other.is_none() {
2585        return ingest_coord_rows_f64(
2586            lines,
2587            n,
2588            spec_version,
2589            global_atom_idx,
2590            symbol,
2591            pos_flat,
2592            ids,
2593            atom_data,
2594        );
2595    }
2596    let mut global_atom_idx = global_atom_idx;
2597    for _ in 0..n {
2598        let (xyz, fixed, atom_id) = lines.next_coord_row(spec_version, global_atom_idx)?;
2599        if let Some(pos) = positions_other.as_mut() {
2600            pos.set_f64_row(ids.len(), xyz);
2601        }
2602        let index = pos_flat.len() / 3;
2603        pos_flat.extend_from_slice(&xyz);
2604        let slot = unsafe { pos_flat.as_mut_ptr().add(index * 3) };
2605        ids.push(atom_id);
2606        atom_data.push(AtomDatum::at_slot(Arc::clone(symbol), slot, fixed, atom_id));
2607        global_atom_idx += 1;
2608    }
2609    Ok(global_atom_idx)
2610}
2611
2612/// Coordinate rows for the f64 storage path.
2613///
2614/// The symbol `Arc` is bumped once for the whole block. Cloning it per atom
2615/// was a visible share of the coordinate parse.
2616#[inline(never)]
2617#[allow(clippy::too_many_arguments)]
2618fn ingest_coord_rows_f64<'a, L>(
2619    lines: &mut L,
2620    n: usize,
2621    spec_version: u32,
2622    mut global_atom_idx: u64,
2623    symbol: &Arc<str>,
2624    pos_flat: &mut Vec<f64>,
2625    ids: &mut Vec<u64>,
2626    atom_data: &mut Vec<AtomDatum>,
2627) -> Result<u64, ParseError>
2628where
2629    L: LineStream<'a>,
2630{
2631    let workers = wide_workers();
2632    if workers >= 2 && n >= 4096 {
2633        if let Some(mut block) =
2634            lines.try_take_coord_block(n, spec_version, global_atom_idx, symbol, workers)
2635        {
2636            let base = pos_flat.len();
2637            // Keep a reservation that covers the whole frame. Replacing it
2638            // with a shorter block makes the next species group reallocate
2639            // under live pointers.
2640            if pos_flat.is_empty() && block.xyz.capacity() >= pos_flat.capacity() {
2641                *pos_flat = block.xyz;
2642            } else {
2643                pos_flat.extend_from_slice(&block.xyz);
2644            }
2645            let ptr = pos_flat.as_mut_ptr();
2646            for (i, atom) in block.atoms.iter_mut().enumerate() {
2647                atom.retarget(unsafe { ptr.add(base + i * 3) });
2648            }
2649            if ids.is_empty() {
2650                *ids = block.ids;
2651            } else {
2652                ids.extend_from_slice(&block.ids);
2653            }
2654            if atom_data.is_empty() {
2655                *atom_data = block.atoms;
2656            } else {
2657                atom_data.extend(block.atoms);
2658            }
2659            return Ok(global_atom_idx + n as u64);
2660        }
2661    }
2662    let mut shares = symbol_shares(symbol, n);
2663    for _ in 0..n {
2664        let (xyz, fixed, atom_id) = lines.next_coord_row(spec_version, global_atom_idx)?;
2665        let sym = shares.take();
2666        let index = pos_flat.len() / 3;
2667        pos_flat.extend_from_slice(&xyz);
2668        let slot = unsafe { pos_flat.as_mut_ptr().add(index * 3) };
2669        ids.push(atom_id);
2670        atom_data.push(AtomDatum::at_slot(sym, slot, fixed, atom_id));
2671        global_atom_idx += 1;
2672    }
2673    Ok(global_atom_idx)
2674}
2675
2676/// Coordinate rows when the declared count must not bump an `Arc` by `n`.
2677fn ingest_coord_rows_cloned<'a, L>(
2678    lines: &mut L,
2679    n: usize,
2680    spec_version: u32,
2681    mut global_atom_idx: u64,
2682    symbol: &Arc<str>,
2683    pos_flat: &mut Vec<f64>,
2684    ids: &mut Vec<u64>,
2685    atom_data: &mut Vec<AtomDatum>,
2686) -> Result<u64, ParseError>
2687where
2688    L: LineStream<'a>,
2689{
2690    for _ in 0..n {
2691        let (xyz, fixed, atom_id) = lines.next_coord_row(spec_version, global_atom_idx)?;
2692        pos_flat.extend_from_slice(&xyz);
2693        ids.push(atom_id);
2694        atom_data.push(AtomDatum::coords(
2695            Arc::clone(symbol),
2696            xyz[0],
2697            xyz[1],
2698            xyz[2],
2699            fixed,
2700            atom_id,
2701        ));
2702        global_atom_idx += 1;
2703    }
2704    Ok(global_atom_idx)
2705}
2706
2707/// `n` extra strong references to `symbol`, taken one at a time.
2708///
2709/// `Arc<str>` stores the header (strong count, weak count) immediately
2710/// before the bytes. One `fetch_add` covers the block; each [`SymbolShares::take`]
2711/// turns one of those counts into an `Arc`. Leftovers drop with the guard.
2712struct SymbolShares {
2713    data: *const u8,
2714    len: usize,
2715    left: usize,
2716}
2717
2718impl SymbolShares {
2719    fn raw(&self) -> *const str {
2720        std::ptr::slice_from_raw_parts(self.data, self.len) as *const str
2721    }
2722
2723    fn take(&mut self) -> Arc<str> {
2724        debug_assert!(self.left > 0);
2725        self.left -= 1;
2726        unsafe { Arc::from_raw(self.raw()) }
2727    }
2728}
2729
2730impl Drop for SymbolShares {
2731    fn drop(&mut self) {
2732        for _ in 0..self.left {
2733            drop(unsafe { Arc::from_raw(self.raw()) });
2734        }
2735        self.left = 0;
2736    }
2737}
2738
2739fn symbol_shares(symbol: &Arc<str>, n: usize) -> SymbolShares {
2740    if n == 0 {
2741        return SymbolShares {
2742            data: std::ptr::null(),
2743            len: 0,
2744            left: 0,
2745        };
2746    }
2747    let data = Arc::as_ptr(symbol);
2748    let offset = arc_str_header(symbol.as_ref());
2749    // SAFETY: `Arc::as_ptr` points at the `str` inside the allocation, and
2750    // the two atomic counts sit in front of it. This is the same subtraction
2751    // `Arc::increment_strong_count` performs, done once for `n` clones.
2752    let bytes = data as *const u8;
2753    let strong = unsafe { bytes.sub(offset) as *const AtomicUsize };
2754    unsafe { (*strong).fetch_add(n, Ordering::Relaxed) };
2755    SymbolShares {
2756        data: bytes,
2757        len: symbol.len(),
2758        left: n,
2759    }
2760}
2761
2762fn arc_str_header(text: &str) -> usize {
2763    let header = 2 * std::mem::size_of::<usize>();
2764    let align = std::mem::align_of_val(text);
2765    let rem = header % align;
2766    if rem == 0 {
2767        header
2768    } else {
2769        header + (align - rem)
2770    }
2771}
2772
2773#[cfg(test)]
2774mod symbol_share_tests {
2775    use super::symbol_shares;
2776    use std::sync::Arc;
2777
2778    #[test]
2779    fn one_bump_matches_n_clones() {
2780        for text in ["", "Cu", "Gold", "a-much-longer-component-symbol"] {
2781            let symbol: Arc<str> = Arc::from(text);
2782            let mut shares = symbol_shares(&symbol, 3);
2783            assert_eq!(Arc::strong_count(&symbol), 4);
2784            let owned = [shares.take(), shares.take(), shares.take()];
2785            assert!(owned.iter().all(|arc| arc.as_ref() == text));
2786            drop(owned);
2787            assert_eq!(Arc::strong_count(&symbol), 1);
2788            let mut partial = symbol_shares(&symbol, 2);
2789            let one = partial.take();
2790            drop(partial);
2791            drop(one);
2792            assert_eq!(Arc::strong_count(&symbol), 1);
2793        }
2794    }
2795}
2796
2797fn validate_header_geometry(boxl: &[f64], angles: &[f64]) -> Result<(), ParseError> {
2798    if boxl
2799        .iter()
2800        .any(|length| !length.is_finite() || *length <= 0.0)
2801        || angles
2802            .iter()
2803            .any(|angle| !angle.is_finite() || *angle <= 0.0 || *angle >= 180.0)
2804    {
2805        return Err(ParseError::ValidationError(
2806            "cell geometry must have positive lengths and angles between 0 and 180 degrees"
2807                .to_string(),
2808        ));
2809    }
2810    Ok(())
2811}
2812
2813fn validate_masses(masses_per_type: &[f64]) -> Result<(), ParseError> {
2814    if masses_per_type
2815        .iter()
2816        .any(|mass| !mass.is_finite() || *mass <= 0.0)
2817    {
2818        return Err(ParseError::ValidationError(
2819            "component masses must be positive".to_string(),
2820        ));
2821    }
2822    Ok(())
2823}
2824
2825fn validate_coordinate_component(
2826    type_idx: usize,
2827    symbol: &str,
2828    label: &str,
2829) -> Result<(), ParseError> {
2830    let expected_label = format!("Coordinates of Component {}", type_idx + 1);
2831    if !labels_equal(label, &expected_label) {
2832        return Err(ParseError::ValidationError(format!(
2833            "expected coordinate label {expected_label:?}, found {label:?}"
2834        )));
2835    }
2836    if symbol != "X" && symbol_to_atomic_number(symbol) == 0 {
2837        return Err(ParseError::ValidationError(format!(
2838            "unknown component symbol {symbol}"
2839        )));
2840    }
2841    Ok(())
2842}
2843
2844fn validate_section_component(
2845    section: &str,
2846    type_idx: usize,
2847    atom_idx: usize,
2848    symbol: &str,
2849    label: &str,
2850    header: &FrameHeader,
2851    atom_data: &[AtomDatum],
2852) -> Result<(), ParseError> {
2853    let expected_label = format!("{section} of Component {}", type_idx + 1);
2854    if !labels_equal(label, &expected_label) {
2855        return Err(ParseError::ValidationError(format!(
2856            "expected section label {expected_label:?}, found {label:?}"
2857        )));
2858    }
2859
2860    if header.natms_per_type[type_idx] == 0 {
2861        return Ok(());
2862    }
2863
2864    let expected_symbol = atom_data
2865        .get(atom_idx)
2866        .map(|atom| atom.symbol.as_ref())
2867        .ok_or_else(|| {
2868            ParseError::ValidationError(format!(
2869                "{section} component {} has no coordinate atom to validate against",
2870                type_idx + 1
2871            ))
2872        })?;
2873    if symbol != expected_symbol {
2874        return Err(ParseError::ValidationError(format!(
2875            "{section} component {} symbol mismatch: expected {expected_symbol}, found {symbol}",
2876            type_idx + 1
2877        )));
2878    }
2879
2880    Ok(())
2881}
2882
2883fn validate_section_atom_identity(
2884    section: &str,
2885    atom_idx: usize,
2886    fixed: [bool; 3],
2887    atom_id: u64,
2888    atom_data: &[AtomDatum],
2889) -> Result<(), ParseError> {
2890    let atom = atom_data.get(atom_idx).ok_or_else(|| {
2891        ParseError::ValidationError(format!(
2892            "{section} row {atom_idx} has no coordinate atom to validate against"
2893        ))
2894    })?;
2895
2896    if atom.fixed() != fixed {
2897        return Err(ParseError::ValidationError(format!(
2898            "{section} row {atom_idx} fixed mask mismatch for atom_id {}",
2899            atom.atom_id
2900        )));
2901    }
2902    if atom.atom_id != atom_id {
2903        return Err(ParseError::ValidationError(format!(
2904            "{section} row {atom_idx} atom_id mismatch: expected {}, found {atom_id}",
2905            atom.atom_id
2906        )));
2907    }
2908
2909    Ok(())
2910}
2911
2912/// Consume the single blank line that opens a section.
2913///
2914/// A blank line followed only by whitespace to EOF does not open a section
2915/// (CON-frame.5). The lines stay in the stream.
2916fn frame_minor(header: &FrameHeader) -> u64 {
2917    header.metadata.spec_minor()
2918}
2919
2920fn section_row_arity(header: &FrameHeader, name: &str) -> Option<usize> {
2921    let obj = header.metadata.get(meta::SECTION_ARITY)?.as_object()?;
2922    let n = obj.get(name)?.as_u64()?;
2923    if n >= 1 { Some(n as usize) } else { None }
2924}
2925
2926/// Skip one unknown section of a newer minor.
2927///
2928/// `arity` is the value-field count (CON-section.12). Each data row holds
2929/// that many values, then the flag, and an optional id, so the row has
2930/// `arity + 1` or `arity + 2` tokens (CON-section.14). The label is the
2931/// section name under CON-file.5, then `of Component N` (CON-section.15).
2932fn skip_unknown_section<'a>(
2933    lines: &mut impl LineStream<'a>,
2934    header: &FrameHeader,
2935    name: &str,
2936    arity: usize,
2937) -> Result<(), ParseError> {
2938    if !open_section(lines)? {
2939        return Err(ParseError::IncompleteSection(name.to_string()));
2940    }
2941    let with_flag = arity.saturating_add(1);
2942    let with_id = arity.saturating_add(2);
2943    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
2944        if lines.next_line().is_none() {
2945            return Err(ParseError::IncompleteSection(name.to_string()));
2946        }
2947        let label = lines
2948            .next_line()
2949            .ok_or_else(|| ParseError::IncompleteSection(name.to_string()))?;
2950        let expected = format!("{name} of Component {}", type_idx + 1);
2951        if !labels_equal(label, &expected) {
2952            return Err(ParseError::IncompleteSection(name.to_string()));
2953        }
2954        for _ in 0..num_atoms {
2955            let row = lines
2956                .next_line()
2957                .ok_or_else(|| ParseError::IncompleteSection(name.to_string()))?;
2958            let found = token_count(row);
2959            if found != with_flag && found != with_id {
2960                let expected = if found < with_flag {
2961                    with_flag
2962                } else {
2963                    with_id
2964                };
2965                return Err(ParseError::InvalidVectorLength { expected, found });
2966            }
2967        }
2968    }
2969    Ok(())
2970}
2971
2972fn open_section<'a>(lines: &mut impl LineStream<'a>) -> Result<bool, ParseError> {
2973    match lines.peek_line() {
2974        Some(line) if line.trim().is_empty() => {
2975            if lines.remainder_is_whitespace() {
2976                return Ok(false);
2977            }
2978            lines.next_line();
2979            Ok(true)
2980        }
2981        _ => Ok(false),
2982    }
2983}
2984
2985/// Attempts to parse an optional velocity section following coordinate blocks.
2986///
2987/// In `.convel` files, after all coordinate blocks there is a blank separator line
2988/// followed by per-component velocity blocks with the same structure as coordinate
2989/// blocks (symbol line, "Velocities of Component N" line, then atom lines with
2990/// `vx vy vz fixed atomID`).
2991///
2992/// This function peeks at the next line. If it is blank (or contains only whitespace),
2993/// it consumes the blank line and writes each velocity into `column` (row-major
2994/// `n * 3` `f64`s, one allocation). If the next line is not blank (or is absent),
2995/// no velocities are parsed and `column` is left untouched.
2996///
2997/// Returns `Ok(true)` if velocities were found and parsed, `Ok(false)` otherwise.
2998pub fn parse_velocity_section<'a>(
2999    lines: &mut impl LineStream<'a>,
3000    header: &FrameHeader,
3001    atom_data: &mut [AtomDatum],
3002    column: &mut Vec<f64>,
3003) -> Result<bool, ParseError> {
3004    let validate = header.strict_validation;
3005    // A blank line followed only by whitespace to EOF is not a velocity
3006    // block (CON-frame.5, D2). Leave those lines for the iterator.
3007    match lines.peek_line() {
3008        Some(line) if line.trim().is_empty() => {
3009            if lines.remainder_is_whitespace() {
3010                return Ok(false);
3011            }
3012            // CON-section.2 / the legacy fallback: the blank counts only
3013            // when it begins a complete velocity block. Otherwise it is
3014            // line 1 of the next frame (CON-frame.7, CON-header.4).
3015            if !lines.blank_line_opens_section() {
3016                return Ok(false);
3017            }
3018            lines.next_line();
3019        }
3020        _ => return Ok(false),
3021    }
3022
3023    let n = atom_data.len();
3024    column.clear();
3025    column.resize(n.saturating_mul(3), 0.0);
3026    let mut atom_idx: usize = 0;
3027    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
3028        // Symbol line
3029        let symbol = lines
3030            .next_line()
3031            .ok_or(ParseError::IncompleteSection(SECTION_VELOCITIES.into()))?
3032            .trim();
3033
3034        // "Velocities of Component N" line
3035        let comp_line = lines
3036            .next_line()
3037            .ok_or(ParseError::IncompleteSection(SECTION_VELOCITIES.into()))?;
3038        if !label_has_prefix(comp_line, "Velocities of Component") {
3039            return Err(ParseError::IncompleteSection(SECTION_VELOCITIES.into()));
3040        }
3041        if validate {
3042            validate_section_component(
3043                "Velocities",
3044                type_idx,
3045                atom_idx,
3046                symbol,
3047                comp_line,
3048                header,
3049                atom_data,
3050            )?;
3051        }
3052
3053        for _ in 0..num_atoms {
3054            let (vals, fixed, atom_id) =
3055                take_vector_row(lines, header.spec_version, SECTION_VELOCITIES)?;
3056            if validate {
3057                validate_section_atom_identity("velocities", atom_idx, fixed, atom_id, atom_data)?;
3058            }
3059            if atom_idx < n {
3060                let base = atom_idx * 3;
3061                column[base] = vals[0];
3062                column[base + 1] = vals[1];
3063                column[base + 2] = vals[2];
3064            }
3065            atom_idx += 1;
3066        }
3067    }
3068
3069    Ok(true)
3070}
3071
3072/// Attempts to parse a force section following coordinate (and optional velocity) blocks.
3073///
3074/// Force sections mirror velocity sections: a blank separator line followed by per-component
3075/// force blocks (symbol line, "Forces of Component N" line, then atom lines with
3076/// `fx fy fz fixed_flag atom_id`).
3077///
3078/// Returns `Ok(true)` if forces were found and parsed, `Ok(false)` otherwise.
3079pub fn parse_force_section<'a>(
3080    lines: &mut impl LineStream<'a>,
3081    header: &FrameHeader,
3082    atom_data: &mut [AtomDatum],
3083    column: &mut Vec<f64>,
3084) -> Result<bool, ParseError> {
3085    let validate = header.strict_validation;
3086    if !open_section(lines)? {
3087        return Ok(false);
3088    }
3089
3090    let n = atom_data.len();
3091    column.clear();
3092    column.resize(n.saturating_mul(3), 0.0);
3093    let mut atom_idx: usize = 0;
3094    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
3095        let symbol = lines
3096            .next_line()
3097            .ok_or(ParseError::IncompleteSection(SECTION_FORCES.into()))?
3098            .trim();
3099
3100        let comp_line = lines
3101            .next_line()
3102            .ok_or(ParseError::IncompleteSection(SECTION_FORCES.into()))?;
3103        if !label_has_prefix(comp_line, "Forces of Component") {
3104            return Err(ParseError::IncompleteSection(SECTION_FORCES.into()));
3105        }
3106        if validate {
3107            validate_section_component(
3108                "Forces", type_idx, atom_idx, symbol, comp_line, header, atom_data,
3109            )?;
3110        }
3111
3112        for _ in 0..num_atoms {
3113            let (vals, fixed, atom_id) =
3114                take_vector_row(lines, header.spec_version, SECTION_FORCES)?;
3115            if validate {
3116                validate_section_atom_identity("forces", atom_idx, fixed, atom_id, atom_data)?;
3117            }
3118            if atom_idx < n {
3119                let base = atom_idx * 3;
3120                column[base] = vals[0];
3121                column[base + 1] = vals[1];
3122                column[base + 2] = vals[2];
3123            }
3124            atom_idx += 1;
3125        }
3126    }
3127
3128    Ok(true)
3129}
3130
3131/// Attempts to parse an energies section following coordinate (and optional
3132/// velocity / force) blocks.
3133///
3134/// Energy sections mirror force sections but with one scalar per atom:
3135/// blank separator, then per-component blocks of (symbol, "Energies of
3136/// Component N", and atom lines `e fixed_flag atom_id`). The two
3137/// trailing identity columns are optional and used only for strict
3138/// validation; in non-strict mode any whitespace after the energy is
3139/// ignored.
3140///
3141/// Returns `Ok(true)` if energies were found and parsed, `Ok(false)`
3142/// otherwise.
3143pub fn parse_energy_section<'a>(
3144    lines: &mut impl LineStream<'a>,
3145    header: &FrameHeader,
3146    atom_data: &mut [AtomDatum],
3147) -> Result<bool, ParseError> {
3148    let validate = header.strict_validation;
3149    if !open_section(lines)? {
3150        return Ok(false);
3151    }
3152
3153    let mut atom_idx: usize = 0;
3154    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
3155        let symbol = lines
3156            .next_line()
3157            .ok_or(ParseError::IncompleteSection(SECTION_ENERGIES.into()))?
3158            .trim();
3159
3160        let comp_line = lines
3161            .next_line()
3162            .ok_or(ParseError::IncompleteSection(SECTION_ENERGIES.into()))?;
3163        if !label_has_prefix(comp_line, "Energies of Component") {
3164            return Err(ParseError::IncompleteSection(SECTION_ENERGIES.into()));
3165        }
3166        if validate {
3167            validate_section_component(
3168                "Energies", type_idx, atom_idx, symbol, comp_line, header, atom_data,
3169            )?;
3170        }
3171
3172        for _ in 0..num_atoms {
3173            let energy_line = lines
3174                .next_line()
3175                .ok_or(ParseError::IncompleteSection(SECTION_ENERGIES.into()))?;
3176            let (value, fixed, atom_id) =
3177                parse_scalar_section_row(energy_line, header.spec_version)?;
3178            if validate {
3179                validate_section_atom_identity("energies", atom_idx, fixed, atom_id, atom_data)?;
3180            }
3181            if atom_idx < atom_data.len() {
3182                atom_data[atom_idx].set_energy(Some(value));
3183            }
3184            atom_idx += 1;
3185        }
3186    }
3187
3188    Ok(true)
3189}
3190
3191/// Parses declared sections from a frame's header metadata.
3192///
3193/// If `header.sections` is non-empty (v2 file with `"sections"` key in JSON),
3194/// parses each declared section in order. Otherwise falls back to legacy
3195/// blank-separator velocity detection.
3196pub fn parse_declared_sections<'a>(
3197    lines: &mut impl LineStream<'a>,
3198    frame: &mut ConFrame,
3199) -> Result<usize, ParseError> {
3200    let mut applied = 0usize;
3201    // A zero-atom frame has no coordinate block and no section block.
3202    // The legacy velocity fallback must not run (CON-count.1).
3203    if frame.header.natm_types == 0 {
3204        if !frame.header.sections.is_empty() {
3205            return Err(ParseError::InvalidCount(
3206                "a zero-atom frame cannot declare sections".into(),
3207            ));
3208        }
3209        return Ok(0);
3210    }
3211    if !frame.header.sections_declared && frame.header.sections.is_empty() {
3212        // Legacy: try velocity detection via blank separator. A blank
3213        // line can also be the empty line 1 of the next frame, so look for
3214        // the velocity label before committing (CON-frame.5).
3215        let mut column = Vec::new();
3216        let found = lines.blank_opens_velocity_block()
3217            && parse_velocity_section(lines, &frame.header, &mut frame.atom_data, &mut column)?;
3218        if found {
3219            frame.adopt_velocity_column(column);
3220            frame.header.sections.push(SECTION_VELOCITIES.into());
3221            applied = 1;
3222        }
3223    } else {
3224        let sections = std::mem::take(&mut frame.header.sections);
3225        for section in &sections {
3226            match section.as_str() {
3227                SECTION_VELOCITIES => {
3228                    let mut column = Vec::new();
3229                    let found = parse_velocity_section(
3230                        lines,
3231                        &frame.header,
3232                        &mut frame.atom_data,
3233                        &mut column,
3234                    )?;
3235                    if !found {
3236                        return Err(ParseError::IncompleteSection(SECTION_VELOCITIES.into()));
3237                    }
3238                    frame.adopt_velocity_column(column);
3239                    applied += 1;
3240                }
3241                SECTION_FORCES => {
3242                    let mut column = Vec::new();
3243                    let found = parse_force_section(
3244                        lines,
3245                        &frame.header,
3246                        &mut frame.atom_data,
3247                        &mut column,
3248                    )?;
3249                    if !found {
3250                        return Err(ParseError::IncompleteSection(SECTION_FORCES.into()));
3251                    }
3252                    frame.adopt_force_column(column);
3253                    applied += 1;
3254                }
3255                SECTION_ENERGIES => {
3256                    let found = parse_energy_section(lines, &frame.header, &mut frame.atom_data)?;
3257                    if !found {
3258                        return Err(ParseError::IncompleteSection(SECTION_ENERGIES.into()));
3259                    }
3260                    applied += 1;
3261                }
3262                SECTION_CHARGES => {
3263                    let found = parse_charge_section(lines, &frame.header, &mut frame.atom_data)?;
3264                    if !found {
3265                        return Err(ParseError::IncompleteSection(SECTION_CHARGES.into()));
3266                    }
3267                    applied += 1;
3268                }
3269                SECTION_SPINS => {
3270                    let found = parse_spin_section(lines, &frame.header, &mut frame.atom_data)?;
3271                    if !found {
3272                        return Err(ParseError::IncompleteSection(SECTION_SPINS.into()));
3273                    }
3274                    applied += 1;
3275                }
3276                SECTION_MAGMOMS => {
3277                    let found = parse_magmom_section(lines, &frame.header, &mut frame.atom_data)?;
3278                    if !found {
3279                        return Err(ParseError::IncompleteSection(SECTION_MAGMOMS.into()));
3280                    }
3281                    applied += 1;
3282                }
3283                SECTION_DISPLACEMENTS => {
3284                    let found =
3285                        parse_displacement_section(lines, &frame.header, &mut frame.atom_data)?;
3286                    if !found {
3287                        return Err(ParseError::IncompleteSection(SECTION_DISPLACEMENTS.into()));
3288                    }
3289                    applied += 1;
3290                }
3291                SECTION_SPREADS => {
3292                    let found = parse_spread_section(lines, &frame.header, &mut frame.atom_data)?;
3293                    if !found {
3294                        return Err(ParseError::IncompleteSection(SECTION_SPREADS.into()));
3295                    }
3296                    applied += 1;
3297                }
3298                other => {
3299                    if frame_minor(&frame.header) > u64::from(crate::CON_SPEC_MINOR)
3300                        && let Some(arity) = section_row_arity(&frame.header, other)
3301                    {
3302                        skip_unknown_section(lines, &frame.header, other, arity)?;
3303                        continue;
3304                    }
3305                    return Err(ParseError::UnknownSection(other.to_string()));
3306                }
3307            }
3308        }
3309        frame.header.sections = sections;
3310    }
3311    reject_velocities_without_time(&frame.header)?;
3312    Ok(applied)
3313}
3314
3315/// CON-units.11. Versions 1, 2, and 3.0 do not require `units.time`
3316/// (CON-units.8, CON-units.9). From minor 1 the key is required when a
3317/// velocities section is present.
3318fn reject_velocities_without_time(header: &FrameHeader) -> Result<(), ParseError> {
3319    if header.spec_version < 3 || frame_minor(header) < 1 {
3320        return Ok(());
3321    }
3322    if !header
3323        .sections
3324        .iter()
3325        .any(|name| name == SECTION_VELOCITIES)
3326    {
3327        return Ok(());
3328    }
3329    let time_ok = header
3330        .metadata
3331        .get(meta::UNITS)
3332        .and_then(|units| units.get("time"))
3333        .and_then(|value| value.as_str())
3334        .is_some_and(|unit| !unit.trim().is_empty());
3335    if time_ok {
3336        Ok(())
3337    } else {
3338        Err(ParseError::InvalidUnits(
3339            "minor >= 1 requires units.time when a velocities section is present".into(),
3340        ))
3341    }
3342}
3343
3344/// Scalar per-atom section: blank separator + per-type blocks
3345/// (`symbol`, `"X of Component N"`, lines `value fixed atom_id`).
3346fn parse_scalar_atom_section<'a>(
3347    lines: &mut impl LineStream<'a>,
3348    header: &FrameHeader,
3349    atom_data: &mut [AtomDatum],
3350    section_name: &str,
3351    component_label: &str,
3352    mut set_value: impl FnMut(&mut AtomDatum, f64),
3353) -> Result<bool, ParseError> {
3354    let validate = header.strict_validation;
3355    if !open_section(lines)? {
3356        return Ok(false);
3357    }
3358
3359    let mut atom_idx: usize = 0;
3360    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
3361        let symbol = lines
3362            .next_line()
3363            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?
3364            .trim();
3365
3366        let comp_line = lines
3367            .next_line()
3368            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
3369        if !label_has_prefix(comp_line, component_label) {
3370            return Err(ParseError::IncompleteSection(section_name.into()));
3371        }
3372        if validate {
3373            validate_section_component(
3374                component_label.trim_end_matches(" of Component").trim(),
3375                type_idx,
3376                atom_idx,
3377                symbol,
3378                comp_line,
3379                header,
3380                atom_data,
3381            )?;
3382        }
3383
3384        for _ in 0..num_atoms {
3385            let data_line = lines
3386                .next_line()
3387                .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
3388            let (value, fixed, atom_id) = parse_scalar_section_row(data_line, header.spec_version)?;
3389            if validate {
3390                validate_section_atom_identity(section_name, atom_idx, fixed, atom_id, atom_data)?;
3391            }
3392            if atom_idx < atom_data.len() {
3393                set_value(&mut atom_data[atom_idx], value);
3394            }
3395            atom_idx += 1;
3396        }
3397    }
3398    Ok(true)
3399}
3400
3401pub fn parse_charge_section<'a>(
3402    lines: &mut impl LineStream<'a>,
3403    header: &FrameHeader,
3404    atom_data: &mut [AtomDatum],
3405) -> Result<bool, ParseError> {
3406    parse_scalar_atom_section(
3407        lines,
3408        header,
3409        atom_data,
3410        SECTION_CHARGES,
3411        "Charges of Component",
3412        |a, v| a.set_charge(Some(v)),
3413    )
3414}
3415
3416pub fn parse_spin_section<'a>(
3417    lines: &mut impl LineStream<'a>,
3418    header: &FrameHeader,
3419    atom_data: &mut [AtomDatum],
3420) -> Result<bool, ParseError> {
3421    parse_scalar_atom_section(
3422        lines,
3423        header,
3424        atom_data,
3425        SECTION_SPINS,
3426        "Spins of Component",
3427        |a, v| a.set_spin(Some(v)),
3428    )
3429}
3430
3431fn parse_vector_named_section<'a>(
3432    lines: &mut impl LineStream<'a>,
3433    header: &FrameHeader,
3434    atom_data: &mut [AtomDatum],
3435    names: (&str, &str, &str),
3436    mut set_value: impl FnMut(&mut AtomDatum, [f64; 3]),
3437    mut check_row: impl FnMut(usize, [f64; 3]) -> Result<(), ParseError>,
3438) -> Result<bool, ParseError> {
3439    let (section_name, component_label, label_kind) = names;
3440    let validate = header.strict_validation;
3441    if !open_section(lines)? {
3442        return Ok(false);
3443    }
3444    let mut atom_idx: usize = 0;
3445    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
3446        let symbol = lines
3447            .next_line()
3448            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?
3449            .trim();
3450        let comp_line = lines
3451            .next_line()
3452            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
3453        if !label_has_prefix(comp_line, component_label) {
3454            return Err(ParseError::IncompleteSection(section_name.into()));
3455        }
3456        if validate {
3457            validate_section_component(
3458                label_kind, type_idx, atom_idx, symbol, comp_line, header, atom_data,
3459            )?;
3460        }
3461        for _ in 0..num_atoms {
3462            let (vals, fixed, atom_id) = take_vector_row(lines, header.spec_version, section_name)?;
3463            check_row(atom_idx, vals)?;
3464            if validate {
3465                validate_section_atom_identity(section_name, atom_idx, fixed, atom_id, atom_data)?;
3466            }
3467            if atom_idx < atom_data.len() {
3468                set_value(&mut atom_data[atom_idx], vals);
3469            }
3470            atom_idx += 1;
3471        }
3472    }
3473    Ok(true)
3474}
3475
3476/// Magmoms: 3-vector per atom, same layout as velocities/forces.
3477pub fn parse_magmom_section<'a>(
3478    lines: &mut impl LineStream<'a>,
3479    header: &FrameHeader,
3480    atom_data: &mut [AtomDatum],
3481) -> Result<bool, ParseError> {
3482    parse_vector_named_section(
3483        lines,
3484        header,
3485        atom_data,
3486        (SECTION_MAGMOMS, "Magmoms of Component", "Magmoms"),
3487        |a, v| a.set_magmom(Some(v)),
3488        |_, _| Ok(()),
3489    )
3490}
3491
3492/// Displacements: 3-vector per atom (Angstrom), same layout as velocities/forces.
3493pub fn parse_displacement_section<'a>(
3494    lines: &mut impl LineStream<'a>,
3495    header: &FrameHeader,
3496    atom_data: &mut [AtomDatum],
3497) -> Result<bool, ParseError> {
3498    parse_vector_named_section(
3499        lines,
3500        header,
3501        atom_data,
3502        (
3503            SECTION_DISPLACEMENTS,
3504            "Displacements of Component",
3505            "Displacements",
3506        ),
3507        |a, v| a.set_displacement(Some(v)),
3508        |_, _| Ok(()),
3509    )
3510}
3511
3512/// Spreads: per-atom root-mean-square spread `[sx, sy, sz]` (Angstrom, each
3513/// non-negative), same layout as velocities/forces.
3514pub fn parse_spread_section<'a>(
3515    lines: &mut impl LineStream<'a>,
3516    header: &FrameHeader,
3517    atom_data: &mut [AtomDatum],
3518) -> Result<bool, ParseError> {
3519    parse_vector_named_section(
3520        lines,
3521        header,
3522        atom_data,
3523        (SECTION_SPREADS, "Spreads of Component", "Spreads"),
3524        |a, v| a.set_spread(Some(v)),
3525        |atom_idx, vals| {
3526            if !vals.iter().all(|v| v.is_finite()) {
3527                return Err(ParseError::NonFiniteValue(format!(
3528                    "spreads atom {atom_idx}"
3529                )));
3530            }
3531            if vals.iter().any(|v| *v < 0.0) {
3532                return Err(crate::types::invalid_spread(atom_idx));
3533            }
3534            Ok(())
3535        },
3536    )
3537}
3538
3539#[cfg(test)]
3540mod tests {
3541    use super::*;
3542    use crate::iterators::ConFrameIterator;
3543
3544    #[test]
3545    fn test_parse_line_of_n_success() {
3546        let line = "1.0 2.5 -3.0";
3547        let values = parse_line_of_n::<f64>(line, 3).unwrap();
3548        assert_eq!(values, vec![1.0, 2.5, -3.0]);
3549    }
3550
3551    #[test]
3552    fn test_parse_line_of_n_too_short() {
3553        let line = "1.0 2.5";
3554        let result = parse_line_of_n::<f64>(line, 3);
3555        assert!(result.is_err());
3556        assert!(matches!(
3557            result.unwrap_err(),
3558            ParseError::InvalidVectorLength {
3559                expected: 3,
3560                found: 2
3561            }
3562        ));
3563    }
3564
3565    #[test]
3566    fn test_parse_line_of_n_too_long() {
3567        let line = "1.0 2.5 -3.0 4.0";
3568        let result = parse_line_of_n::<f64>(line, 3);
3569        assert!(result.is_err());
3570        assert!(matches!(
3571            result.unwrap_err(),
3572            ParseError::InvalidVectorLength {
3573                expected: 3,
3574                found: 4
3575            }
3576        ));
3577    }
3578
3579    #[test]
3580    fn test_parse_line_of_n_invalid_float() {
3581        let line = "1.0 abc -3.0";
3582        let result = parse_line_of_n::<f64>(line, 3);
3583        assert!(result.is_err());
3584        assert!(matches!(
3585            result.unwrap_err(),
3586            ParseError::InvalidNumberFormat(_)
3587        ));
3588    }
3589
3590    #[test]
3591    fn test_parse_frame_header_success() {
3592        let lines = [
3593            "PREBOX1",
3594            "{\"con_spec_version\":2}",
3595            "10.0 20.0 30.0",
3596            "90.0 90.0 90.0",
3597            "POSTBOX1",
3598            "POSTBOX2",
3599            "2",
3600            "1 1",
3601            "12.011 1.008",
3602        ];
3603        let mut line_it = lines.iter().copied();
3604        match parse_frame_header(&mut line_it) {
3605            Ok(header) => {
3606                assert_eq!(header.prebox_header.user, "PREBOX1");
3607                assert_eq!(header.spec_version, 2);
3608                assert_eq!(header.boxl, [10.0, 20.0, 30.0]);
3609                assert_eq!(header.angles, [90.0, 90.0, 90.0]);
3610                assert_eq!(header.postbox_header, ["POSTBOX1", "POSTBOX2"]);
3611                assert_eq!(header.natm_types, 2);
3612                assert_eq!(header.natms_per_type, vec![1, 1]);
3613                assert_eq!(header.masses_per_type, vec![12.011, 1.008]);
3614            }
3615            Err(e) => {
3616                panic!("Parsing failed when it should have succeeded. Error: {e:?}");
3617            }
3618        }
3619    }
3620
3621    #[test]
3622    fn test_parse_frame_header_missing_line() {
3623        let lines = [
3624            "PREBOX1",
3625            "{\"con_spec_version\":2}",
3626            "10.0 20.0 30.0",
3627            "90.0 90.0 90.0",
3628            "POSTBOX1",
3629            "POSTBOX2",
3630            "2",
3631            "1 1",
3632            // Missing masses_per_type
3633        ];
3634        let mut line_it = lines.iter().copied();
3635        let result = parse_frame_header(&mut line_it);
3636        assert!(result.is_err());
3637        assert!(matches!(result.unwrap_err(), ParseError::IncompleteHeader));
3638    }
3639
3640    #[test]
3641    fn test_parse_frame_header_missing_spec_version() {
3642        let lines = [
3643            "PREBOX1",
3644            "{}",
3645            "10.0 20.0 30.0",
3646            "90.0 90.0 90.0",
3647            "POSTBOX1",
3648            "POSTBOX2",
3649            "2",
3650            "1 1",
3651            "12.011 1.008",
3652        ];
3653        let mut line_it = lines.iter().copied();
3654        let result = parse_frame_header(&mut line_it);
3655        assert!(result.is_err());
3656        assert!(matches!(
3657            result.unwrap_err(),
3658            ParseError::MissingSpecVersion
3659        ));
3660    }
3661
3662    #[test]
3663    fn test_parse_frame_header_legacy_no_json() {
3664        // A non-JSON line 2 is treated as a legacy (v1) file.
3665        let lines = [
3666            "PREBOX1",
3667            "0.0000 TIME",
3668            "10.0 20.0 30.0",
3669            "90.0 90.0 90.0",
3670            "POSTBOX1",
3671            "POSTBOX2",
3672            "2",
3673            "1 1",
3674            "12.011 1.008",
3675        ];
3676        let mut line_it = lines.iter().copied();
3677        let header = parse_frame_header(&mut line_it).unwrap();
3678        assert_eq!(header.spec_version, 1);
3679        assert!(header.metadata.is_empty());
3680    }
3681
3682    #[test]
3683    fn test_parse_frame_header_malformed_json() {
3684        // Line 2 starts with '{' but is not valid JSON -- this IS an error.
3685        let lines = [
3686            "PREBOX1",
3687            "{broken json",
3688            "10.0 20.0 30.0",
3689            "90.0 90.0 90.0",
3690            "POSTBOX1",
3691            "POSTBOX2",
3692            "2",
3693            "1 1",
3694            "12.011 1.008",
3695        ];
3696        let mut line_it = lines.iter().copied();
3697        let result = parse_frame_header(&mut line_it);
3698        assert!(result.is_err());
3699        assert!(matches!(
3700            result.unwrap_err(),
3701            ParseError::InvalidMetadataJson(_)
3702        ));
3703    }
3704
3705    #[test]
3706    fn test_parse_frame_header_unsupported_version() {
3707        let lines = [
3708            "PREBOX1",
3709            "{\"con_spec_version\":999}",
3710            "10.0 20.0 30.0",
3711            "90.0 90.0 90.0",
3712            "POSTBOX1",
3713            "POSTBOX2",
3714            "2",
3715            "1 1",
3716            "12.011 1.008",
3717        ];
3718        let mut line_it = lines.iter().copied();
3719        let result = parse_frame_header(&mut line_it);
3720        assert!(result.is_err());
3721        assert!(matches!(
3722            result.unwrap_err(),
3723            ParseError::UnsupportedVersion(999)
3724        ));
3725    }
3726
3727    #[test]
3728    fn test_v3_missing_units_rejected() {
3729        let lines = [
3730            "PREBOX1",
3731            "{\"con_spec_version\":3}",
3732            "10.0 20.0 30.0",
3733            "90.0 90.0 90.0",
3734            "POSTBOX1",
3735            "POSTBOX2",
3736            "1",
3737            "1",
3738            "1.0",
3739        ];
3740        let mut line_it = lines.iter().copied();
3741        let err = parse_frame_header(&mut line_it).unwrap_err();
3742        let msg = err.to_string();
3743        assert!(
3744            msg.contains("units") || msg.contains("v3"),
3745            "expected units error, got {msg}"
3746        );
3747    }
3748
3749    #[test]
3750    fn test_v3_invalid_units_rejected() {
3751        let lines = [
3752            "PREBOX1",
3753            r#"{"con_spec_version":3,"units":{"length":"eV","energy":"angstrom"}}"#,
3754            "10.0 20.0 30.0",
3755            "90.0 90.0 90.0",
3756            "POSTBOX1",
3757            "POSTBOX2",
3758            "1",
3759            "1",
3760            "1.0",
3761        ];
3762        let mut line_it = lines.iter().copied();
3763        assert!(parse_frame_header(&mut line_it).is_err());
3764    }
3765
3766    #[test]
3767    fn test_v3_valid_units_exposes_length_energy() {
3768        let lines = [
3769            "PREBOX1",
3770            r#"{"con_spec_version":3,"units":{"length":"angstrom","energy":"eV","mass":"amu","time":"fs"}}"#,
3771            "10.0 20.0 30.0",
3772            "90.0 90.0 90.0",
3773            "POSTBOX1",
3774            "POSTBOX2",
3775            "1",
3776            "1",
3777            "1.0",
3778        ];
3779        let mut line_it = lines.iter().copied();
3780        let header = parse_frame_header(&mut line_it).unwrap();
3781        assert_eq!(header.spec_version, 3);
3782        assert_eq!(header.length_unit(), Some("angstrom"));
3783        assert_eq!(header.energy_unit(), Some("eV"));
3784        let f = header.conversion_factor_to("length", "nm").unwrap();
3785        assert!((f - 0.1).abs() < 1e-12);
3786    }
3787
3788    #[test]
3789    fn test_parse_frame_header_extra_metadata_preserved() {
3790        let lines = [
3791            "PREBOX1",
3792            "{\"con_spec_version\":2,\"generator\":\"test\"}",
3793            "10.0 20.0 30.0",
3794            "90.0 90.0 90.0",
3795            "POSTBOX1",
3796            "POSTBOX2",
3797            "2",
3798            "1 1",
3799            "12.011 1.008",
3800        ];
3801        let mut line_it = lines.iter().copied();
3802        let header = parse_frame_header(&mut line_it).unwrap();
3803        assert_eq!(header.spec_version, 2);
3804        assert_eq!(
3805            header.metadata.get("generator"),
3806            Some(&serde_json::Value::String("test".to_string()))
3807        );
3808    }
3809
3810    #[test]
3811    fn test_parse_frame_header_invalid_natms_per_type() {
3812        let lines = [
3813            "PREBOX1",
3814            "{\"con_spec_version\":2}",
3815            "10.0 20.0 30.0",
3816            "90.0 90.0 90.0",
3817            "POSTBOX1",
3818            "POSTBOX2",
3819            "2",
3820            "1 1 1", // 3 values, but natm_types is 2
3821            "12.011 1.008",
3822        ];
3823        let mut line_it = lines.iter().copied();
3824        let result = parse_frame_header(&mut line_it);
3825        assert!(result.is_err());
3826        assert!(matches!(
3827            result.unwrap_err(),
3828            ParseError::InvalidVectorLength {
3829                expected: 2,
3830                found: 3
3831            }
3832        ));
3833    }
3834
3835    #[test]
3836    fn test_parse_single_frame_success() {
3837        let lines = vec![
3838            "PREBOX1",
3839            "{\"con_spec_version\":2}",
3840            "10.0 20.0 30.0",
3841            "90.0 90.0 90.0",
3842            "POSTBOX1",
3843            "POSTBOX2",
3844            "2",
3845            "3 3",
3846            "12.011 1.008",
3847            "1",
3848            "Coordinates of Component 1",
3849            "0.0 0.0 0.0 0 1",
3850            "1.0940 0.0 0.0 0 2",
3851            "-0.5470 0.9499 0.0 0 3",
3852            "2",
3853            "Coordinates of Component 2",
3854            "5.0 5.0 5.0 0 4",
3855            "6.0940 5.0 5.0 0 5",
3856            "5.5470 5.9499 5.0 0 6",
3857        ];
3858        let mut line_it = lines.iter().copied();
3859        let frame = parse_single_frame(&mut line_it).unwrap();
3860
3861        assert_eq!(frame.header.natm_types, 2);
3862        assert_eq!(frame.header.natms_per_type, vec![3, 3]);
3863        assert_eq!(frame.header.masses_per_type, vec![12.011, 1.008]);
3864        assert_eq!(frame.atom_data.len(), 6);
3865        assert_eq!(&*frame.atom_data[0].symbol, "1");
3866        assert_eq!(frame.atom_data[0].atom_id, 1);
3867        assert_eq!(&*frame.atom_data[5].symbol, "2");
3868        assert_eq!(frame.atom_data[5].atom_id, 6);
3869    }
3870
3871    #[test]
3872    fn test_column_4_value_1_is_x_only_on_spec_2() {
3873        let lines = [
3874            "Generated by probe",
3875            "{\"con_spec_version\":2}",
3876            "20.0 20.0 20.0",
3877            "90.0 90.0 90.0",
3878            "",
3879            "",
3880            "1",
3881            "1",
3882            "63.5",
3883            "Cu",
3884            "Coordinates of component   1",
3885            "1.0 2.0 3.0 1 1",
3886        ];
3887        let mut line_it = lines.iter().copied();
3888        let frame = parse_single_frame(&mut line_it).unwrap();
3889        assert_eq!(frame.header.spec_version, 2);
3890        assert_eq!(frame.atom_data[0].fixed(), [true, false, false]);
3891    }
3892
3893    #[test]
3894    fn test_column_4_value_1_is_all_fixed_on_spec_1() {
3895        let lines = [
3896            "Generated by probe",
3897            "0.0000 TIME",
3898            "20.0 20.0 20.0",
3899            "90.0 90.0 90.0",
3900            "",
3901            "",
3902            "1",
3903            "1",
3904            "63.5",
3905            "Cu",
3906            "Coordinates of component   1",
3907            "1.0 2.0 3.0 1 1",
3908        ];
3909        let mut line_it = lines.iter().copied();
3910        let frame = parse_single_frame(&mut line_it).unwrap();
3911        assert_eq!(frame.header.spec_version, 1);
3912        assert_eq!(frame.atom_data[0].fixed(), [true, true, true]);
3913    }
3914
3915    #[test]
3916    fn test_parse_single_frame_missing_line() {
3917        // With a valid header but truncated atom data, we get IncompleteFrame.
3918        let lines = vec![
3919            "PREBOX1",
3920            "{\"con_spec_version\":2}",
3921            "10.0 20.0 30.0",
3922            "90.0 90.0 90.0",
3923            "POSTBOX1",
3924            "POSTBOX2",
3925            "2",
3926            "3 3",
3927            "12.011 1.008",
3928            "1",
3929            "Coordinates of Component 1",
3930            "0.0 0.0 0.0 0 1",
3931            "1.0940 0.0 0.0 0 2",
3932            "-0.5470 0.9499 0.0 0 3",
3933            // Missing Component 2 entirely
3934        ];
3935        let mut line_it = lines.iter().copied();
3936        let result = parse_single_frame(&mut line_it);
3937        assert!(result.is_err());
3938        assert!(matches!(result.unwrap_err(), ParseError::IncompleteFrame));
3939    }
3940
3941    #[test]
3942    fn test_parse_single_frame_missing_atom_index_defaults_sequential() {
3943        // Column 5 (atom_index) is optional; when absent, defaults to sequential.
3944        let lines = vec![
3945            "PREBOX1",
3946            "{\"con_spec_version\":2}",
3947            "10.0 20.0 30.0",
3948            "90.0 90.0 90.0",
3949            "POSTBOX1",
3950            "POSTBOX2",
3951            "2",
3952            "3 3",
3953            "12.011 1.008",
3954            "1",
3955            "Coordinates of Component 1",
3956            "0.0 0.0 0.0 0 1",
3957            "1.0940 0.0 0.0 0 2",
3958            "-0.5470 0.9499 0.0 0 3",
3959            "2",
3960            "Coordinates of Component 2",
3961            "5.0 5.0 5.0 0",       // No atom_index: defaults to 3
3962            "6.0940 5.0 5.0 0 10", // Explicit atom_index: 10
3963            "5.5470 5.9499 5.0 0", // No atom_index: defaults to 5
3964        ];
3965        let mut line_it = lines.iter().copied();
3966        let frame = parse_single_frame(&mut line_it).unwrap();
3967        assert_eq!(frame.atom_data.len(), 6);
3968        // First type: explicit atom_index values
3969        assert_eq!(frame.atom_data[0].atom_id, 1);
3970        assert_eq!(frame.atom_data[1].atom_id, 2);
3971        assert_eq!(frame.atom_data[2].atom_id, 3);
3972        // Second type: mixed explicit and defaulted
3973        assert_eq!(frame.atom_data[3].atom_id, 3); // defaulted (global idx 3)
3974        assert_eq!(frame.atom_data[4].atom_id, 10); // explicit
3975        assert_eq!(frame.atom_data[5].atom_id, 5); // defaulted (global idx 5)
3976    }
3977
3978    #[test]
3979    fn test_parse_single_frame_too_few_columns_fails() {
3980        // Only 3 columns (missing fixed_flag too) should still fail.
3981        let lines = [
3982            "PREBOX1",
3983            "{\"con_spec_version\":2}",
3984            "10.0 20.0 30.0",
3985            "90.0 90.0 90.0",
3986            "POSTBOX1",
3987            "POSTBOX2",
3988            "1",
3989            "1",
3990            "12.011",
3991            "C",
3992            "Coordinates of Component 1",
3993            "0.0 0.0 0.0", // Only 3 values
3994        ];
3995        let mut line_it = lines.iter().copied();
3996        let result = parse_single_frame(&mut line_it);
3997        assert!(result.is_err());
3998        assert!(matches!(
3999            result.unwrap_err(),
4000            ParseError::InvalidVectorLength {
4001                expected: 5,
4002                found: 3
4003            }
4004        ));
4005    }
4006
4007    #[test]
4008    fn test_parse_velocity_section_present() {
4009        let lines = vec![
4010            "PREBOX1",
4011            "{\"con_spec_version\":2}",
4012            "10.0 20.0 30.0",
4013            "90.0 90.0 90.0",
4014            "POSTBOX1",
4015            "POSTBOX2",
4016            "2",
4017            "1 1",
4018            "63.546 1.008",
4019            "Cu",
4020            "Coordinates of Component 1",
4021            "0.0 0.0 0.0 1 0",
4022            "H",
4023            "Coordinates of Component 2",
4024            "1.0 2.0 3.0 0 1",
4025            "",
4026            "Cu",
4027            "Velocities of Component 1",
4028            "0.1 0.2 0.3 1 0",
4029            "H",
4030            "Velocities of Component 2",
4031            "0.4 0.5 0.6 0 1",
4032        ];
4033        let mut line_it = lines.iter().copied().peekable();
4034        // Parse the frame first (consuming 15 lines)
4035        let mut frame =
4036            parse_single_frame(&mut line_it).expect("coordinate parsing should succeed");
4037        assert!(!frame.has_velocities());
4038
4039        // Now parse the velocity section
4040        let mut column = Vec::new();
4041        let has_vel = parse_velocity_section(
4042            &mut line_it,
4043            &frame.header,
4044            &mut frame.atom_data,
4045            &mut column,
4046        )
4047        .expect("velocity parsing should succeed");
4048        assert!(has_vel);
4049        frame.adopt_velocity_column(column);
4050        assert_eq!(frame.atom_data[0].velocity(), Some([0.1, 0.2, 0.3]));
4051        assert_eq!(frame.atom_data[1].velocity(), Some([0.4, 0.5, 0.6]));
4052    }
4053
4054    #[test]
4055    fn test_validate_true_accepts_matching_section_identity() {
4056        let text = r#"
4057PREBOX1
4058{"con_spec_version":2,"sections":["velocities"],"validate":true}
405910.0 20.0 30.0
406090.0 90.0 90.0
4061POSTBOX1
4062POSTBOX2
40632
40641 1
406563.546 1.008
4066Cu
4067Coordinates of Component 1
40680.0 0.0 0.0 5 0
4069H
4070Coordinates of Component 2
40711.0 2.0 3.0 0 1
4072
4073Cu
4074Velocities of Component 1
40750.1 0.2 0.3 5 0
4076H
4077Velocities of Component 2
40780.4 0.5 0.6 0 1
4079"#;
4080        let mut iter = ConFrameIterator::new(text.trim());
4081        let frame = iter.next().unwrap().unwrap();
4082
4083        assert!(frame.has_velocities());
4084        assert_eq!(
4085            frame
4086                .header
4087                .metadata
4088                .get("validate")
4089                .and_then(|v| v.as_bool()),
4090            Some(true)
4091        );
4092    }
4093
4094    #[test]
4095    fn test_validate_true_rejects_section_atom_id_mismatch() {
4096        let text = r#"
4097PREBOX1
4098{"con_spec_version":2,"sections":["velocities"],"validate":true}
409910.0 20.0 30.0
410090.0 90.0 90.0
4101POSTBOX1
4102POSTBOX2
41031
41041
410563.546
4106Cu
4107Coordinates of Component 1
41080.0 0.0 0.0 5 0
4109
4110Cu
4111Velocities of Component 1
41120.1 0.2 0.3 5 99
4113"#;
4114        let mut iter = ConFrameIterator::new(text.trim());
4115        let err = iter.next().unwrap().unwrap_err();
4116
4117        assert!(matches!(err, ParseError::ValidationError(_)));
4118        assert!(err.to_string().contains("atom_id mismatch"));
4119    }
4120
4121    #[test]
4122    fn test_validate_true_rejects_section_symbol_mismatch() {
4123        let text = r#"
4124PREBOX1
4125{"con_spec_version":2,"sections":["forces"],"validate":true}
412610.0 20.0 30.0
412790.0 90.0 90.0
4128POSTBOX1
4129POSTBOX2
41301
41311
413263.546
4133Cu
4134Coordinates of Component 1
41350.0 0.0 0.0 5 0
4136
4137H
4138Forces of Component 1
41390.1 0.2 0.3 5 0
4140"#;
4141        let mut iter = ConFrameIterator::new(text.trim());
4142        let err = iter.next().unwrap().unwrap_err();
4143
4144        assert!(matches!(err, ParseError::ValidationError(_)));
4145        assert!(err.to_string().contains("symbol mismatch"));
4146    }
4147
4148    #[test]
4149    fn test_validate_absent_allows_legacy_duplicate_identity_mismatch() {
4150        let text = r#"
4151PREBOX1
4152{"con_spec_version":2,"sections":["velocities"]}
415310.0 20.0 30.0
415490.0 90.0 90.0
4155POSTBOX1
4156POSTBOX2
41571
41581
415963.546
4160Cu
4161Coordinates of Component 1
41620.0 0.0 0.0 5 0
4163
4164Cu
4165Velocities of Component 1
41660.1 0.2 0.3 0 99
4167"#;
4168        let mut iter = ConFrameIterator::new(text.trim());
4169        let frame = iter.next().unwrap().unwrap();
4170
4171        assert!(frame.has_velocities());
4172        assert_eq!(frame.atom_data[0].atom_id, 0);
4173        assert_eq!(frame.atom_data[0].fixed(), [true, false, true]);
4174    }
4175
4176    #[test]
4177    fn test_validate_must_be_boolean_when_present() {
4178        let lines = [
4179            "PREBOX1",
4180            "{\"con_spec_version\":2,\"validate\":\"yes\",\"sections\":[]}",
4181            "10.0 20.0 30.0",
4182            "90.0 90.0 90.0",
4183            "POSTBOX1",
4184            "POSTBOX2",
4185            "1",
4186            "1",
4187            "12.011",
4188        ];
4189        let mut line_it = lines.iter().copied();
4190        let err = parse_frame_header(&mut line_it).unwrap_err();
4191
4192        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
4193        assert!(err.to_string().contains("validate"));
4194    }
4195
4196    #[test]
4197    fn test_validate_true_requires_sections_key() {
4198        let lines = [
4199            "PREBOX1",
4200            "{\"con_spec_version\":2,\"validate\":true}",
4201            "10.0 20.0 30.0",
4202            "90.0 90.0 90.0",
4203            "POSTBOX1",
4204            "POSTBOX2",
4205            "1",
4206            "1",
4207            "12.011",
4208        ];
4209        let mut line_it = lines.iter().copied();
4210        let err = parse_frame_header(&mut line_it).unwrap_err();
4211
4212        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
4213        assert!(err.to_string().contains("sections"));
4214    }
4215
4216    #[test]
4217    fn test_sections_must_be_string_array_when_present() {
4218        let lines = [
4219            "PREBOX1",
4220            "{\"con_spec_version\":2,\"sections\":[\"velocities\",7]}",
4221            "10.0 20.0 30.0",
4222            "90.0 90.0 90.0",
4223            "POSTBOX1",
4224            "POSTBOX2",
4225            "1",
4226            "1",
4227            "12.011",
4228        ];
4229        let mut line_it = lines.iter().copied();
4230        let err = parse_frame_header(&mut line_it).unwrap_err();
4231
4232        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
4233        assert!(err.to_string().contains("sections"));
4234    }
4235
4236    #[test]
4237    fn test_validate_true_rejects_non_integer_coordinate_identity_columns() {
4238        let text = r#"
4239PREBOX1
4240{"con_spec_version":2,"sections":[],"validate":true}
424110.0 20.0 30.0
424290.0 90.0 90.0
4243POSTBOX1
4244POSTBOX2
42451
42461
424763.546
4248Cu
4249Coordinates of Component 1
42500.0 0.0 0.0 5.0 0
4251"#;
4252        let mut iter = ConFrameIterator::new(text.trim());
4253        let err = iter.next().unwrap().unwrap_err();
4254
4255        assert!(matches!(err, ParseError::InvalidFixedFlag(_)));
4256        assert!(err.to_string().contains("5.0"));
4257    }
4258
4259    #[test]
4260    fn test_validate_true_rejects_non_exact_coordinate_label() {
4261        let text = r#"
4262PREBOX1
4263{"con_spec_version":2,"sections":[],"validate":true}
426410.0 20.0 30.0
426590.0 90.0 90.0
4266POSTBOX1
4267POSTBOX2
42681
42691
427063.546
4271Cu
4272Coordinates Component 1
42730.0 0.0 0.0 5 0
4274"#;
4275        let mut iter = ConFrameIterator::new(text.trim());
4276        let err = iter.next().unwrap().unwrap_err();
4277
4278        assert!(matches!(err, ParseError::ValidationError(_)));
4279        assert!(err.to_string().contains("Coordinates of Component 1"));
4280    }
4281
4282    #[test]
4283    fn test_validate_true_rejects_unknown_component_symbol() {
4284        let text = r#"
4285PREBOX1
4286{"con_spec_version":2,"sections":[],"validate":true}
428710.0 20.0 30.0
428890.0 90.0 90.0
4289POSTBOX1
4290POSTBOX2
42911
42921
429363.546
4294Qq
4295Coordinates of Component 1
42960.0 0.0 0.0 0 0
4297"#;
4298        let mut iter = ConFrameIterator::new(text.trim());
4299        let err = iter.next().unwrap().unwrap_err();
4300
4301        assert!(matches!(err, ParseError::ValidationError(_)));
4302        assert!(err.to_string().contains("symbol"));
4303    }
4304
4305    #[test]
4306    fn test_declared_section_must_be_present() {
4307        let text = r#"
4308PREBOX1
4309{"con_spec_version":2,"sections":["velocities"]}
431010.0 20.0 30.0
431190.0 90.0 90.0
4312POSTBOX1
4313POSTBOX2
43141
43151
431663.546
4317Cu
4318Coordinates of Component 1
43190.0 0.0 0.0 0 0
4320"#;
4321        let mut iter = ConFrameIterator::new(text.trim());
4322        let err = iter.next().unwrap().unwrap_err();
4323
4324        assert!(matches!(
4325            err,
4326            ParseError::IncompleteSection(ref name) if name == "velocities"
4327        ));
4328    }
4329
4330    #[test]
4331    fn test_non_finite_cell_geometry_rejected_in_strict_mode() {
4332        let lines = [
4333            "PREBOX1",
4334            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
4335            "10.0 NaN 30.0",
4336            "90.0 90.0 90.0",
4337            "POSTBOX1",
4338            "POSTBOX2",
4339            "1",
4340            "1",
4341            "12.011",
4342        ];
4343        let mut line_it = lines.iter().copied();
4344        let err = parse_frame_header(&mut line_it).unwrap_err();
4345
4346        assert!(matches!(err, ParseError::NonFiniteValue(_)));
4347        assert!(err.to_string().contains("NaN"));
4348    }
4349
4350    #[test]
4351    fn test_validate_true_rejects_non_physical_cell_geometry() {
4352        let lines = [
4353            "PREBOX1",
4354            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
4355            "0.0 20.0 30.0",
4356            "90.0 180.0 90.0",
4357            "POSTBOX1",
4358            "POSTBOX2",
4359            "1",
4360            "1",
4361            "12.011",
4362        ];
4363        let mut line_it = lines.iter().copied();
4364        let err = parse_frame_header(&mut line_it).unwrap_err();
4365
4366        assert!(matches!(err, ParseError::ValidationError(_)));
4367        assert!(err.to_string().contains("cell"));
4368    }
4369
4370    #[test]
4371    fn test_validate_true_rejects_reserved_metadata_type_mismatch() {
4372        let lines = [
4373            "PREBOX1",
4374            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"energy\":\"low\"}",
4375            "10.0 20.0 30.0",
4376            "90.0 90.0 90.0",
4377            "POSTBOX1",
4378            "POSTBOX2",
4379            "1",
4380            "1",
4381            "12.011",
4382        ];
4383        let mut line_it = lines.iter().copied();
4384        let err = parse_frame_header(&mut line_it).unwrap_err();
4385
4386        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
4387        assert!(err.to_string().contains("energy"));
4388    }
4389
4390    #[test]
4391    fn test_validate_true_rejects_malformed_bonds() {
4392        let lines = [
4393            "PREBOX1",
4394            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"bonds\":[[0]]}",
4395            "10.0 20.0 30.0",
4396            "90.0 90.0 90.0",
4397            "POSTBOX1",
4398            "POSTBOX2",
4399            "1",
4400            "1",
4401            "12.011",
4402        ];
4403        let mut line_it = lines.iter().copied();
4404        let err = parse_frame_header(&mut line_it).unwrap_err();
4405        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
4406        assert!(err.to_string().contains("bonds"));
4407    }
4408
4409    #[test]
4410    fn test_bonds_metadata_round_trip_in_header() {
4411        use crate::types::{Bond, meta};
4412        let lines = [
4413            "PREBOX1",
4414            "{\"con_spec_version\":2,\"bonds\":[[0,1],{\"i\":0,\"j\":2,\"order\":1}]}",
4415            "10.0 20.0 30.0",
4416            "90.0 90.0 90.0",
4417            "POSTBOX1",
4418            "POSTBOX2",
4419            "1",
4420            "1",
4421            "12.011",
4422        ];
4423        let mut line_it = lines.iter().copied();
4424        let header = parse_frame_header(&mut line_it).expect("header");
4425        let bonds = header.bonds();
4426        assert_eq!(bonds.len(), 2);
4427        assert_eq!(bonds[0], Bond::new(0, 1));
4428        assert_eq!(bonds[1].i, 0);
4429        assert_eq!(bonds[1].j, 2);
4430        assert_eq!(bonds[1].order, Some(1));
4431        assert!(header.metadata.contains_key(meta::BONDS));
4432    }
4433
4434    #[test]
4435    fn test_parse_velocity_section_absent() {
4436        let lines = [
4437            "PREBOX1",
4438            "{\"con_spec_version\":2}",
4439            "10.0 20.0 30.0",
4440            "90.0 90.0 90.0",
4441            "POSTBOX1",
4442            "POSTBOX2",
4443            "1",
4444            "1",
4445            "12.011",
4446            "C",
4447            "Coordinates of Component 1",
4448            "0.0 0.0 0.0 0 1",
4449        ];
4450        let mut line_it = lines.iter().copied().peekable();
4451        let mut frame = parse_single_frame(&mut line_it).expect("parse should succeed");
4452        let mut column = Vec::new();
4453        let has_vel = parse_velocity_section(
4454            &mut line_it,
4455            &frame.header,
4456            &mut frame.atom_data,
4457            &mut column,
4458        )
4459        .expect("should succeed with no velocities");
4460        assert!(!has_vel);
4461        assert!(column.is_empty());
4462        assert_eq!(frame.atom_data[0].velocity(), None);
4463    }
4464
4465    /// The velocity column is the storage `atom.velocity` reads. Writing the
4466    /// atom updates that buffer in place, and the bytes match the file.
4467    #[test]
4468    fn velocity_column_aliases_soa_bytes() {
4469        let text = "\
4470bulk
4471{\"con_spec_version\":2}
44724.0 5.0 6.0
447390.0 90.0 90.0
44740 0
44750 0
44761
44772
447863.546
4479Cu
4480Coordinates of Component 1
44810.0 0.0 0.0 7 0
44821.0 0.0 0.0 0 1
4483
4484Cu
4485Velocities of Component 1
44860.125 -0.25 0.5 7 0
4487-0.125 0.25 -0.5 0 1
4488";
4489        let mut frame = ConFrameIterator::new(text)
4490            .next()
4491            .expect("frame")
4492            .expect("parse");
4493        let soa = frame.velocities.f64_slice().expect("f64 column");
4494        let mut packed = Vec::with_capacity(soa.len());
4495        for atom in &frame.atom_data {
4496            packed.extend_from_slice(&atom.velocity().expect("velocity"));
4497        }
4498        assert_eq!(soa, packed.as_slice());
4499        assert_eq!(soa, &[0.125, -0.25, 0.5, -0.125, 0.25, -0.5][..]);
4500        assert_eq!(frame.atom_data[0].velocity_slot(), soa.as_ptr());
4501        frame.atom_data[0].set_velocity(Some([9.0, 8.0, 7.0]));
4502        assert_eq!(frame.velocities.as_f64_row(0), [9.0, 8.0, 7.0]);
4503        assert_eq!(frame.velocities.as_f64_row(1), [-0.125, 0.25, -0.5]);
4504    }
4505
4506    #[test]
4507    fn force_column_aliases_soa_bytes() {
4508        let text = "\
4509bulk
4510{\"con_spec_version\":2,\"sections\":[\"forces\"]}
45114.0 5.0 6.0
451290.0 90.0 90.0
45130 0
45140 0
45151
45162
451763.546
4518Cu
4519Coordinates of Component 1
45200.0 0.0 0.0 0 0
45211.0 1.0 1.0 7 1
4522
4523Cu
4524Forces of Component 1
45250.1 0.2 0.3 0 0
4526-0.4 0.5 -0.6 7 1
4527";
4528        let mut frame = ConFrameIterator::new(text)
4529            .next()
4530            .expect("frame")
4531            .expect("parse");
4532        let soa = frame.forces.f64_slice().expect("f64 column");
4533        let mut packed = Vec::with_capacity(soa.len());
4534        for atom in &frame.atom_data {
4535            packed.extend_from_slice(&atom.force().expect("force"));
4536        }
4537        assert_eq!(soa, packed.as_slice());
4538        assert_eq!(soa, &[0.1, 0.2, 0.3, -0.4, 0.5, -0.6][..]);
4539        assert_eq!(frame.atom_data[0].force_slot(), soa.as_ptr());
4540        frame.atom_data[1].set_force(Some([1.0, 2.0, 3.0]));
4541        assert_eq!(frame.forces.as_f64_row(1), [1.0, 2.0, 3.0]);
4542    }
4543
4544    /// Million-atom `.convel` parse. Release builds must stay under the
4545    /// per-atom box path (about 0.7 s on this host).
4546    #[test]
4547    #[ignore = "times a million-atom convel parse"]
4548    fn microbench_convel_column_parse() {
4549        let n = 1_000_000usize;
4550        let mut text = String::with_capacity(n * 64);
4551        text.push_str("bench\n{\"con_spec_version\":2}\n4 5 6\n90 90 90\n0 0\n0 0\n1\n");
4552        text.push_str(&format!("{n}\n63.546\nCu\nCoordinates of Component 1\n"));
4553        for i in 0..n {
4554            text.push_str(&format!("0 0 0 0 {i}\n"));
4555        }
4556        text.push_str("\nCu\nVelocities of Component 1\n");
4557        for i in 0..n {
4558            let v = (i % 1000) as f64 * 0.001;
4559            text.push_str(&format!("{v} 0 0 0 {i}\n"));
4560        }
4561        let started = std::time::Instant::now();
4562        let frame = ConFrameIterator::new(&text)
4563            .next()
4564            .expect("frame")
4565            .expect("parse");
4566        let elapsed = started.elapsed().as_secs_f64();
4567        eprintln!("convel column parse {n} atoms {elapsed:.4} s");
4568        let soa = frame.velocities.f64_slice().expect("f64");
4569        assert_eq!(frame.atom_data[0].velocity_slot(), soa.as_ptr());
4570        assert_eq!(frame.atom_data[n - 1].velocity().unwrap()[0], 0.999);
4571        assert_eq!(soa[(n - 1) * 3], 0.999);
4572        if !cfg!(debug_assertions) {
4573            assert!(elapsed < 0.35, "convel parse {elapsed:.4} s");
4574        }
4575        let real = std::path::Path::new("bench/h2h/work/thread-reads/con-h2h.convel");
4576        if let Ok(bytes) = std::fs::read(real) {
4577            let started = std::time::Instant::now();
4578            let frame = ConFrameIterator::from_bytes(&bytes)
4579                .next()
4580                .expect("frame")
4581                .expect("parse");
4582            let elapsed = started.elapsed().as_secs_f64();
4583            eprintln!("real convel column parse {elapsed:.4} s");
4584            let soa = frame.velocities.f64_slice().expect("f64");
4585            assert_eq!(frame.atom_data[0].velocity_slot(), soa.as_ptr());
4586            assert_eq!(
4587                frame.atom_data[0].velocity().unwrap(),
4588                frame.velocities.as_f64_row(0)
4589            );
4590        }
4591    }
4592
4593    #[test]
4594    fn test_parse_line_of_range_f64_exact() {
4595        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0 42", 4, 5, &[0.0; 5]).unwrap();
4596        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 42.0]);
4597    }
4598
4599    #[test]
4600    fn test_byte_scan_stack_parses_realistic_coord_line() {
4601        // Typical CON atom line: x y z fixed_mask [atom_id]
4602        let line = "   0.63939999999999997    0.90449999999999997   -0.00009999999999977 1    0";
4603        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
4604        let mut buf = [0.0f64; NUMERIC_STACK_WIDTH];
4605        let n = parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
4606        assert_eq!(n, 5);
4607        assert!((buf[0] - 0.6394).abs() < 1e-4);
4608        assert!((buf[1] - 0.9045).abs() < 1e-4);
4609        assert_eq!(buf[3] as u8, 1);
4610        assert_eq!(buf[4] as u64, 0);
4611        // trailing junk must error
4612        let bad = "1.0 2.0 3.0 1 0 EXTRA";
4613        assert!(parse_line_of_range_f64_stack(bad, 4, 5, &defaults, &mut buf).is_err());
4614        // partial non-boundary token must error
4615        let glued = "1.0 2.0 3.0abc 1 0";
4616        assert!(parse_line_of_range_f64_stack(glued, 4, 5, &defaults, &mut buf).is_err());
4617    }
4618
4619    #[test]
4620    fn test_parse_line_of_range_f64_stack_matches_vec_api() {
4621        let defaults = [0.0, 0.0, 0.0, 0.0, 7.0];
4622        let line = "1.0 2.0 3.0 0.0";
4623        let mut buf = [0.0f64; NUMERIC_STACK_WIDTH];
4624        parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
4625        let via_vec = parse_line_of_range_f64(line, 4, 5, &defaults).unwrap();
4626        assert_eq!(&buf[..5], via_vec.as_slice());
4627        assert_eq!(buf[4], 7.0);
4628    }
4629
4630    #[test]
4631    fn test_parse_line_of_range_f64_padded() {
4632        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
4633        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0", 4, 5, &defaults).unwrap();
4634        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 99.0]);
4635    }
4636
4637    #[test]
4638    fn test_parse_line_of_range_f64_too_few() {
4639        let result = parse_line_of_range_f64("1.0 2.0 3.0", 4, 5, &[0.0; 5]);
4640        assert!(result.is_err());
4641    }
4642
4643    #[test]
4644    fn test_parse_line_of_range_f64_too_many() {
4645        let result = parse_line_of_range_f64("1.0 2.0 3.0 0.0 5.0 6.0", 4, 5, &[0.0; 5]);
4646        assert!(result.is_err());
4647    }
4648
4649    #[test]
4650    fn test_parse_all_four_column_lines() {
4651        // All atom lines have only 4 columns; atom_index defaults to sequential.
4652        let lines = [
4653            "PREBOX1",
4654            "{\"con_spec_version\":2}",
4655            "10.0 10.0 10.0",
4656            "90.0 90.0 90.0",
4657            "POSTBOX1",
4658            "POSTBOX2",
4659            "1",
4660            "3",
4661            "12.011",
4662            "C",
4663            "Coordinates of Component 1",
4664            "0.0 0.0 0.0 0",
4665            "1.0 0.0 0.0 0",
4666            "2.0 0.0 0.0 1",
4667        ];
4668        let mut line_it = lines.iter().copied();
4669        let frame = parse_single_frame(&mut line_it).unwrap();
4670        assert_eq!(frame.atom_data[0].atom_id, 0);
4671        assert_eq!(frame.atom_data[1].atom_id, 1);
4672        assert_eq!(frame.atom_data[2].atom_id, 2);
4673        assert!(frame.atom_data[2].is_fixed());
4674    }
4675
4676    #[test]
4677    fn test_positions_take_the_storage_dtype_of_the_metadata() {
4678        use crate::storage_dtype::{ElementKind, FloatArray2};
4679        let rows = [[0.5, -1.25, 3.0], [1.0e-3, 2.5e4, -7.75]];
4680        let atom_lines: Vec<String> = rows
4681            .iter()
4682            .enumerate()
4683            .map(|(id, [x, y, z])| format!("{x} {y} {z} 0 {id}"))
4684            .collect();
4685        for &kind in ElementKind::all_hosted() {
4686            let metadata = format!(
4687                "{{\"con_spec_version\":3,\"units\":{{\"length\":\"angstrom\",\"energy\":\"eV\"}},\
4688                 \"storage_dtypes\":{{\"positions\":\"{}\"}}}}",
4689                kind.as_str()
4690            );
4691            let mut lines = vec![
4692                "PREBOX1",
4693                metadata.as_str(),
4694                "10.0 10.0 10.0",
4695                "90.0 90.0 90.0",
4696                "POSTBOX1",
4697                "POSTBOX2",
4698                "1",
4699                "2",
4700                "1.008",
4701                "H",
4702                "Coordinates of Component 1",
4703            ];
4704            lines.extend(atom_lines.iter().map(String::as_str));
4705            let frame = parse_single_frame(&mut lines.iter().copied()).unwrap();
4706            assert_eq!(frame.positions.kind(), kind);
4707            assert_eq!(frame.positions.nrows(), rows.len());
4708            for (i, &xyz) in rows.iter().enumerate() {
4709                let mut expected = FloatArray2::zeros(kind, 1, 3);
4710                expected.set_f64_row(0, xyz);
4711                assert_eq!(
4712                    frame.positions.as_f64_row(i),
4713                    expected.as_f64_row(0),
4714                    "{kind:?} row {i}"
4715                );
4716            }
4717        }
4718    }
4719}
4720
4721#[cfg(all(test, feature = "parallel"))]
4722mod newline_index_bench {
4723    use super::newline_index_at;
4724
4725    #[test]
4726    fn index_matches_memchr() {
4727        let mut bytes = vec![b'x'; 80_000];
4728        for index in (20..bytes.len()).step_by(23) {
4729            bytes[index] = b'\n';
4730        }
4731        let serial: Vec<usize> = memchr::memchr_iter(b'\n', &bytes).collect();
4732        assert_eq!(newline_index_at(&bytes, 0, 8), serial);
4733        // Above the 1 MiB serial cutoff, including a buffer of bare newlines.
4734        let mut long = Vec::new();
4735        while long.len() < (1 << 20) + 64 {
4736            long.extend_from_slice(b"01234567 1.25 -2.5 3.5 0 7\n");
4737        }
4738        let long_serial: Vec<usize> = memchr::memchr_iter(b'\n', &long).collect();
4739        assert_eq!(newline_index_at(&long, 40, 8), {
4740            let mut shifted = long_serial.clone();
4741            for offset in &mut shifted {
4742                *offset += 40;
4743            }
4744            shifted
4745        });
4746        let bare = vec![b'\n'; (1 << 20) + 64];
4747        let bare_serial: Vec<usize> = memchr::memchr_iter(b'\n', &bare).collect();
4748        assert_eq!(newline_index_at(&bare, 0, 8), bare_serial);
4749    }
4750}