ballistics_engine/
drag_file.rs

1//! Cleanroom parser for the `.drg` drag-curve text file format (MBA-1409).
2//!
3//! `.drg` is a small vendor text format used to distribute Doppler-radar-measured
4//! drag-coefficient-vs-Mach curves for individual bullets (e.g. Lapua's free downloads
5//! for QuickTARGET Unlimited). This module was written from **publicly observable file
6//! structure only** — line layout, field separators, and column semantics inferred by
7//! inspecting one real vendor sample locally for grammar-pinning purposes (see the task
8//! report for structural findings). **No vendor drag-coefficient data is embedded here**:
9//! every fixture in this module's tests is synthetic, invented content.
10//!
11//! Shared by the CLI (`main.rs`) and the WASM terminal (`wasm.rs`); lives in the library
12//! crate — not gated behind any feature — so it compiles for `wasm32-unknown-unknown`.
13//! This module is fs-free: it parses an in-memory `&str`, never touches `std::fs`. File
14//! I/O (reading the path the user gave `--drag-table`, or the WASM text-ingestion glue)
15//! stays in the caller.
16//!
17//! ## Grammar (tolerant superset covering the vendor layout and plain two-column CSV)
18//!
19//! - Zero or more leading non-data lines (title/header/blank/comment) are skipped until
20//!   the first line that parses as exactly two finite numbers. The first non-empty
21//!   skipped line (trimmed) is captured as [`ParsedDragCurve::name`]; if the very first
22//!   line in the file is itself a data row (headerless CSV), `name` is `None`.
23//! - From that point on, every non-blank line must parse as exactly two finite numbers —
24//!   garbage after data has started is a hard error, not silently skipped.
25//! - Fields are separated by a run of whitespace (space or tab — real vendor files use
26//!   tab) or by a single `,`/`;` delimiter.
27//! - Columns may appear as `(mach, cd)` *or* `(cd, mach)`. Column detection: if exactly
28//!   one column is strictly ascending across every row, that column is mach (this is not
29//!   merely a defensive fallback — some real vendor files use `(cd, mach)` column order,
30//!   so this detection is load-bearing). If *both* columns ascend — e.g. a `(cd, mach)`
31//!   file whose cd happens to be monotonic across a subsonic-only deck — the column with
32//!   the larger maximum value is mach: mach spans past `1.0` in real decks, while cd
33//!   stays well under `1.5`. If the two maxima are within 20% of each other, that's too
34//!   close to call, and parsing fails with a dedicated "ambiguous columns" error rather
35//!   than silently guessing. If neither column ascends, parsing fails with a dedicated
36//!   error naming the problem.
37//! - The decimal separator is `.` only. A line is only flagged as the decimal-comma
38//!   error if it splits into exactly two tokens and *both* look like decimal-comma
39//!   numbers (e.g. `"0,5 0,3"`); this produces a dedicated error naming the problem,
40//!   rather than a generic parse failure or a comma-CSV misread. A line where only one
41//!   token looks like a decimal-comma number — e.g. a header line mixing prose and a
42//!   comma-formatted number such as `"Density 1,225"` — is not treated as this error; it
43//!   is simply skipped like ordinary header text.
44//! - Row count must be in `2..=4096` (`4096` matches `ffi::MAX_FFI_DRAG_TABLE_LEN`, the
45//!   cap already enforced on the array-based FFI drag-table entry point).
46//! - `mach` must be finite and `>= 0` (the real vendor sample's first row is exactly
47//!   `mach = 0`); `cd` must be finite and `> 0`.
48
49/// The maximum number of `(mach, cd)` rows a `.drg` file may contain.
50/// Matches `ffi::MAX_FFI_DRAG_TABLE_LEN`, the cap already enforced on the array-based FFI
51/// custom-drag-table entry point, so a `.drg` load can never produce a table too large for
52/// that path to accept.
53const MAX_DRG_ROWS: usize = 4096;
54/// The minimum number of rows needed to define a drag curve.
55const MIN_DRG_ROWS: usize = 2;
56
57/// A drag curve parsed from `.drg` text: an optional vendor-supplied name/description and
58/// the `(mach, cd)` points in ascending-mach order.
59#[derive(Debug, Clone, PartialEq)]
60pub struct ParsedDragCurve {
61    pub name: Option<String>,
62    pub points: Vec<(f64, f64)>,
63}
64
65/// How a single line of `.drg` text classifies during parsing.
66enum LineKind {
67    /// Blank (whitespace-only) line: always skippable, before or after data starts.
68    Blank,
69    /// A data row: two finite-parseable tokens, in file column order (not yet assigned
70    /// to mach/cd — that happens once every row has been collected).
71    Row(f64, f64),
72    /// Looks like an attempted data row using a decimal comma (e.g. `"0,5 0,3"`) rather
73    /// than a decimal point: exactly two tokens, *both* comma-decimal-shaped. Always a
74    /// hard error, even during header-skip — unlike a merely-skippable header line that
75    /// happens to contain one comma-formatted number amid prose (only one token would be
76    /// comma-decimal-shaped there, which doesn't qualify).
77    DecimalComma,
78    /// Anything else: header/title/comment text, or (once data has started) garbage.
79    Other,
80}
81
82/// Tries to interpret `tokens` as exactly two numeric fields. Returns `None` if `tokens`
83/// doesn't have exactly two entries (the caller should try a different separator), or if
84/// there aren't clearly two intended numbers at all.
85fn try_two_tokens(tokens: &[&str]) -> Option<LineKind> {
86    if tokens.len() != 2 {
87        return None;
88    }
89    let t0 = tokens[0].trim();
90    let t1 = tokens[1].trim();
91    match (t0.parse::<f64>(), t1.parse::<f64>()) {
92        (Ok(a), Ok(b)) => Some(LineKind::Row(a, b)),
93        _ => {
94            // Both tokens must look like decimal-comma numbers before we call this an
95            // attempted (malformed) data row. If only one token looks comma-decimal-ish
96            // (e.g. a header line mixing prose with a comma-formatted number, such as
97            // "Density 1,225"), that's not a data row at all — leave it as `None` so the
98            // caller falls through to ordinary header/garbage handling instead of a hard
99            // decimal-comma error.
100            if looks_like_comma_decimal(t0) && looks_like_comma_decimal(t1) {
101                Some(LineKind::DecimalComma)
102            } else {
103                None
104            }
105        }
106    }
107}
108
109/// True if `tok` looks like a number written with a decimal comma instead of a decimal
110/// point (e.g. `"0,523"`): no `.` present, exactly one `,`, and swapping that `,` for `.`
111/// makes it parse as `f64`.
112fn looks_like_comma_decimal(tok: &str) -> bool {
113    if tok.is_empty() || tok.contains('.') {
114        return false;
115    }
116    if tok.matches(',').count() != 1 {
117        return false;
118    }
119    tok.replace(',', ".").parse::<f64>().is_ok()
120}
121
122/// Classifies one line of `.drg` text. Tries whitespace-separated fields first (the real
123/// vendor format uses tab), then comma, then semicolon.
124fn classify_line(line: &str) -> LineKind {
125    let trimmed = line.trim();
126    if trimmed.is_empty() {
127        return LineKind::Blank;
128    }
129    let ws_tokens: Vec<&str> = trimmed.split_whitespace().collect();
130    if let Some(kind) = try_two_tokens(&ws_tokens) {
131        return kind;
132    }
133    let comma_tokens: Vec<&str> = trimmed.split(',').map(str::trim).collect();
134    if let Some(kind) = try_two_tokens(&comma_tokens) {
135        return kind;
136    }
137    let semi_tokens: Vec<&str> = trimmed.split(';').map(str::trim).collect();
138    if let Some(kind) = try_two_tokens(&semi_tokens) {
139        return kind;
140    }
141    LineKind::Other
142}
143
144/// True if every consecutive pair in `vals` is strictly increasing.
145fn is_strictly_ascending(vals: &[f64]) -> bool {
146    vals.windows(2).all(|w| w[0] < w[1])
147}
148
149/// Parses `.drg` drag-curve text into a [`ParsedDragCurve`]. See the module docs for the
150/// full tolerance contract. On any violation, returns `Err(String)` naming the offending
151/// line number (1-indexed) and the problem.
152pub fn parse_drg(text: &str) -> Result<ParsedDragCurve, String> {
153    let mut name: Option<String> = None;
154    let mut header_done = false;
155    let mut raw: Vec<(usize, f64, f64)> = Vec::new();
156
157    for (idx, line) in text.lines().enumerate() {
158        let lineno = idx + 1;
159        match classify_line(line) {
160            LineKind::DecimalComma => {
161                return Err(format!(
162                    "line {lineno}: numbers appear to use a decimal comma (e.g. \"0,5\"); \
163                     .drg files must use a decimal point"
164                ));
165            }
166            LineKind::Row(a, b) => {
167                header_done = true;
168                if raw.len() >= MAX_DRG_ROWS {
169                    return Err(format!(
170                        "line {lineno}: too many data rows (more than {MAX_DRG_ROWS})"
171                    ));
172                }
173                raw.push((lineno, a, b));
174            }
175            LineKind::Blank => {}
176            LineKind::Other => {
177                if header_done {
178                    return Err(format!(
179                        "line {lineno}: expected two numbers, found {:?}",
180                        line.trim()
181                    ));
182                }
183                let trimmed = line.trim();
184                if name.is_none() && !trimmed.is_empty() {
185                    name = Some(trimmed.to_string());
186                }
187            }
188        }
189    }
190
191    if raw.len() < MIN_DRG_ROWS {
192        return Err(format!(
193            "found {} data row(s); need at least {MIN_DRG_ROWS}",
194            raw.len()
195        ));
196    }
197
198    let col0: Vec<f64> = raw.iter().map(|&(_, a, _)| a).collect();
199    let col1: Vec<f64> = raw.iter().map(|&(_, _, b)| b).collect();
200
201    // Column detection. Real vendor files store (cd, mach) — column 0 is *not* mach there
202    // — so this detection must run for every file, not just as a defensive fallback.
203    let col0_ascends = is_strictly_ascending(&col0);
204    let col1_ascends = is_strictly_ascending(&col1);
205    let mach_is_col0 = match (col0_ascends, col1_ascends) {
206        (true, false) => true,
207        (false, true) => false,
208        (false, false) => {
209            return Err(
210                "neither column is strictly ascending; expected a mach column".to_string(),
211            );
212        }
213        (true, true) => {
214            // Both columns ascend (e.g. a (cd, mach) file whose cd happens to be
215            // monotonic across a subsonic-only deck). Break the tie by magnitude: mach
216            // spans past 1.0 in real decks, cd stays well under ~1.5, so the column with
217            // the larger maximum is mach — unless the two maxima are too close to call.
218            let max0 = col0.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
219            let max1 = col1.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
220            let (larger, smaller) = if max0 >= max1 { (max0, max1) } else { (max1, max0) };
221            if larger > 0.0 && smaller / larger >= 0.8 {
222                return Err(
223                    "ambiguous columns: both ascend with similar ranges; cannot determine \
224                     which is mach - if you know the order, convert the file to a mach,cd \
225                     CSV to bypass detection"
226                        .to_string(),
227                );
228            }
229            max0 > max1
230        }
231    };
232
233    let mut points = Vec::with_capacity(raw.len());
234    for &(lineno, a, b) in &raw {
235        let (mach, cd) = if mach_is_col0 { (a, b) } else { (b, a) };
236        if !mach.is_finite() || mach < 0.0 {
237            return Err(format!(
238                "line {lineno}: mach must be finite and >= 0, got {mach}"
239            ));
240        }
241        if !cd.is_finite() || cd <= 0.0 {
242            return Err(format!("line {lineno}: cd must be finite and > 0, got {cd}"));
243        }
244        points.push((mach, cd));
245    }
246
247    Ok(ParsedDragCurve { name, points })
248}
249
250/// Cheap sniff: does `text` look like `.drg` (or headerless two-column CSV of the same
251/// shape)? Finds the first two data rows and checks that one of the two columns is
252/// ascending between them — a bounded scan that does **not** allocate the full points
253/// vector (unlike calling [`parse_drg`] and checking `is_ok()`), so it's cheap to use as
254/// a pre-check before committing to a full parse.
255pub fn looks_like_drg(text: &str) -> bool {
256    let mut first: Option<(f64, f64)> = None;
257    for line in text.lines() {
258        match classify_line(line) {
259            LineKind::Row(a, b) => match first {
260                None => first = Some((a, b)),
261                Some((pa, pb)) => return pa < a || pb < b,
262            },
263            LineKind::DecimalComma => return false,
264            LineKind::Blank | LineKind::Other => {}
265        }
266    }
267    false
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    const SYNTH: &str = "SYN123, Synthetic Test Bullet 7.62mm 10.00g\n\
275                         Doppler radar test data (synthetic)\n\
276                         0.50 0.230\n\
277                         0.80 0.210\n\
278                         0.95 0.280\n\
279                         1.05 0.450\n\
280                         1.50 0.420\n\
281                         2.50 0.300\n";
282
283    #[test]
284    fn parses_synthetic_drg_with_header() {
285        let c = parse_drg(SYNTH).unwrap();
286        assert_eq!(c.points.len(), 6);
287        assert_eq!(c.points[0], (0.50, 0.230));
288        assert_eq!(c.points[5], (2.50, 0.300));
289        assert_eq!(c.name.as_deref(), Some("SYN123, Synthetic Test Bullet 7.62mm 10.00g"));
290    }
291
292    #[test]
293    fn rejects_bad_decks() {
294        // descending mach
295        assert!(parse_drg("t\n1.0 0.3\n0.9 0.3\n").is_err());
296        // single row
297        assert!(parse_drg("t\n1.0 0.3\n").is_err());
298        // non-positive cd
299        assert!(parse_drg("t\n0.5 0.0\n1.0 0.3\n").is_err());
300        // garbage after data starts
301        assert!(parse_drg("t\n0.5 0.3\nnot numbers\n1.0 0.3\n").is_err());
302        // decimal commas -> clear error mentioning decimal
303        let e = parse_drg("t\n0,5 0,3\n1,0 0,31\n").unwrap_err();
304        assert!(e.to_lowercase().contains("decimal") || e.to_lowercase().contains("comma"), "{e}");
305        // empty / header-only
306        assert!(parse_drg("").is_err());
307        assert!(parse_drg("just a title\n").is_err());
308    }
309
310    #[test]
311    fn sniff_distinguishes_drg_from_csv_and_junk() {
312        assert!(looks_like_drg(SYNTH));
313        assert!(!looks_like_drg("mach,cd\n")); // header-only csv
314        assert!(!looks_like_drg("hello world\nthis is prose\n"));
315        // a plain two-column csv WITHOUT header is also a valid drg shape — sniffing
316        // may accept it; that is fine (same numbers either way). Just pin the behavior:
317        assert!(looks_like_drg("0.5,0.3\n1.0,0.31\n"));
318    }
319
320    #[test]
321    fn row_cap_enforced() {
322        let mut s = String::from("t\n");
323        for i in 0..4097 {
324            s.push_str(&format!("{} 0.3\n", 0.1 + i as f64 * 0.001));
325        }
326        assert!(parse_drg(&s).is_err());
327    }
328
329    // --- Additional coverage beyond the brief's baseline, informed by the real vendor
330    // sample inspected for this task (structure only; no vendor data reproduced here) ---
331
332    #[test]
333    fn accepts_cd_mach_column_order_like_the_real_vendor_format() {
334        // Exercises column-order detection: some files store (cd, mach) rather than
335        // (mach, cd). Everything here — header text, field values, separators — is
336        // invented for this test; it shares no structure with any vendor file.
337        let synth_cd_mach =
338            "synthetic reversed-column test deck, invented values\r\n\
339             0.230\t0.000\r\n\
340             0.210\t0.400\r\n\
341             0.280\t0.900\r\n\
342             0.450\t1.050\r\n\
343             0.300\t2.500\r\n";
344        let c = parse_drg(synth_cd_mach).unwrap();
345        assert_eq!(c.points.len(), 5);
346        assert_eq!(c.points[0], (0.000, 0.230));
347        assert_eq!(c.points[4], (2.500, 0.300));
348    }
349
350    #[test]
351    fn semicolon_separated_rows_are_accepted() {
352        let c = parse_drg("t\n0.5;0.3\n1.0;0.31\n").unwrap();
353        assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
354    }
355
356    #[test]
357    fn headerless_csv_has_no_name() {
358        let c = parse_drg("0.5,0.3\n1.0,0.31\n").unwrap();
359        assert_eq!(c.name, None);
360    }
361
362    #[test]
363    fn mach_may_start_at_zero() {
364        // The real vendor sample's first row is mach = 0 exactly; confirm the >= 0
365        // (not > 0) tolerance is intentional and doesn't reject this.
366        let c = parse_drg("t\n0.0 0.3\n1.0 0.31\n").unwrap();
367        assert_eq!(c.points[0], (0.0, 0.3));
368    }
369
370    #[test]
371    fn looks_like_drg_is_false_for_empty_and_single_row() {
372        assert!(!looks_like_drg(""));
373        assert!(!looks_like_drg("t\n1.0 0.3\n"));
374    }
375
376    // --- Column tie-break when both columns strictly ascend (MBA-1409 review finding 2) ---
377
378    #[test]
379    fn both_ascend_larger_max_col1_rescues_subsonic_only_cd_mach_deck() {
380        // A (cd, mach) deck limited to the subsonic range can have a strictly ascending
381        // cd column too (col0 here: 0.20 -> 0.30). Column 1 still wins because its
382        // maximum (2.0) is far larger than column 0's (0.30) — mach spans past 1.0,
383        // cd doesn't.
384        let c = parse_drg("t\n0.20 0.5\n0.25 1.0\n0.30 2.0\n").unwrap();
385        assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
386    }
387
388    #[test]
389    fn both_ascend_larger_max_col0_is_mach() {
390        // Ordinary (mach, cd) order where cd also happens to ascend (0.20 -> 0.30).
391        // Column 0 wins because its maximum (2.0) is far larger than column 1's (0.30).
392        let c = parse_drg("t\n0.5 0.20\n1.0 0.25\n2.0 0.30\n").unwrap();
393        assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
394    }
395
396    #[test]
397    fn both_ascend_similar_maxima_is_ambiguous_error() {
398        // Both columns ascend and their maxima (1.0 vs 1.1) are within 20% of each
399        // other -- too close to call which one is mach.
400        let e = parse_drg("t\n0.5 0.6\n0.8 0.9\n1.0 1.1\n").unwrap_err();
401        assert!(e.contains("ambiguous"), "{e}");
402    }
403
404    // --- Header-phase decimal-comma false positive (MBA-1409 review finding 3) ---
405
406    #[test]
407    fn header_line_with_prose_and_comma_number_is_skipped_not_flagged_as_decimal_comma() {
408        // A header/metadata line mixing prose with a comma-formatted number (only one of
409        // its two tokens looks comma-decimal-shaped) must be treated as ordinary
410        // skippable header text, not misdiagnosed as an attempted decimal-comma data row.
411        let c = parse_drg("Density 1,225\n0.5 0.3\n1.0 0.31\n").unwrap();
412        assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
413        assert_eq!(c.name.as_deref(), Some("Density 1,225"));
414    }
415}