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("neither column is strictly ascending; expected a mach column".to_string());
210        }
211        (true, true) => {
212            // Both columns ascend (e.g. a (cd, mach) file whose cd happens to be
213            // monotonic across a subsonic-only deck). Break the tie by magnitude: mach
214            // spans past 1.0 in real decks, cd stays well under ~1.5, so the column with
215            // the larger maximum is mach — unless the two maxima are too close to call.
216            let max0 = col0.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
217            let max1 = col1.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
218            let (larger, smaller) = if max0 >= max1 {
219                (max0, max1)
220            } else {
221                (max1, max0)
222            };
223            if larger > 0.0 && smaller / larger >= 0.8 {
224                return Err(
225                    "ambiguous columns: both ascend with similar ranges; cannot determine \
226                     which is mach - if you know the order, convert the file to a mach,cd \
227                     CSV to bypass detection"
228                        .to_string(),
229                );
230            }
231            max0 > max1
232        }
233    };
234
235    let mut points = Vec::with_capacity(raw.len());
236    for &(lineno, a, b) in &raw {
237        let (mach, cd) = if mach_is_col0 { (a, b) } else { (b, a) };
238        if !mach.is_finite() || mach < 0.0 {
239            return Err(format!(
240                "line {lineno}: mach must be finite and >= 0, got {mach}"
241            ));
242        }
243        if !cd.is_finite() || cd <= 0.0 {
244            return Err(format!(
245                "line {lineno}: cd must be finite and > 0, got {cd}"
246            ));
247        }
248        points.push((mach, cd));
249    }
250
251    Ok(ParsedDragCurve { name, points })
252}
253
254/// Cheap sniff: does `text` look like `.drg` (or headerless two-column CSV of the same
255/// shape)? Finds the first two data rows and checks that one of the two columns is
256/// ascending between them — a bounded scan that does **not** allocate the full points
257/// vector (unlike calling [`parse_drg`] and checking `is_ok()`), so it's cheap to use as
258/// a pre-check before committing to a full parse.
259pub fn looks_like_drg(text: &str) -> bool {
260    let mut first: Option<(f64, f64)> = None;
261    for line in text.lines() {
262        match classify_line(line) {
263            LineKind::Row(a, b) => match first {
264                None => first = Some((a, b)),
265                Some((pa, pb)) => return pa < a || pb < b,
266            },
267            LineKind::DecimalComma => return false,
268            LineKind::Blank | LineKind::Other => {}
269        }
270    }
271    false
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    const SYNTH: &str = "SYN123, Synthetic Test Bullet 7.62mm 10.00g\n\
279                         Doppler radar test data (synthetic)\n\
280                         0.50 0.230\n\
281                         0.80 0.210\n\
282                         0.95 0.280\n\
283                         1.05 0.450\n\
284                         1.50 0.420\n\
285                         2.50 0.300\n";
286
287    #[test]
288    fn parses_synthetic_drg_with_header() {
289        let c = parse_drg(SYNTH).unwrap();
290        assert_eq!(c.points.len(), 6);
291        assert_eq!(c.points[0], (0.50, 0.230));
292        assert_eq!(c.points[5], (2.50, 0.300));
293        assert_eq!(
294            c.name.as_deref(),
295            Some("SYN123, Synthetic Test Bullet 7.62mm 10.00g")
296        );
297    }
298
299    #[test]
300    fn rejects_bad_decks() {
301        // descending mach
302        assert!(parse_drg("t\n1.0 0.3\n0.9 0.3\n").is_err());
303        // single row
304        assert!(parse_drg("t\n1.0 0.3\n").is_err());
305        // non-positive cd
306        assert!(parse_drg("t\n0.5 0.0\n1.0 0.3\n").is_err());
307        // garbage after data starts
308        assert!(parse_drg("t\n0.5 0.3\nnot numbers\n1.0 0.3\n").is_err());
309        // decimal commas -> clear error mentioning decimal
310        let e = parse_drg("t\n0,5 0,3\n1,0 0,31\n").unwrap_err();
311        assert!(
312            e.to_lowercase().contains("decimal") || e.to_lowercase().contains("comma"),
313            "{e}"
314        );
315        // empty / header-only
316        assert!(parse_drg("").is_err());
317        assert!(parse_drg("just a title\n").is_err());
318    }
319
320    #[test]
321    fn sniff_distinguishes_drg_from_csv_and_junk() {
322        assert!(looks_like_drg(SYNTH));
323        assert!(!looks_like_drg("mach,cd\n")); // header-only csv
324        assert!(!looks_like_drg("hello world\nthis is prose\n"));
325        // a plain two-column csv WITHOUT header is also a valid drg shape — sniffing
326        // may accept it; that is fine (same numbers either way). Just pin the behavior:
327        assert!(looks_like_drg("0.5,0.3\n1.0,0.31\n"));
328    }
329
330    #[test]
331    fn row_cap_enforced() {
332        let mut s = String::from("t\n");
333        for i in 0..4097 {
334            s.push_str(&format!("{} 0.3\n", 0.1 + i as f64 * 0.001));
335        }
336        assert!(parse_drg(&s).is_err());
337    }
338
339    // --- Additional coverage beyond the brief's baseline, informed by the real vendor
340    // sample inspected for this task (structure only; no vendor data reproduced here) ---
341
342    #[test]
343    fn accepts_cd_mach_column_order_like_the_real_vendor_format() {
344        // Exercises column-order detection: some files store (cd, mach) rather than
345        // (mach, cd). Everything here — header text, field values, separators — is
346        // invented for this test; it shares no structure with any vendor file.
347        let synth_cd_mach = "synthetic reversed-column test deck, invented values\r\n\
348             0.230\t0.000\r\n\
349             0.210\t0.400\r\n\
350             0.280\t0.900\r\n\
351             0.450\t1.050\r\n\
352             0.300\t2.500\r\n";
353        let c = parse_drg(synth_cd_mach).unwrap();
354        assert_eq!(c.points.len(), 5);
355        assert_eq!(c.points[0], (0.000, 0.230));
356        assert_eq!(c.points[4], (2.500, 0.300));
357    }
358
359    #[test]
360    fn semicolon_separated_rows_are_accepted() {
361        let c = parse_drg("t\n0.5;0.3\n1.0;0.31\n").unwrap();
362        assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
363    }
364
365    #[test]
366    fn headerless_csv_has_no_name() {
367        let c = parse_drg("0.5,0.3\n1.0,0.31\n").unwrap();
368        assert_eq!(c.name, None);
369    }
370
371    #[test]
372    fn mach_may_start_at_zero() {
373        // The real vendor sample's first row is mach = 0 exactly; confirm the >= 0
374        // (not > 0) tolerance is intentional and doesn't reject this.
375        let c = parse_drg("t\n0.0 0.3\n1.0 0.31\n").unwrap();
376        assert_eq!(c.points[0], (0.0, 0.3));
377    }
378
379    #[test]
380    fn looks_like_drg_is_false_for_empty_and_single_row() {
381        assert!(!looks_like_drg(""));
382        assert!(!looks_like_drg("t\n1.0 0.3\n"));
383    }
384
385    // --- Column tie-break when both columns strictly ascend (MBA-1409 review finding 2) ---
386
387    #[test]
388    fn both_ascend_larger_max_col1_rescues_subsonic_only_cd_mach_deck() {
389        // A (cd, mach) deck limited to the subsonic range can have a strictly ascending
390        // cd column too (col0 here: 0.20 -> 0.30). Column 1 still wins because its
391        // maximum (2.0) is far larger than column 0's (0.30) — mach spans past 1.0,
392        // cd doesn't.
393        let c = parse_drg("t\n0.20 0.5\n0.25 1.0\n0.30 2.0\n").unwrap();
394        assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
395    }
396
397    #[test]
398    fn both_ascend_larger_max_col0_is_mach() {
399        // Ordinary (mach, cd) order where cd also happens to ascend (0.20 -> 0.30).
400        // Column 0 wins because its maximum (2.0) is far larger than column 1's (0.30).
401        let c = parse_drg("t\n0.5 0.20\n1.0 0.25\n2.0 0.30\n").unwrap();
402        assert_eq!(c.points, vec![(0.5, 0.20), (1.0, 0.25), (2.0, 0.30)]);
403    }
404
405    #[test]
406    fn both_ascend_similar_maxima_is_ambiguous_error() {
407        // Both columns ascend and their maxima (1.0 vs 1.1) are within 20% of each
408        // other -- too close to call which one is mach.
409        let e = parse_drg("t\n0.5 0.6\n0.8 0.9\n1.0 1.1\n").unwrap_err();
410        assert!(e.contains("ambiguous"), "{e}");
411    }
412
413    // --- Header-phase decimal-comma false positive (MBA-1409 review finding 3) ---
414
415    #[test]
416    fn header_line_with_prose_and_comma_number_is_skipped_not_flagged_as_decimal_comma() {
417        // A header/metadata line mixing prose with a comma-formatted number (only one of
418        // its two tokens looks comma-decimal-shaped) must be treated as ordinary
419        // skippable header text, not misdiagnosed as an attempted decimal-comma data row.
420        let c = parse_drg("Density 1,225\n0.5 0.3\n1.0 0.31\n").unwrap();
421        assert_eq!(c.points, vec![(0.5, 0.3), (1.0, 0.31)]);
422        assert_eq!(c.name.as_deref(), Some("Density 1,225"));
423    }
424}