Skip to main content

ballistics_engine/
card.rs

1//! Shared display-ready row type behind the CLI's card-shaped table/CSV/JSON surfaces
2//! (0.33.0 decision-support Plan B Task 9).
3//!
4//! `come-ups`, `range-table`, `wind-card` and `compare` each grew their own
5//! function-local row struct (`ComeUpRow`/`RangeRow`/`WindRow`/`LoadRow`) that said the
6//! same handful of things -- range, drop, wind, velocity, energy, time -- a different way,
7//! which blocked any shared card machinery between them. `CardRow` is the display-ready
8//! superset: every surface populates only the fields it has ever had and leaves the rest
9//! `None` / empty, so each surface's existing table/CSV/JSON writer keeps reading the
10//! identical numbers it always did (pinned byte-identical by `tests/card_golden_cli.rs`).
11//!
12//! No feature gate: this module must compile for `wasm32-unknown-unknown` with no default
13//! features (pure data, no `fs`, no `clap`, no `pdf`). Task 10 rewrites the PDF dope card on
14//! `&[CardRow]`; Task 11 grows an adaptive-card engine in this module.
15
16/// One card row. Display-ready values in the surface's chosen units (exactly what the
17/// legacy per-surface structs stored), so rendering is unchanged; range is f64 metres-
18/// or-display per the surface's existing convention — DO NOT re-convert anything.
19///
20/// `Serialize` (0.33.0 decision-support Plan B Task 12) so `adaptive-card -o json` can
21/// pretty-print [`AdaptiveCardReportV1`] verbatim rather than hand-rebuilding a `json!`
22/// object the way the four Task 9 surfaces do -- purely additive: none of those four
23/// surfaces serializes a `CardRow` directly (each still builds its own `json!({..})` from
24/// named fields), so this creates no new wire surface for them. Deliberately NOT
25/// `Deserialize` -- nothing reads a `CardRow` back yet, and adding it speculatively is not
26/// this task's job.
27#[derive(Debug, Clone, Serialize)]
28pub struct CardRow {
29    pub range: f64,
30    pub drop_linear: Option<f64>,
31    pub drop_adj: Option<f64>,
32    pub come_up: Option<f64>,
33    pub wind_linear: Option<f64>,
34    pub wind_adj: Option<f64>,
35    pub velocity: Option<f64>,
36    pub energy: Option<f64>,
37    pub time: Option<f64>,
38    pub lead_adj: Option<f64>,
39    /// wind-card's per-speed drift columns; empty elsewhere.
40    pub wind_columns: Vec<f64>,
41}
42
43// ---------------------------------------------------------------------------
44// Adaptive range-card engine (0.33.0 decision-support Plan B Task 11, MBA-1351).
45//
46// `use` items sit here rather than at the top of the file so this task's addition is a
47// pure append -- Task 9's `CardRow` block above is untouched, and a parallel lane editing
48// it merges cleanly. Rust does not care where module-level items appear.
49// ---------------------------------------------------------------------------
50
51use crate::adjustment::{click_size_mil, quantize_angle, ClickBase, ClickValue};
52use crate::hold_curve::HoldCurve;
53use serde::Serialize;
54use std::fmt;
55
56/// Schema version of [`AdaptiveCardReportV1`]. Bump only for a breaking shape change.
57pub const ADAPTIVE_CARD_SCHEMA_VERSION_V1: u32 = 1;
58
59/// The reconstruction error a shooter is willing to accept, per axis, **in the card's
60/// printed adjustment unit** (mil for [`CardAdjustmentUnit::Mil`], the locked-3438 MOA for
61/// [`CardAdjustmentUnit::Moa`]). The caller converts; this engine never guesses a unit.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct AdaptiveBudget {
64    pub elevation: f64,
65    pub windage: f64,
66}
67
68/// Everything one adaptive card needs beyond the solved curve itself.
69///
70/// `elevation_cf` / `windage_cf` are the scope's tracking correction factors (MBA-1358).
71/// Both are VALIDATED here against [`crate::adjustment::tracking_cf_in_range`]'s locked
72/// `(0.5, 1.5)` band and rejected with [`CardError::InvalidTrackingCf`] -- this is a public
73/// library API that language bindings call without the CLI's own validation, and an
74/// out-of-band CF fails silently rather than loudly. Enforced as a hard bound here, unlike
75/// [`crate::optic::OpticError::NonPositiveTrackingFactor`]'s advisory-only band (see its own
76/// doc comment): the card engine enforces it because a large finite CF here would otherwise
77/// produce a confident `budget_met: true` on a card that does not actually meet its stated
78/// error budget, while the planner does not, because its residual stays honest under a wild
79/// CF. `bias_mil` is the selected zero set's elevation dial
80/// correction (MBA-1360) in true angular mil; it applies to the ELEVATION axis only, which
81/// is what "zero-set bias as a drop-equivalent" means.
82#[derive(Debug, Clone)]
83pub struct AdaptiveRequest<'a> {
84    /// `(start, end)` in meters. Both must be finite, positive, and increasing.
85    pub domain_m: (f64, f64),
86    /// Ranges that must appear as rows whatever the error says (a known dope point, a
87    /// target distance). Validated into the domain, never silently dropped.
88    pub anchors_m: Vec<f64>,
89    pub budget: AdaptiveBudget,
90    /// Upper bound on printed rows. The mandatory seed (both domain ends plus every anchor)
91    /// is never truncated to honour it -- see [`AdaptiveCardReportV1::rows_capped`].
92    pub max_rows: usize,
93    /// `(elevation, windage)` turret graduations. `Some` snaps every printed row onto the
94    /// detent lattice, which is what a shooter can actually dial; `None` prints the
95    /// unrounded angle.
96    pub click: Option<(&'a ClickValue, &'a ClickValue)>,
97    pub elevation_cf: f64,
98    pub windage_cf: f64,
99    pub bias_mil: f64,
100}
101
102/// The printed adjustment unit of a card's dial columns.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum CardAdjustmentUnit {
105    Mil,
106    Moa,
107}
108
109impl CardAdjustmentUnit {
110    /// Multiplier from the curve's true angular mil into this printed unit.
111    ///
112    /// `Moa` uses this crate's LOCKED printed-table constant `3438` (MBA-724), deliberately
113    /// not the exact-angle 3437.7467 -- every printed MOA column in the crate is drawn on
114    /// that ratio, and an adaptive card that measured its error on a different one would be
115    /// auditing numbers nobody prints.
116    pub fn from_mil_factor(&self) -> f64 {
117        match self {
118            Self::Mil => 1.0,
119            Self::Moa => 3438.0 / 1000.0,
120        }
121    }
122
123    /// The [`ClickBase`] this printed unit quantizes on, so the synthetic printed-space
124    /// graduation handed to [`quantize_angle`] is self-consistent rather than mislabelled.
125    fn click_base(&self) -> ClickBase {
126        match self {
127            Self::Mil => ClickBase::Mil,
128            Self::Moa => ClickBase::Moa,
129        }
130    }
131}
132
133/// Every way an [`adaptive_card`] request can be rejected. All six are structured: a range
134/// a shooter asked for and did not get back is information, never a silent drop.
135#[derive(Debug, Clone, Copy, PartialEq)]
136pub enum CardError {
137    /// The domain is not a forward, positive, finite interval. (Angular drop divides by the
138    /// range, so a start at or below zero has no angular value at all.)
139    EmptyOrInvertedDomain {
140        start_m: f64,
141        end_m: f64,
142    },
143    AnchorOutsideDomain {
144        anchor_m: f64,
145        start_m: f64,
146        end_m: f64,
147    },
148    NonPositiveBudget {
149        axis: &'static str,
150        value: f64,
151    },
152    ZeroMaxRows,
153    /// The domain runs past the last sampled point of the curve, where there is no ground
154    /// truth to verify against.
155    DomainOutsideCurve {
156        requested_m: f64,
157        curve_max_m: f64,
158    },
159    /// A tracking correction factor outside [`crate::adjustment::tracking_cf_in_range`]'s
160    /// locked `(0.5, 1.5)` band (MBA-1358).
161    ///
162    /// Checked rather than assumed because the failure is SILENT AND CONFIDENT, not loud: a
163    /// finite but out-of-band CF -- the realistic slip of passing the percentage `95` where
164    /// the ratio `0.95` belongs, or an `INFINITY` -- divides every printed value to ~0, so
165    /// every measured error is ~0 and the engine would hand back a two-row card of near-zero
166    /// dial values reporting `budget_met: true`. That is a wrong answer on the one field
167    /// this whole module exists to make trustworthy, and it is worse than the NaN a zero CF
168    /// produces, which at least reports `budget_met: false`.
169    InvalidTrackingCf {
170        axis: &'static str,
171        value: f64,
172    },
173}
174
175impl fmt::Display for CardError {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            Self::EmptyOrInvertedDomain { start_m, end_m } => write!(
179                f,
180                "card domain {start_m} m to {end_m} m must be a forward interval with a positive start"
181            ),
182            Self::AnchorOutsideDomain {
183                anchor_m,
184                start_m,
185                end_m,
186            } => write!(
187                f,
188                "anchor {anchor_m} m lies outside the card domain {start_m} m to {end_m} m"
189            ),
190            Self::NonPositiveBudget { axis, value } => {
191                write!(f, "{axis} budget {value} must be positive and finite")
192            }
193            Self::ZeroMaxRows => write!(f, "a card needs room for at least one row"),
194            Self::DomainOutsideCurve {
195                requested_m,
196                curve_max_m,
197            } => write!(
198                f,
199                "range {requested_m} m is past the curve's last sampled point at {curve_max_m} m"
200            ),
201            Self::InvalidTrackingCf { axis, value } => write!(
202                f,
203                "{axis} tracking correction factor {value} must be finite and between 0.5 and 1.5 \
204                 (it is a ratio such as 0.95, not a percentage)"
205            ),
206        }
207    }
208}
209
210impl std::error::Error for CardError {}
211
212/// What an adaptive card is, plus what its own numbers were measured to be worth.
213///
214/// The error fields are MEASURED by a dense verification pass over the declared grid after
215/// the rows are final -- they are not the loop's running estimate, and `budget_met` is
216/// recomputed from that pass rather than inherited from the insertion loop.
217///
218/// `Serialize` (Task 12): `adaptive-card -o json` prints this struct pretty-printed
219/// verbatim, field names unchanged -- unlike the four Task 9 surfaces, there is no legacy
220/// hand-built `json!` shape to preserve here, so a plain derive IS the wire format.
221#[derive(Debug, Clone, Serialize)]
222pub struct AdaptiveCardReportV1 {
223    pub schema_version: u32,
224    /// Stable identifier for how these rows were chosen.
225    pub method: String,
226    /// Exactly five entries; see [`adaptive_card`]. Index-stable: a consumer may quote
227    /// `assumptions[3]` and mean the half-click floor.
228    pub assumptions: Vec<String>,
229    /// Printed rows, ascending by range (meters). Dial values are quantized when clicks
230    /// were supplied.
231    pub rows: Vec<CardRow>,
232    pub budget_met: bool,
233    /// The row cap stopped refinement while violations remained.
234    pub rows_capped: bool,
235    /// Worst measured elevation error over the audited points, in the card's printed unit.
236    pub worst_elevation_error: f64,
237    /// Worst measured windage error over the audited points, in the card's printed unit.
238    pub worst_windage_error: f64,
239    /// Range of the single worst point by budget-normalized excess (ties to the lowest
240    /// range). The two per-axis maxima above may each occur elsewhere, which is exactly why
241    /// they are reported as their own scalars.
242    pub worst_error_range_m: f64,
243    /// Spacing of the declared verification grid, meters. The honesty claim extends to this
244    /// grid and no further.
245    pub verification_grid_step_m: f64,
246}
247
248/// One axis's printed-value pipeline, in the LOCKED composition order the CLI's
249/// `adjustment_display` boundary already uses (MBA-1360 x MBA-1358): the zero-set bias joins
250/// the TRUE angular need first, the tracking correction divides second, click quantization
251/// happens last on the corrected value. The order is load-bearing and must not move.
252#[derive(Debug, Clone, Copy)]
253struct PrintedAxis {
254    unit_factor: f64,
255    bias_mil: f64,
256    cf: f64,
257    /// The detent graduation expressed in the PRINTED unit, so quantization and the error
258    /// metric live in the same space. `None` prints the unrounded angle.
259    click: Option<ClickValue>,
260}
261
262impl PrintedAxis {
263    fn new(unit: CardAdjustmentUnit, bias_mil: f64, cf: f64, click: Option<&ClickValue>) -> Self {
264        let unit_factor = unit.from_mil_factor();
265        Self {
266            unit_factor,
267            bias_mil,
268            cf,
269            // click_size_mil is the crate's one click -> mil converter (locked 3438 for MOA);
270            // scaling its result by the printed unit factor lands the graduation in printed
271            // space without a second, divergent conversion table.
272            click: click.map(|c| ClickValue {
273                size: click_size_mil(c) * unit_factor,
274                base: unit.click_base(),
275            }),
276        }
277    }
278
279    /// The unquantized printed value -- what the card WOULD say with infinite dial
280    /// resolution. This is the ground truth every error below is measured against.
281    fn exact(&self, true_mil: f64) -> f64 {
282        let printed = true_mil * self.unit_factor;
283        // Skipping a zero bias is bit-exact on purpose: an unconditional `+ 0.0` flips a
284        // -0.0 to +0.0 and changes rendered bytes (the same rule `adjustment_display` keeps).
285        let biased = if self.bias_mil != 0.0 {
286            printed + self.bias_mil * self.unit_factor
287        } else {
288            printed
289        };
290        biased / self.cf
291    }
292
293    /// The value actually PRINTED on the row: [`Self::exact`] snapped to the detent lattice
294    /// when the optic's clicks are known.
295    fn printed(&self, true_mil: f64) -> f64 {
296        let exact = self.exact(true_mil);
297        match &self.click {
298            Some(c) => quantize_angle(exact, c).clicks as f64 * c.size,
299            None => exact,
300        }
301    }
302}
303
304/// One audited range: the exact printed values, the values the card prints there, and the
305/// display extras a [`CardRow`] wants. Precomputed once -- the curve is never re-queried
306/// inside the insertion loop, which keeps each pass O(points) and makes the loop's result
307/// independent of how many passes it takes.
308#[derive(Debug, Clone, Copy)]
309struct AuditPoint {
310    range_m: f64,
311    exact_elevation: f64,
312    exact_windage: f64,
313    printed_elevation: f64,
314    printed_windage: f64,
315    drop_linear_m: f64,
316    wind_linear_m: f64,
317    velocity_mps: f64,
318    energy_j: f64,
319    time_s: f64,
320}
321
322/// Per-axis absolute reconstruction error at one audited point.
323type AxisErrors = (f64, f64);
324
325/// Sort ascending and drop exact duplicates. `total_cmp` gives a total order with no
326/// comparator-contract hazard; NaN cannot reach here (validation rejects it).
327fn sorted_dedup(mut values: Vec<f64>) -> Vec<f64> {
328    values.sort_by(f64::total_cmp);
329    values.dedup();
330    values
331}
332
333/// The hold curve's own sample points inside `[start_m, end_m]`.
334///
335/// [`HoldCurve`] samples at exact multiples of [`HoldCurve::SAMPLE_INTERVAL_M`] (the sampler
336/// builds its distance list as `i as f64 * step`), so recomputing that arithmetic sequence
337/// reproduces the native grid bit-for-bit without needing access to the curve's private
338/// sample vector -- pinned by `verification_grid_lands_on_the_curves_native_samples`.
339fn native_grid_m(start_m: f64, end_m: f64) -> Vec<f64> {
340    let step = HoldCurve::SAMPLE_INTERVAL_M;
341    // Index 0 is the muzzle, where an angular hold is undefined; start at 1.
342    let first = ((start_m / step).ceil() as i64).max(1);
343    let last = (end_m / step).floor() as i64;
344    let mut grid = Vec::new();
345    for i in first..=last {
346        let g = i as f64 * step;
347        // Re-check the bounds rather than trusting ceil/floor at the endpoints.
348        if g >= start_m && g <= end_m {
349            grid.push(g);
350        }
351    }
352    grid
353}
354
355/// Reconstruct the printed card at every audited point and measure the per-axis error.
356///
357/// At a row the reconstruction IS the row's printed value, so the error there is the
358/// quantization residual -- not zero. That is deliberate: hiding it would hide the
359/// half-click floor, the one error extra rows cannot fix.
360fn sweep(audit: &[AuditPoint], rows: &[usize]) -> Vec<AxisErrors> {
361    let mut errors = vec![(0.0, 0.0); audit.len()];
362    for &r in rows {
363        let p = &audit[r];
364        errors[r] = (
365            (p.printed_elevation - p.exact_elevation).abs(),
366            (p.printed_windage - p.exact_windage).abs(),
367        );
368    }
369    for pair in rows.windows(2) {
370        let (lo, hi) = (pair[0], pair[1]);
371        let (a, b) = (&audit[lo], &audit[hi]);
372        let span = b.range_m - a.range_m;
373        for (k, point) in audit.iter().enumerate().take(hi).skip(lo + 1) {
374            let t = if span > 0.0 {
375                (point.range_m - a.range_m) / span
376            } else {
377                0.0
378            };
379            let elevation = a.printed_elevation + (b.printed_elevation - a.printed_elevation) * t;
380            let windage = a.printed_windage + (b.printed_windage - a.printed_windage) * t;
381            errors[k] = (
382                (elevation - point.exact_elevation).abs(),
383                (windage - point.exact_windage).abs(),
384            );
385        }
386    }
387    errors
388}
389
390/// Loop instrumentation, for the termination tests only. Not part of the public report:
391/// how many passes the search took is an implementation detail, but it is the ONE
392/// observable that distinguishes "stopped because the error is irreducible" from "spun
393/// until the runaway backstop caught it".
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395struct LoopTrace {
396    iterations: usize,
397    iteration_cap: usize,
398}
399
400/// Build an adaptive range card and measure what it is worth.
401///
402/// Greedy worst-point insertion: start from the domain ends plus every anchor, then
403/// repeatedly add the single audited point whose linearly-interpolated printed value is
404/// furthest outside budget, until nothing violates, the row cap binds, or the remaining
405/// error is irreducible. A SEPARATE dense pass over the declared grid then measures the
406/// finished card, and `budget_met` comes from that measurement.
407///
408/// Everything is measured in printed-value space -- the same zero-set bias, tracking
409/// correction and click quantization the rows carry -- so the reported error is the error a
410/// shooter interpolating the printed card actually makes.
411///
412/// # What this does and does not buy you
413///
414/// It buys a MEASURED error bound, guaranteed anchors, and no step to guess. It does not
415/// reliably buy a shorter card than a well-chosen fixed step: a single insertion can at
416/// best quarter an interval's error (a bisection), so on a trajectory whose curvature
417/// barely varies the whole card doubles at once while a uniform card may pick any row
418/// count. See `fixed_step_comparison_is_measured_not_assumed` for the measurements.
419///
420/// # Errors
421///
422/// Returns [`CardError`] for an inverted or non-positive domain, an anchor outside it, a
423/// non-positive budget, a zero row cap, a domain running past the curve's last sample, or a
424/// tracking correction factor outside the locked `(0.5, 1.5)` band. Every one of these is
425/// checked before any work is done, on every build profile -- a caller cannot reach the
426/// solver with a request that would produce a confidently wrong card.
427pub fn adaptive_card(
428    curve: &HoldCurve,
429    req: &AdaptiveRequest,
430    unit: CardAdjustmentUnit,
431) -> Result<AdaptiveCardReportV1, CardError> {
432    adaptive_card_traced(curve, req, unit).map(|(report, _)| report)
433}
434
435/// [`adaptive_card`] plus its loop trace. Private: see [`LoopTrace`].
436fn adaptive_card_traced(
437    curve: &HoldCurve,
438    req: &AdaptiveRequest,
439    unit: CardAdjustmentUnit,
440) -> Result<(AdaptiveCardReportV1, LoopTrace), CardError> {
441    let (start_m, end_m) = req.domain_m;
442
443    if req.max_rows == 0 {
444        return Err(CardError::ZeroMaxRows);
445    }
446    for (axis, value) in [
447        ("elevation", req.budget.elevation),
448        ("windage", req.budget.windage),
449    ] {
450        if !value.is_finite() || value <= 0.0 {
451            return Err(CardError::NonPositiveBudget { axis, value });
452        }
453    }
454    // Enforced, not assumed: an out-of-band CF silently produces a confident, wrong card
455    // (see `CardError::InvalidTrackingCf`), and a `debug_assert` is compiled out of exactly
456    // the builds that ship. `tracking_cf_in_range` is the crate's ONE locked band (MBA-1358),
457    // shared with the CLI and the WASM terminal -- reused here, never restated as a literal.
458    for (axis, value) in [("elevation", req.elevation_cf), ("windage", req.windage_cf)] {
459        if !crate::adjustment::tracking_cf_in_range(value) {
460            return Err(CardError::InvalidTrackingCf { axis, value });
461        }
462    }
463    if !start_m.is_finite() || !end_m.is_finite() || start_m <= 0.0 || end_m <= start_m {
464        return Err(CardError::EmptyOrInvertedDomain { start_m, end_m });
465    }
466    let curve_max_m = curve.max_sampled_range_m();
467    if end_m > curve_max_m {
468        return Err(CardError::DomainOutsideCurve {
469            requested_m: end_m,
470            curve_max_m,
471        });
472    }
473    for &anchor_m in &req.anchors_m {
474        if !anchor_m.is_finite() || anchor_m < start_m || anchor_m > end_m {
475            return Err(CardError::AnchorOutsideDomain {
476                anchor_m,
477                start_m,
478                end_m,
479            });
480        }
481    }
482    debug_assert!(req.bias_mil.is_finite(), "zero-set bias must be finite");
483
484    let (elevation_click, windage_click) = match req.click {
485        Some((e, w)) => (Some(e), Some(w)),
486        None => (None, None),
487    };
488    let elevation = PrintedAxis::new(unit, req.bias_mil, req.elevation_cf, elevation_click);
489    // The zero-set bias is an elevation-only dial correction; windage carries its own
490    // tracking correction but no bias on this interface.
491    let windage = PrintedAxis::new(unit, 0.0, req.windage_cf, windage_click);
492
493    // Audited points = the curve's native grid inside the domain, UNION the mandatory rows.
494    // The union matters: a domain end or an anchor need not land on the grid, and a
495    // quantized row carries error at its own range, so leaving one unaudited would under-
496    // report the very floor `assumptions[3]` warns about. Every point the loop may insert
497    // therefore already lives in this fixed, precomputed set.
498    let mut seeds = vec![start_m, end_m];
499    seeds.extend_from_slice(&req.anchors_m);
500    let mut ranges = native_grid_m(start_m, end_m);
501    ranges.extend_from_slice(&seeds);
502    let ranges = sorted_dedup(ranges);
503
504    let mut audit = Vec::with_capacity(ranges.len());
505    for range_m in ranges {
506        let point = curve
507            .at_range(range_m)
508            .ok_or(CardError::DomainOutsideCurve {
509                requested_m: range_m,
510                curve_max_m,
511            })?;
512        audit.push(AuditPoint {
513            range_m,
514            exact_elevation: elevation.exact(point.drop_mil),
515            exact_windage: windage.exact(point.wind_mil),
516            printed_elevation: elevation.printed(point.drop_mil),
517            printed_windage: windage.printed(point.wind_mil),
518            drop_linear_m: point.drop_mil / 1000.0 * range_m,
519            wind_linear_m: point.wind_mil / 1000.0 * range_m,
520            velocity_mps: point.velocity_mps,
521            energy_j: point.energy_j,
522            time_s: point.time_s,
523        });
524    }
525
526    // Seed rows: both domain ends plus every anchor. `audit` is sorted and contains each of
527    // them, so a partition point gives the seed indices in ascending order.
528    let mut rows: Vec<usize> = sorted_dedup(seeds)
529        .iter()
530        .map(|target| {
531            audit
532                .partition_point(|p| p.range_m < *target)
533                .min(audit.len() - 1)
534        })
535        .collect();
536    rows.dedup();
537    let mut is_row = vec![false; audit.len()];
538    for &r in &rows {
539        is_row[r] = true;
540    }
541    debug_assert_eq!(rows.first(), Some(&0), "the domain start must seed row 0");
542    debug_assert_eq!(
543        rows.last(),
544        Some(&(audit.len() - 1)),
545        "the domain end must seed the last row"
546    );
547
548    // Runaway backstop, NOT the termination argument. Every iteration either stops or
549    // inserts an audited point that was not already a row, so at most `audit.len()`
550    // insertions are possible and the irreducible-error stop below must fire first; this
551    // cap only bounds the damage if that reasoning is ever broken by a later edit.
552    let iteration_cap = 2 * audit.len() + 8;
553    let mut iterations = 0usize;
554    let mut rows_capped = false;
555
556    while iterations < iteration_cap {
557        iterations += 1;
558        let errors = sweep(&audit, &rows);
559
560        let mut any_violation = false;
561        let mut worst: Option<(f64, usize)> = None;
562        for (k, &(elevation_error, windage_error)) in errors.iter().enumerate() {
563            if elevation_error <= req.budget.elevation && windage_error <= req.budget.windage {
564                continue;
565            }
566            any_violation = true;
567            if is_row[k] {
568                continue;
569            }
570            // Two axes with different budgets reduce to one comparable number by
571            // budget-normalized excess. `audit` is ascending and the comparison is strict,
572            // so ties keep the lowest range.
573            let excess =
574                (elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
575            if worst.is_none_or(|(best, _)| excess > best) {
576                worst = Some((excess, k));
577            }
578        }
579
580        if !any_violation {
581            break;
582        }
583        // IRREDUCIBLE-ERROR STOP. Violations remain, but every one of them is AT a row, so
584        // there is nothing left to insert -- the residue is the quantization floor, not a
585        // shortage of rows. Without this arm the loop re-measures the same state forever;
586        // deleting it makes `quantization_floor_is_honest_and_terminates` run to the runaway
587        // backstop (50 iterations for 21 audited points) and fail, which is how that test
588        // earns its keep.
589        let Some((_, insert_at)) = worst else {
590            break;
591        };
592        if rows.len() >= req.max_rows {
593            rows_capped = true;
594            break;
595        }
596        rows.insert(rows.partition_point(|&r| r < insert_at), insert_at);
597        is_row[insert_at] = true;
598    }
599
600    // Dense verification pass -- deliberately SEPARATE from the loop above. It re-measures
601    // the finished card from scratch; nothing about `budget_met` is inherited from the
602    // search, which is what stops a loop that ended early (capped or irreducible) from
603    // claiming a tolerance it never reached.
604    let final_errors = sweep(&audit, &rows);
605    let mut worst_elevation_error = 0.0_f64;
606    let mut worst_windage_error = 0.0_f64;
607    let mut worst_excess = f64::NEG_INFINITY;
608    let mut worst_error_range_m = audit[0].range_m;
609    for (k, &(elevation_error, windage_error)) in final_errors.iter().enumerate() {
610        worst_elevation_error = worst_elevation_error.max(elevation_error);
611        worst_windage_error = worst_windage_error.max(windage_error);
612        let excess =
613            (elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
614        if excess > worst_excess {
615            worst_excess = excess;
616            worst_error_range_m = audit[k].range_m;
617        }
618    }
619    let budget_met =
620        worst_elevation_error <= req.budget.elevation && worst_windage_error <= req.budget.windage;
621
622    // (T11 review fix: this used to carry a `debug_assert!(half_click_floor() >= 0.0, ...)`,
623    // where `half_click_floor` was `PrintedAxis::half_click_floor(&self) -> f64`, `self.click
624    // .map_or(0.0, |c| c.size / 2.0)`. That was checking a property already guaranteed by
625    // construction wherever a `ClickValue` reaches this code path -- both CLI parsing and
626    // profile loading reject a non-positive click size before it gets this far -- not the
627    // `assumptions[3]` floor invariant the comment claimed to be checking. Removed (along with
628    // the now-unused `half_click_floor` method) rather than replaced with an assertion this
629    // function cannot actually verify: whether a given run's worst-case error DOES reach the
630    // floor depends on where the mandatory rows happen to fall in the click phase, not on
631    // anything checkable here.)
632
633    let card_rows = rows
634        .iter()
635        .map(|&r| {
636            let p = &audit[r];
637            CardRow {
638                range: p.range_m,
639                drop_linear: Some(p.drop_linear_m),
640                drop_adj: Some(p.printed_elevation),
641                come_up: None,
642                wind_linear: Some(p.wind_linear_m),
643                wind_adj: Some(p.printed_windage),
644                velocity: Some(p.velocity_mps),
645                energy: Some(p.energy_j),
646                time: Some(p.time_s),
647                lead_adj: None,
648                wind_columns: Vec::new(),
649            }
650        })
651        .collect();
652
653    let report = AdaptiveCardReportV1 {
654        schema_version: ADAPTIVE_CARD_SCHEMA_VERSION_V1,
655        method: "greedy_worst_point_insertion_on_holdcurve_grid_v1".to_string(),
656        assumptions: adaptive_card_assumptions(),
657        rows: card_rows,
658        budget_met,
659        rows_capped,
660        worst_elevation_error,
661        worst_windage_error,
662        worst_error_range_m,
663        verification_grid_step_m: HoldCurve::SAMPLE_INTERVAL_M,
664    };
665    Ok((
666        report,
667        LoopTrace {
668            iterations,
669            iteration_cap,
670        },
671    ))
672}
673
674/// The five index-stable claims [`AdaptiveCardReportV1`] ships with. Written once, here, so
675/// the report and its pinning test cannot drift apart by editing only one of them.
676fn adaptive_card_assumptions() -> Vec<String> {
677    [
678        "Verification is limited to the hold curve's declared sample grid (verification_grid_step_m) together with the card's own rows; no claim is made about ranges between those audited points.",
679        "The reader of this card interpolates linearly between adjacent rows.",
680        "Errors are measured in printed-value space -- the same zero-set bias, tracking-correction division and click quantization the printed rows carry -- so a constant zero-set bias cancels out of the interpolation error and the tracking correction factor is already inside the numbers being compared.",
681        "Rows quantized to an optic's clicks carry an irreducible error of up to half a click at the rows themselves, which no number of added rows can remove.",
682        "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed.",
683    ]
684    .iter()
685    .map(|s| (*s).to_string())
686    .collect()
687}
688
689#[cfg(test)]
690mod adaptive_card_tests {
691    use super::*;
692    use crate::hold_curve::HoldCurveLoad;
693    use crate::DragModel;
694
695    /// The same representative .308-class load `hold_curve`'s own tests use, so a curve
696    /// difference between the two modules cannot masquerade as a card-engine difference.
697    fn test_load() -> HoldCurveLoad {
698        HoldCurveLoad {
699            velocity_mps: 800.0,
700            bc: 0.223,
701            mass_kg: 0.0109,
702            diameter_m: 0.00782,
703            drag_model: DragModel::G7,
704            sight_height_m: 0.045,
705            zero_distance_m: 100.0,
706            temperature_c: 15.0,
707            pressure_hpa: 1013.25,
708            humidity: 50.0,
709            altitude_m: 0.0,
710            wind_speed_mps: 3.0,
711            wind_direction_deg: 90.0,
712        }
713    }
714
715    fn test_curve(max_range_m: f64) -> HoldCurve {
716        HoldCurve::solve(&test_load(), max_range_m).expect("hold curve should solve")
717    }
718
719    /// An unbiased, uncorrected, unquantized request: in `Mil` the printed value is then
720    /// exactly the curve's `drop_mil` / `wind_mil`, which keeps the independent checks below
721    /// free of any conversion the engine could also get wrong.
722    fn plain_request(
723        domain_m: (f64, f64),
724        budget: f64,
725        max_rows: usize,
726    ) -> AdaptiveRequest<'static> {
727        AdaptiveRequest {
728            domain_m,
729            anchors_m: Vec::new(),
730            budget: AdaptiveBudget {
731                elevation: budget,
732                windage: budget,
733            },
734            max_rows,
735            click: None,
736            elevation_cf: 1.0,
737            windage_cf: 1.0,
738            bias_mil: 0.0,
739        }
740    }
741
742    /// The TEST's own audited set -- `(range_m, drop_mil, wind_mil)` at the curve's native
743    /// grid inside the domain plus the two domain ends. Written out longhand rather than
744    /// calling `native_grid_m`, so a bug in the engine's grid construction cannot hide
745    /// behind the check that uses it.
746    fn independent_audited(curve: &HoldCurve, start_m: f64, end_m: f64) -> Vec<(f64, f64, f64)> {
747        let step = HoldCurve::SAMPLE_INTERVAL_M;
748        let mut ranges = vec![start_m];
749        let mut i = 1_i64;
750        loop {
751            let g = i as f64 * step;
752            if g > end_m {
753                break;
754            }
755            if g > start_m {
756                ranges.push(g);
757            }
758            i += 1;
759        }
760        if ranges.last().is_none_or(|&last| last < end_m) {
761            ranges.push(end_m);
762        }
763        ranges
764            .into_iter()
765            .map(|g| {
766                let p = curve
767                    .at_range(g)
768                    .expect("audited range must be on the curve");
769                (g, p.drop_mil, p.wind_mil)
770            })
771            .collect()
772    }
773
774    /// The TEST's own reconstruction check: its own bracket search and hand-written lerp,
775    /// deliberately NOT the engine's `sweep`. Returns the worst (elevation, windage)
776    /// absolute error over `audited`.
777    fn independent_worst_error(
778        audited: &[(f64, f64, f64)],
779        row_ranges: &[f64],
780        row_elevation: &[f64],
781        row_windage: &[f64],
782    ) -> (f64, f64) {
783        let mut worst = (0.0_f64, 0.0_f64);
784        for &(g, drop_mil, wind_mil) in audited {
785            // The last row at or below `g`, clamped so the final row still brackets.
786            let lo = row_ranges
787                .partition_point(|&r| r <= g)
788                .saturating_sub(1)
789                .min(row_ranges.len() - 2);
790            let hi = lo + 1;
791            let span = row_ranges[hi] - row_ranges[lo];
792            let t = if span > 0.0 {
793                (g - row_ranges[lo]) / span
794            } else {
795                0.0
796            };
797            let elevation = row_elevation[lo] + (row_elevation[hi] - row_elevation[lo]) * t;
798            let windage = row_windage[lo] + (row_windage[hi] - row_windage[lo]) * t;
799            worst.0 = worst.0.max((elevation - drop_mil).abs());
800            worst.1 = worst.1.max((windage - wind_mil).abs());
801        }
802        worst
803    }
804
805    /// Smallest evenly-spaced card meeting `budget` on the same audited points, found by
806    /// trial. `None` if no step up to `max_n` rows manages it.
807    fn smallest_uniform_card(
808        curve: &HoldCurve,
809        audited: &[(f64, f64, f64)],
810        start_m: f64,
811        end_m: f64,
812        budget: f64,
813        max_n: usize,
814    ) -> Option<usize> {
815        for n in 2..=max_n {
816            let ranges: Vec<f64> = (0..n)
817                .map(|i| start_m + (end_m - start_m) * i as f64 / (n - 1) as f64)
818                .collect();
819            let points: Vec<_> = ranges
820                .iter()
821                .map(|&r| curve.at_range(r).expect("uniform row on the curve"))
822                .collect();
823            let elevation: Vec<f64> = points.iter().map(|p| p.drop_mil).collect();
824            let windage: Vec<f64> = points.iter().map(|p| p.wind_mil).collect();
825            let (worst_elevation, worst_windage) =
826                independent_worst_error(audited, &ranges, &elevation, &windage);
827            if worst_elevation <= budget && worst_windage <= budget {
828                return Some(n);
829            }
830        }
831        None
832    }
833
834    fn row_columns(report: &AdaptiveCardReportV1) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
835        (
836            report.rows.iter().map(|r| r.range).collect(),
837            report
838                .rows
839                .iter()
840                .map(|r| r.drop_adj.expect("adaptive rows always carry a dial value"))
841                .collect(),
842            report
843                .rows
844                .iter()
845                .map(|r| r.wind_adj.expect("adaptive rows always carry a dial value"))
846                .collect(),
847        )
848    }
849
850    /// Spec 8.2 acceptance: a card that claims a met budget must survive an audit written
851    /// by someone other than the engine. Every audited point is re-measured here with the
852    /// test's own grid, bracket search and lerp.
853    #[test]
854    fn verification_pass_confirms_every_audited_point_within_bounds() {
855        let curve = test_curve(900.0);
856        let budget = 0.1;
857        let req = plain_request((200.0, 800.0), budget, 500);
858        let report =
859            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
860
861        assert!(
862            report.budget_met,
863            "0.1 mil over 200-800 m should be reachable"
864        );
865        assert!(!report.rows_capped);
866        assert_eq!(report.schema_version, ADAPTIVE_CARD_SCHEMA_VERSION_V1);
867        assert_eq!(
868            report.verification_grid_step_m,
869            HoldCurve::SAMPLE_INTERVAL_M
870        );
871
872        let (ranges, elevation, windage) = row_columns(&report);
873        let audited = independent_audited(&curve, 200.0, 800.0);
874        let (worst_elevation, worst_windage) =
875            independent_worst_error(&audited, &ranges, &elevation, &windage);
876
877        assert!(
878            worst_elevation <= budget,
879            "independent audit found {worst_elevation} mil of elevation error, budget {budget}"
880        );
881        assert!(
882            worst_windage <= budget,
883            "independent audit found {worst_windage} mil of windage error, budget {budget}"
884        );
885        // The engine's own measurement must not be optimistic relative to the independent one.
886        assert!(report.worst_elevation_error >= worst_elevation - 1e-12);
887        assert!(report.worst_windage_error >= worst_windage - 1e-12);
888    }
889
890    /// A tighter tolerance can never buy a shorter card. Swept across four budgets rather
891    /// than compared at two points, so a non-monotone middle cannot slip through.
892    #[test]
893    fn tightening_the_budget_never_decreases_row_count() {
894        let curve = test_curve(900.0);
895        let counts: Vec<usize> = [0.4, 0.2, 0.1, 0.05]
896            .iter()
897            .map(|&budget| {
898                let req = plain_request((200.0, 800.0), budget, 800);
899                let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
900                    .expect("card should build");
901                assert!(
902                    report.budget_met,
903                    "{budget} mil should be reachable unquantized"
904                );
905                report.rows.len()
906            })
907            .collect();
908
909        for pair in counts.windows(2) {
910            assert!(
911                pair[1] >= pair[0],
912                "row counts must not decrease as the budget tightens: {counts:?}"
913            );
914        }
915        assert!(
916            counts[3] > counts[0],
917            "a 8x tighter budget should cost rows: {counts:?}"
918        );
919    }
920
921    /// The search really does adapt: rows bunch up where the curve bends. Measured as the
922    /// mean row spacing over the far half of the card against the near half, which is the
923    /// property that survives whatever the total row count turns out to be.
924    #[test]
925    fn adaptive_rows_concentrate_where_the_curve_bends() {
926        let curve = test_curve(900.0);
927        for budget in [0.1, 0.05, 0.02] {
928            let req = plain_request((200.0, 800.0), budget, 800);
929            let report =
930                adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
931            assert!(report.budget_met, "{budget} mil should be reachable");
932            assert!(report.rows.len() >= 4, "need enough rows to halve");
933
934            let gaps: Vec<f64> = report
935                .rows
936                .windows(2)
937                .map(|p| p[1].range - p[0].range)
938                .collect();
939            let half = gaps.len() / 2;
940            let near: f64 = gaps[..half].iter().sum::<f64>() / half as f64;
941            let far: f64 = gaps[gaps.len() - half..].iter().sum::<f64>() / half as f64;
942            assert!(
943                far < near,
944                "budget {budget}: far-half spacing {far:.1} m is not tighter than near-half \
945                 {near:.1} m -- the card is not adapting, gaps {gaps:?}"
946            );
947        }
948    }
949
950    /// The fixed-step comparison, MEASURED rather than assumed -- and it does not come out
951    /// the way the task brief predicted.
952    ///
953    /// The brief specified this as `smooth_trajectory_beats_fixed_step`: adaptive at 0.1 mil
954    /// on 200-800 m should need fewer rows than the smallest uniform card meeting the same
955    /// budget. It does not. Measured over five domains x four budgets, greedy worst-point
956    /// insertion lost 10, tied 5 and won 5, and its row counts cluster on 5 / 9 / 17.
957    ///
958    /// That is a property of the pinned algorithm, not a defect in it. One insertion splits
959    /// an interval into parts of length `l*h` and `(1-l)*h` carrying `l^2` and `(1-l)^2` of
960    /// the old error, so the best any single insertion can do to an interval's error is
961    /// divide it by four -- a bisection. When a trajectory's curvature barely varies (over
962    /// 200-800 m this load's does so by under 2x, which is exactly what "smooth" means),
963    /// every interval needs refining at once and the card doubles, while a uniform card is
964    /// free to pick any row count at all. Adaptive placement wins where curvature varies
965    /// sharply; on a smooth mid-range trajectory its value is the MEASURED error bound, the
966    /// anchors and not having to guess a step -- not a shorter card.
967    ///
968    /// Two assertions, bounding the finding from BOTH sides, because a one-sided bound is
969    /// not a pin. The upper bound is a regression backstop (bisection granularity must never
970    /// cost more than a doubling). The directional one is the finding itself: adaptive does
971    /// not meaningfully beat uniform here. Neither pins exact arithmetic a physics change
972    /// would move -- the `+ 1` slack keeps a ULP-level shift at the current 5-vs-5 tie from
973    /// firing spuriously -- but if adaptive ever genuinely wins, the directional assertion
974    /// fails, and that is the signal that this comment, the public `adaptive_card` docs and
975    /// the product guidance built on them have all gone stale.
976    #[test]
977    fn fixed_step_comparison_is_measured_not_assumed() {
978        let curve = test_curve(900.0);
979        let (start_m, end_m, budget) = (200.0, 800.0, 0.1);
980
981        let req = plain_request((start_m, end_m), budget, 800);
982        let report =
983            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
984        assert!(report.budget_met);
985        let adaptive_rows = report.rows.len();
986
987        // Smallest uniform card meeting the same budget on the same audited points, found
988        // by trial in the test -- the most generous possible fixed-step baseline.
989        let audited = independent_audited(&curve, start_m, end_m);
990        let uniform_rows = smallest_uniform_card(&curve, &audited, start_m, end_m, budget, 400)
991            .expect("some uniform step must meet the budget");
992
993        assert!(
994            adaptive_rows <= 2 * uniform_rows,
995            "adaptive used {adaptive_rows} rows against a {uniform_rows}-row uniform card; \
996             bisection granularity should never cost more than a doubling"
997        );
998        assert!(
999            adaptive_rows + 1 >= uniform_rows,
1000            "adaptive ({adaptive_rows} rows) now beats uniform ({uniform_rows} rows) at the \
1001             brief's own parameters -- the insertion rule has been improved, so the finding in \
1002             this test's doc comment, the \"not a shorter card\" disclosure on `adaptive_card`, \
1003             and the product guidance built on it are ALL stale and must be revisited"
1004        );
1005    }
1006
1007    /// Anchors are promises, not suggestions -- and the same request must produce the same
1008    /// card every time (no RNG, no hash-order dependence anywhere in the search).
1009    #[test]
1010    fn anchors_always_present_and_determinism() {
1011        let curve = test_curve(900.0);
1012        let anchors = vec![300.0, 512.5, 777.0];
1013        let mut req = plain_request((200.0, 800.0), 0.15, 500);
1014        req.anchors_m.clone_from(&anchors);
1015
1016        let first =
1017            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1018        let second =
1019            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1020
1021        for anchor in &anchors {
1022            assert!(
1023                first.rows.iter().any(|row| row.range == *anchor),
1024                "anchor {anchor} m is missing from the card"
1025            );
1026        }
1027        // Both domain ends are rows too.
1028        assert_eq!(first.rows.first().map(|r| r.range), Some(200.0));
1029        assert_eq!(first.rows.last().map(|r| r.range), Some(800.0));
1030
1031        assert_eq!(first.method, second.method);
1032        assert_eq!(first.assumptions, second.assumptions);
1033        assert_eq!(first.budget_met, second.budget_met);
1034        assert_eq!(first.rows_capped, second.rows_capped);
1035        assert_eq!(first.rows.len(), second.rows.len());
1036        // Bit-for-bit, not approximately: determinism means identical, not close.
1037        assert_eq!(
1038            first.worst_elevation_error.to_bits(),
1039            second.worst_elevation_error.to_bits()
1040        );
1041        assert_eq!(
1042            first.worst_windage_error.to_bits(),
1043            second.worst_windage_error.to_bits()
1044        );
1045        assert_eq!(
1046            first.worst_error_range_m.to_bits(),
1047            second.worst_error_range_m.to_bits()
1048        );
1049        for (a, b) in first.rows.iter().zip(second.rows.iter()) {
1050            assert_eq!(a.range.to_bits(), b.range.to_bits());
1051            assert_eq!(a.drop_adj.map(f64::to_bits), b.drop_adj.map(f64::to_bits));
1052            assert_eq!(a.wind_adj.map(f64::to_bits), b.wind_adj.map(f64::to_bits));
1053        }
1054    }
1055
1056    /// A budget below the half-click floor cannot be met by adding rows, and the search
1057    /// must SAY so rather than grind. The iteration assertion is the fault-injection probe:
1058    /// with the irreducible-error stop removed the loop stops making progress and runs to
1059    /// the runaway backstop, blowing this bound.
1060    #[test]
1061    fn quantization_floor_is_honest_and_terminates() {
1062        let curve = test_curve(900.0);
1063        let step = HoldCurve::SAMPLE_INTERVAL_M;
1064        // Domain ends chosen ON the native grid so the audited set is exactly 21 points,
1065        // whichever way the ceil/floor rounds at the endpoints.
1066        let (start_m, end_m) = (330.0 * step, 350.0 * step);
1067        let audited_points = 21usize;
1068
1069        let click = ClickValue {
1070            size: 0.1,
1071            base: ClickBase::Mil,
1072        };
1073        let half_click = 0.05;
1074        let budget = 0.001;
1075        let req = AdaptiveRequest {
1076            domain_m: (start_m, end_m),
1077            anchors_m: Vec::new(),
1078            budget: AdaptiveBudget {
1079                elevation: budget,
1080                windage: budget,
1081            },
1082            max_rows: 500, // deliberately not binding: the stop must come from the floor
1083            click: Some((&click, &click)),
1084            elevation_cf: 1.0,
1085            windage_cf: 1.0,
1086            bias_mil: 0.0,
1087        };
1088
1089        let (report, trace) =
1090            adaptive_card_traced(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1091
1092        // Termination, stated as a bound rather than demonstrated by hanging: every
1093        // iteration inserts a distinct audited point or stops, so one pass per point plus
1094        // the final deciding pass is the most the search can legitimately take.
1095        assert!(
1096            trace.iterations <= audited_points + 1,
1097            "search took {} iterations for {audited_points} audited points (cap {}) -- \
1098             the irreducible-error stop is not firing",
1099            trace.iterations,
1100            trace.iteration_cap
1101        );
1102        assert!(
1103            trace.iterations < trace.iteration_cap,
1104            "the runaway backstop, not the irreducible-error stop, ended the search"
1105        );
1106
1107        assert!(!report.budget_met, "0.001 mil is under the 0.05 mil floor");
1108        assert!(
1109            !report.rows_capped,
1110            "the row cap was not binding; the stop must be attributed to the floor"
1111        );
1112        assert!(report.rows.len() <= audited_points);
1113
1114        // The reported worst error IS the measured half-click residue: recomputed here from
1115        // the printed rows alone, with no help from the engine.
1116        let mut row_worst = (0.0_f64, 0.0_f64);
1117        for row in &report.rows {
1118            let point = curve.at_range(row.range).expect("row on the curve");
1119            row_worst.0 = row_worst
1120                .0
1121                .max((row.drop_adj.expect("dial") - point.drop_mil).abs());
1122            row_worst.1 = row_worst
1123                .1
1124                .max((row.wind_adj.expect("dial") - point.wind_mil).abs());
1125        }
1126        assert!(
1127            (report.worst_elevation_error - row_worst.0).abs() < 1e-12,
1128            "worst elevation {} vs independently measured row residue {}",
1129            report.worst_elevation_error,
1130            row_worst.0
1131        );
1132        assert!(report.worst_elevation_error > budget);
1133        assert!(
1134            report.worst_elevation_error <= half_click + 1e-12,
1135            "residue {} exceeded the half-click floor",
1136            report.worst_elevation_error
1137        );
1138        assert!(report.worst_windage_error <= half_click + 1e-12);
1139    }
1140
1141    /// The row cap is honoured and admitted to, never papered over with a met budget.
1142    #[test]
1143    fn max_rows_caps_with_capped_flag() {
1144        let curve = test_curve(900.0);
1145        let req = plain_request((200.0, 800.0), 0.001, 5);
1146        let report =
1147            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1148
1149        assert!(report.rows_capped);
1150        assert!(!report.budget_met);
1151        assert_eq!(report.rows.len(), 5);
1152        assert!(report.worst_elevation_error > 0.001);
1153        assert!(report.worst_error_range_m >= 200.0 && report.worst_error_range_m <= 800.0);
1154    }
1155
1156    /// The method string and all five assumptions, pinned by length AND by exact content at
1157    /// every index -- a consumer that quotes `assumptions[3]` must keep getting the
1158    /// half-click floor and not whatever a later edit shuffled into that slot.
1159    #[test]
1160    fn report_carries_method_and_all_five_assumptions() {
1161        let curve = test_curve(900.0);
1162        let req = plain_request((200.0, 400.0), 0.2, 50);
1163        let report =
1164            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1165
1166        assert_eq!(
1167            report.method,
1168            "greedy_worst_point_insertion_on_holdcurve_grid_v1"
1169        );
1170        assert_eq!(report.assumptions.len(), 5);
1171        assert_eq!(
1172            report.assumptions[0],
1173            "Verification is limited to the hold curve's declared sample grid (verification_grid_step_m) together with the card's own rows; no claim is made about ranges between those audited points."
1174        );
1175        assert_eq!(
1176            report.assumptions[1],
1177            "The reader of this card interpolates linearly between adjacent rows."
1178        );
1179        assert_eq!(
1180            report.assumptions[2],
1181            "Errors are measured in printed-value space -- the same zero-set bias, tracking-correction division and click quantization the printed rows carry -- so a constant zero-set bias cancels out of the interpolation error and the tracking correction factor is already inside the numbers being compared."
1182        );
1183        assert_eq!(
1184            report.assumptions[3],
1185            "Rows quantized to an optic's clicks carry an irreducible error of up to half a click at the rows themselves, which no number of added rows can remove."
1186        );
1187        assert_eq!(
1188            report.assumptions[4],
1189            "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed."
1190        );
1191    }
1192
1193    /// The verification grid's one load-bearing assumption: the curve's native samples sit
1194    /// at exact multiples of `SAMPLE_INTERVAL_M`, so `native_grid_m` reproduces them without
1195    /// reaching into the curve's private sample vector.
1196    ///
1197    /// Discriminating, not merely consistent: the interpolated drop is piecewise linear with
1198    /// its kinks AT the sample nodes, so three probes straddling a claimed node are
1199    /// measurably non-collinear while three probes inside one claimed interval are collinear
1200    /// to floating-point noise. A grid that was offset from the real nodes would swap those
1201    /// two outcomes.
1202    #[test]
1203    fn verification_grid_lands_on_the_curves_native_samples() {
1204        let curve = test_curve(900.0);
1205        let step = HoldCurve::SAMPLE_INTERVAL_M;
1206        let max_m = curve.max_sampled_range_m();
1207
1208        // The last sample is an exact multiple of the step, bit-for-bit.
1209        let index = (max_m / step).round();
1210        assert_eq!((index * step).to_bits(), max_m.to_bits());
1211        assert!(curve.at_range(max_m).is_some());
1212        assert!(curve.at_range(max_m + step).is_none());
1213
1214        // Linear drop at a range, reconstructed from the angular reading.
1215        let drop_m_at = |range_m: f64| {
1216            let p = curve.at_range(range_m).expect("probe on the curve");
1217            p.drop_mil / 1000.0 * range_m
1218        };
1219        // A claimed node, out where the curve bends hardest -- drawn from `native_grid_m`
1220        // itself (not independently reconstructed as `800.0 * step`) so a phase bug in the
1221        // function under test would surface in this kink probe too, not only in the separate
1222        // ascending/step-spaced check below.
1223        let node = native_grid_m(step, max_m)[799];
1224        let delta = step / 4.0;
1225
1226        let bend_at = |centre: f64| {
1227            let mid = drop_m_at(centre);
1228            let avg = 0.5 * (drop_m_at(centre - delta) + drop_m_at(centre + delta));
1229            (mid - avg).abs()
1230        };
1231        let at_node = bend_at(node);
1232        let inside_interval = bend_at(node + step / 2.0);
1233
1234        assert!(
1235            inside_interval < 1e-12,
1236            "probes inside one claimed sample interval were not collinear ({inside_interval} m) \
1237             -- the reconstructed grid is offset from the curve's real nodes"
1238        );
1239        assert!(
1240            at_node > 1e-9 && at_node > 100.0 * inside_interval.max(f64::MIN_POSITIVE),
1241            "no interpolation kink at the claimed node ({at_node} m) -- \
1242             the reconstructed grid is offset from the curve's real nodes"
1243        );
1244
1245        // And the reconstructed grid is inside the domain, ascending, and step-spaced.
1246        let grid = native_grid_m(300.0, 300.0 + 10.0 * step);
1247        assert!(grid.len() >= 10);
1248        for pair in grid.windows(2) {
1249            assert!((pair[1] - pair[0] - step).abs() < 1e-12);
1250        }
1251    }
1252
1253    /// The locked composition order (MBA-1360 x MBA-1358 x MBA-724): bias joins the TRUE
1254    /// angular need first, the tracking correction divides second, quantization is last.
1255    /// The wrong-order value is computed too, so the test provably fails if the pipeline
1256    /// is ever reordered.
1257    #[test]
1258    fn printed_pipeline_keeps_the_locked_bias_then_cf_then_quantize_order() {
1259        let curve = test_curve(900.0);
1260        let click = ClickValue {
1261            size: 0.1,
1262            base: ClickBase::Mil,
1263        };
1264        // Magnitudes chosen so the two composition orders land on DIFFERENT detents: they
1265        // differ by `bias * (1/cf - 1)`, which must clear a half click (0.05 mil) or
1266        // quantization would erase the very thing this test is trying to observe.
1267        let (bias_mil, cf) = (2.0, 0.9);
1268        let req = AdaptiveRequest {
1269            domain_m: (300.0, 600.0),
1270            anchors_m: Vec::new(),
1271            budget: AdaptiveBudget {
1272                elevation: 0.2,
1273                windage: 0.2,
1274            },
1275            max_rows: 200,
1276            click: Some((&click, &click)),
1277            elevation_cf: cf,
1278            windage_cf: 1.0,
1279            bias_mil,
1280        };
1281        let report =
1282            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1283
1284        let row = &report.rows[1];
1285        let true_mil = curve
1286            .at_range(row.range)
1287            .expect("row on the curve")
1288            .drop_mil;
1289
1290        let right_order = ((true_mil + bias_mil) / cf / 0.1).round() * 0.1;
1291        let wrong_order = (((true_mil / cf) + bias_mil) / 0.1).round() * 0.1;
1292        let dialed = row.drop_adj.expect("dial");
1293
1294        assert!(
1295            (dialed - right_order).abs() < 1e-12,
1296            "printed {dialed} is not (true + bias) / cf quantized ({right_order})"
1297        );
1298        assert!(
1299            (right_order - wrong_order).abs() > 1e-9,
1300            "the chosen bias/CF make both orders agree; this test would not catch a swap"
1301        );
1302
1303        // Windage carries its own CF and no bias, and is quantized on the same lattice.
1304        let true_wind = curve
1305            .at_range(row.range)
1306            .expect("row on the curve")
1307            .wind_mil;
1308        let expected_wind = (true_wind / 0.1).round() * 0.1;
1309        assert!((row.wind_adj.expect("dial") - expected_wind).abs() < 1e-12);
1310    }
1311
1312    /// MOA cards are drawn on this crate's locked printed-table ratio (MBA-724), never on
1313    /// the exact-angle 3437.7467.
1314    #[test]
1315    fn moa_cards_use_the_locked_3438_ratio() {
1316        assert_eq!(CardAdjustmentUnit::Mil.from_mil_factor(), 1.0);
1317        assert_eq!(CardAdjustmentUnit::Moa.from_mil_factor(), 3438.0 / 1000.0);
1318        assert_ne!(
1319            CardAdjustmentUnit::Moa.from_mil_factor(),
1320            3437.7467 / 1000.0
1321        );
1322
1323        let curve = test_curve(900.0);
1324        let req = plain_request((300.0, 600.0), 0.5, 200);
1325        let report =
1326            adaptive_card(&curve, &req, CardAdjustmentUnit::Moa).expect("card should build");
1327        let row = &report.rows[0];
1328        let true_mil = curve
1329            .at_range(row.range)
1330            .expect("row on the curve")
1331            .drop_mil;
1332        assert_eq!(
1333            row.drop_adj.expect("dial").to_bits(),
1334            (true_mil * (3438.0 / 1000.0)).to_bits()
1335        );
1336    }
1337
1338    /// Every rejection is structured and specific -- a range a shooter asked for and did
1339    /// not get back must come back as a reason, never as a silently shortened card.
1340    #[test]
1341    fn request_validation_reports_every_structured_error() {
1342        let curve = test_curve(900.0);
1343        let curve_max_m = curve.max_sampled_range_m();
1344
1345        let mut req = plain_request((200.0, 800.0), 0.1, 0);
1346        assert_eq!(
1347            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1348            CardError::ZeroMaxRows
1349        );
1350
1351        req = plain_request((200.0, 800.0), 0.1, 50);
1352        req.budget.elevation = 0.0;
1353        assert_eq!(
1354            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1355            CardError::NonPositiveBudget {
1356                axis: "elevation",
1357                value: 0.0
1358            }
1359        );
1360        req = plain_request((200.0, 800.0), 0.1, 50);
1361        req.budget.windage = f64::NAN;
1362        assert!(matches!(
1363            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1364            Err(CardError::NonPositiveBudget {
1365                axis: "windage",
1366                ..
1367            })
1368        ));
1369
1370        for domain in [(800.0, 200.0), (0.0, 500.0), (-10.0, 500.0), (300.0, 300.0)] {
1371            let req = plain_request(domain, 0.1, 50);
1372            assert!(
1373                matches!(
1374                    adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1375                    Err(CardError::EmptyOrInvertedDomain { .. })
1376                ),
1377                "domain {domain:?} must be rejected"
1378            );
1379        }
1380
1381        let req = plain_request((200.0, curve_max_m + 1.0), 0.1, 50);
1382        assert_eq!(
1383            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1384            CardError::DomainOutsideCurve {
1385                requested_m: curve_max_m + 1.0,
1386                curve_max_m
1387            }
1388        );
1389
1390        let mut req = plain_request((200.0, 800.0), 0.1, 50);
1391        req.anchors_m = vec![900.0];
1392        assert_eq!(
1393            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1394            CardError::AnchorOutsideDomain {
1395                anchor_m: 900.0,
1396                start_m: 200.0,
1397                end_m: 800.0
1398            }
1399        );
1400
1401        // Every variant renders as a sentence, so a CLI can print the reason verbatim.
1402        assert!(CardError::ZeroMaxRows
1403            .to_string()
1404            .contains("at least one row"));
1405    }
1406
1407    /// An out-of-band ELEVATION tracking CF is rejected with the exact variant and payload.
1408    ///
1409    /// `95.0` is the specific realistic slip this guards: a percentage typed where the ratio
1410    /// `0.95` belongs. Unvalidated it does not blow up -- it divides every printed value to
1411    /// ~0, so every measured error is ~0 and the card comes back `budget_met: true` while
1412    /// being entirely wrong. The assertion below therefore also pins that the request is
1413    /// refused rather than answered.
1414    #[test]
1415    fn out_of_band_elevation_tracking_cf_is_rejected() {
1416        let curve = test_curve(900.0);
1417        for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1418            let mut req = plain_request((200.0, 800.0), 0.1, 50);
1419            req.elevation_cf = bad;
1420            let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1421                .expect_err("an out-of-band elevation CF must be refused, not answered");
1422            match err {
1423                CardError::InvalidTrackingCf { axis, value } => {
1424                    assert_eq!(axis, "elevation");
1425                    // NaN never equals itself; compare bit patterns so the payload is pinned
1426                    // for every case including the non-finite ones.
1427                    assert_eq!(
1428                        value.to_bits(),
1429                        bad.to_bits(),
1430                        "payload must echo the input"
1431                    );
1432                }
1433                other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1434            }
1435        }
1436        // The band's interior is accepted, so the guard is not simply refusing everything.
1437        let mut req = plain_request((200.0, 800.0), 0.1, 50);
1438        req.elevation_cf = 0.95;
1439        assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1440    }
1441
1442    /// Same for the WINDAGE axis -- a per-axis check, because one shared guard covering only
1443    /// the elevation field would pass an elevation-only test and still ship the bug.
1444    #[test]
1445    fn out_of_band_windage_tracking_cf_is_rejected() {
1446        let curve = test_curve(900.0);
1447        for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1448            let mut req = plain_request((200.0, 800.0), 0.1, 50);
1449            req.windage_cf = bad;
1450            let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1451                .expect_err("an out-of-band windage CF must be refused, not answered");
1452            match err {
1453                CardError::InvalidTrackingCf { axis, value } => {
1454                    assert_eq!(axis, "windage");
1455                    assert_eq!(
1456                        value.to_bits(),
1457                        bad.to_bits(),
1458                        "payload must echo the input"
1459                    );
1460                }
1461                other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1462            }
1463        }
1464        let mut req = plain_request((200.0, 800.0), 0.1, 50);
1465        req.windage_cf = 1.05;
1466        assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1467
1468        // Elevation is reported first when both axes are bad, so the message names one
1469        // concrete axis rather than a vague "a tracking factor".
1470        let mut req = plain_request((200.0, 800.0), 0.1, 50);
1471        req.elevation_cf = 95.0;
1472        req.windage_cf = 95.0;
1473        assert_eq!(
1474            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1475            CardError::InvalidTrackingCf {
1476                axis: "elevation",
1477                value: 95.0
1478            }
1479        );
1480        // And it renders as a sentence that names the ratio-vs-percentage trap.
1481        let text = CardError::InvalidTrackingCf {
1482            axis: "elevation",
1483            value: 95.0,
1484        }
1485        .to_string();
1486        assert!(
1487            text.contains("elevation") && text.contains("0.5") && text.contains("1.5"),
1488            "{text}"
1489        );
1490    }
1491
1492    /// Task 12: `adaptive-card -o json` prints this report pretty-printed VERBATIM (no
1493    /// hand-rebuilt `json!` object, unlike the four Task 9 surfaces), so the derived
1494    /// `Serialize` impl IS the wire contract. Pins the field names a CLI/binding consumer
1495    /// would rely on, and that a row's `None` fields serialize as `null` (never dropped),
1496    /// matching `CardRow`'s existing "every surface populates only the fields it has"
1497    /// convention -- a `skip_serializing_if` would silently make an adaptive row's JSON
1498    /// shape depend on which fields happened to be absent.
1499    #[test]
1500    fn report_serializes_verbatim_with_stable_field_names() {
1501        let curve = test_curve(900.0);
1502        let req = plain_request((200.0, 400.0), 0.2, 50);
1503        let report =
1504            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1505
1506        let json = serde_json::to_value(&report).expect("report must serialize");
1507        assert_eq!(json["schema_version"], ADAPTIVE_CARD_SCHEMA_VERSION_V1);
1508        assert_eq!(
1509            json["method"],
1510            "greedy_worst_point_insertion_on_holdcurve_grid_v1"
1511        );
1512        assert_eq!(
1513            json["assumptions"]
1514                .as_array()
1515                .expect("assumptions array")
1516                .len(),
1517            5
1518        );
1519        assert_eq!(json["budget_met"], report.budget_met);
1520        assert_eq!(json["rows_capped"], report.rows_capped);
1521        assert!(json.get("worst_elevation_error").is_some());
1522        assert!(json.get("worst_windage_error").is_some());
1523        assert!(json.get("worst_error_range_m").is_some());
1524        assert!(json.get("verification_grid_step_m").is_some());
1525
1526        let rows = json["rows"].as_array().expect("rows array");
1527        assert_eq!(rows.len(), report.rows.len());
1528        let first = &rows[0];
1529        assert!(first["range"].is_number());
1530        assert!(
1531            first["drop_adj"].is_number(),
1532            "a populated Some(..) field must serialize as a number"
1533        );
1534        // Fields every adaptive row leaves `None` serialize as explicit JSON null, not as an
1535        // absent key -- a consumer can tell "never populated by this engine" from "absent
1536        // because of a version skew" only if the key is always present.
1537        assert!(first["come_up"].is_null());
1538        assert!(first["lead_adj"].is_null());
1539        assert_eq!(first["wind_columns"], serde_json::json!([]));
1540    }
1541
1542    /// Review fix I-4 (review of `4e69435`): the test above pinned only 5 of `CardRow`'s 11
1543    /// fields (`range`, `drop_adj`, `come_up`, `lead_adj`, `wind_columns`) -- `drop_linear`,
1544    /// `wind_linear`, `wind_adj`, `velocity`, `energy`, `time` were unpinned key names.
1545    /// `CardRow` is a *shared* internal type Task 9 itself created by unifying four other
1546    /// structs one task ago; a further rename during refactoring is a live possibility, and
1547    /// it would silently invalidate a wire published under `ADAPTIVE_CARD_SCHEMA_VERSION_V1`
1548    /// (and the 45-line worked JSON example in CLI_USAGE.md) with nothing here noticing.
1549    ///
1550    /// Every field gets its own pairwise-distinct sentinel (all `Some`, unlike the report-level
1551    /// test above which reads a real, physics-derived row) so a field-name<->value
1552    /// transposition -- not just a missing key -- fails this, and the row's key COUNT is
1553    /// pinned too, so a future field addition/removal is caught even if its name happens to
1554    /// collide with an existing sentinel value.
1555    #[test]
1556    fn card_row_field_names_bind_to_their_sentinel_values_in_json() {
1557        let row = CardRow {
1558            range: 111.1,
1559            drop_linear: Some(222.2),
1560            drop_adj: Some(333.3),
1561            come_up: Some(444.4),
1562            wind_linear: Some(555.5),
1563            wind_adj: Some(666.6),
1564            velocity: Some(777.7),
1565            energy: Some(888.8),
1566            time: Some(9.99),
1567            lead_adj: Some(101.1),
1568            wind_columns: vec![1.0, 2.0, 3.0],
1569        };
1570        let json = serde_json::to_value(&row).expect("row must serialize");
1571
1572        assert_eq!(json["range"], 111.1);
1573        assert_eq!(json["drop_linear"], 222.2);
1574        assert_eq!(json["drop_adj"], 333.3);
1575        assert_eq!(json["come_up"], 444.4);
1576        assert_eq!(json["wind_linear"], 555.5);
1577        assert_eq!(json["wind_adj"], 666.6);
1578        assert_eq!(json["velocity"], 777.7);
1579        assert_eq!(json["energy"], 888.8);
1580        assert_eq!(json["time"], 9.99);
1581        assert_eq!(json["lead_adj"], 101.1);
1582        assert_eq!(json["wind_columns"], serde_json::json!([1.0, 2.0, 3.0]));
1583
1584        // No extra keys, and none silently dropped: exactly CardRow's 11 fields.
1585        assert_eq!(
1586            json.as_object()
1587                .expect("row must serialize to a JSON object")
1588                .len(),
1589            11
1590        );
1591    }
1592}