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