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            station_presence: crate::atmosphere::StationAtmospherePresence::NEITHER,
713        }
714    }
715
716    fn test_curve(max_range_m: f64) -> HoldCurve {
717        HoldCurve::solve(&test_load(), max_range_m).expect("hold curve should solve")
718    }
719
720    /// An unbiased, uncorrected, unquantized request: in `Mil` the printed value is then
721    /// exactly the curve's `drop_mil` / `wind_mil`, which keeps the independent checks below
722    /// free of any conversion the engine could also get wrong.
723    fn plain_request(
724        domain_m: (f64, f64),
725        budget: f64,
726        max_rows: usize,
727    ) -> AdaptiveRequest<'static> {
728        AdaptiveRequest {
729            domain_m,
730            anchors_m: Vec::new(),
731            budget: AdaptiveBudget {
732                elevation: budget,
733                windage: budget,
734            },
735            max_rows,
736            click: None,
737            elevation_cf: 1.0,
738            windage_cf: 1.0,
739            bias_mil: 0.0,
740        }
741    }
742
743    /// The TEST's own audited set -- `(range_m, drop_mil, wind_mil)` at the curve's native
744    /// grid inside the domain plus the two domain ends. Written out longhand rather than
745    /// calling `native_grid_m`, so a bug in the engine's grid construction cannot hide
746    /// behind the check that uses it.
747    fn independent_audited(curve: &HoldCurve, start_m: f64, end_m: f64) -> Vec<(f64, f64, f64)> {
748        let step = HoldCurve::SAMPLE_INTERVAL_M;
749        let mut ranges = vec![start_m];
750        let mut i = 1_i64;
751        loop {
752            let g = i as f64 * step;
753            if g > end_m {
754                break;
755            }
756            if g > start_m {
757                ranges.push(g);
758            }
759            i += 1;
760        }
761        if ranges.last().is_none_or(|&last| last < end_m) {
762            ranges.push(end_m);
763        }
764        ranges
765            .into_iter()
766            .map(|g| {
767                let p = curve
768                    .at_range(g)
769                    .expect("audited range must be on the curve");
770                (g, p.drop_mil, p.wind_mil)
771            })
772            .collect()
773    }
774
775    /// The TEST's own reconstruction check: its own bracket search and hand-written lerp,
776    /// deliberately NOT the engine's `sweep`. Returns the worst (elevation, windage)
777    /// absolute error over `audited`.
778    fn independent_worst_error(
779        audited: &[(f64, f64, f64)],
780        row_ranges: &[f64],
781        row_elevation: &[f64],
782        row_windage: &[f64],
783    ) -> (f64, f64) {
784        let mut worst = (0.0_f64, 0.0_f64);
785        for &(g, drop_mil, wind_mil) in audited {
786            // The last row at or below `g`, clamped so the final row still brackets.
787            let lo = row_ranges
788                .partition_point(|&r| r <= g)
789                .saturating_sub(1)
790                .min(row_ranges.len() - 2);
791            let hi = lo + 1;
792            let span = row_ranges[hi] - row_ranges[lo];
793            let t = if span > 0.0 {
794                (g - row_ranges[lo]) / span
795            } else {
796                0.0
797            };
798            let elevation = row_elevation[lo] + (row_elevation[hi] - row_elevation[lo]) * t;
799            let windage = row_windage[lo] + (row_windage[hi] - row_windage[lo]) * t;
800            worst.0 = worst.0.max((elevation - drop_mil).abs());
801            worst.1 = worst.1.max((windage - wind_mil).abs());
802        }
803        worst
804    }
805
806    /// Smallest evenly-spaced card meeting `budget` on the same audited points, found by
807    /// trial. `None` if no step up to `max_n` rows manages it.
808    fn smallest_uniform_card(
809        curve: &HoldCurve,
810        audited: &[(f64, f64, f64)],
811        start_m: f64,
812        end_m: f64,
813        budget: f64,
814        max_n: usize,
815    ) -> Option<usize> {
816        for n in 2..=max_n {
817            let ranges: Vec<f64> = (0..n)
818                .map(|i| start_m + (end_m - start_m) * i as f64 / (n - 1) as f64)
819                .collect();
820            let points: Vec<_> = ranges
821                .iter()
822                .map(|&r| curve.at_range(r).expect("uniform row on the curve"))
823                .collect();
824            let elevation: Vec<f64> = points.iter().map(|p| p.drop_mil).collect();
825            let windage: Vec<f64> = points.iter().map(|p| p.wind_mil).collect();
826            let (worst_elevation, worst_windage) =
827                independent_worst_error(audited, &ranges, &elevation, &windage);
828            if worst_elevation <= budget && worst_windage <= budget {
829                return Some(n);
830            }
831        }
832        None
833    }
834
835    fn row_columns(report: &AdaptiveCardReportV1) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
836        (
837            report.rows.iter().map(|r| r.range).collect(),
838            report
839                .rows
840                .iter()
841                .map(|r| r.drop_adj.expect("adaptive rows always carry a dial value"))
842                .collect(),
843            report
844                .rows
845                .iter()
846                .map(|r| r.wind_adj.expect("adaptive rows always carry a dial value"))
847                .collect(),
848        )
849    }
850
851    /// Spec 8.2 acceptance: a card that claims a met budget must survive an audit written
852    /// by someone other than the engine. Every audited point is re-measured here with the
853    /// test's own grid, bracket search and lerp.
854    #[test]
855    fn verification_pass_confirms_every_audited_point_within_bounds() {
856        let curve = test_curve(900.0);
857        let budget = 0.1;
858        let req = plain_request((200.0, 800.0), budget, 500);
859        let report =
860            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
861
862        assert!(
863            report.budget_met,
864            "0.1 mil over 200-800 m should be reachable"
865        );
866        assert!(!report.rows_capped);
867        assert_eq!(report.schema_version, ADAPTIVE_CARD_SCHEMA_VERSION_V1);
868        assert_eq!(
869            report.verification_grid_step_m,
870            HoldCurve::SAMPLE_INTERVAL_M
871        );
872
873        let (ranges, elevation, windage) = row_columns(&report);
874        let audited = independent_audited(&curve, 200.0, 800.0);
875        let (worst_elevation, worst_windage) =
876            independent_worst_error(&audited, &ranges, &elevation, &windage);
877
878        assert!(
879            worst_elevation <= budget,
880            "independent audit found {worst_elevation} mil of elevation error, budget {budget}"
881        );
882        assert!(
883            worst_windage <= budget,
884            "independent audit found {worst_windage} mil of windage error, budget {budget}"
885        );
886        // The engine's own measurement must not be optimistic relative to the independent one.
887        assert!(report.worst_elevation_error >= worst_elevation - 1e-12);
888        assert!(report.worst_windage_error >= worst_windage - 1e-12);
889    }
890
891    /// A tighter tolerance can never buy a shorter card. Swept across four budgets rather
892    /// than compared at two points, so a non-monotone middle cannot slip through.
893    #[test]
894    fn tightening_the_budget_never_decreases_row_count() {
895        let curve = test_curve(900.0);
896        let counts: Vec<usize> = [0.4, 0.2, 0.1, 0.05]
897            .iter()
898            .map(|&budget| {
899                let req = plain_request((200.0, 800.0), budget, 800);
900                let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
901                    .expect("card should build");
902                assert!(
903                    report.budget_met,
904                    "{budget} mil should be reachable unquantized"
905                );
906                report.rows.len()
907            })
908            .collect();
909
910        for pair in counts.windows(2) {
911            assert!(
912                pair[1] >= pair[0],
913                "row counts must not decrease as the budget tightens: {counts:?}"
914            );
915        }
916        assert!(
917            counts[3] > counts[0],
918            "a 8x tighter budget should cost rows: {counts:?}"
919        );
920    }
921
922    /// The search really does adapt: rows bunch up where the curve bends. Measured as the
923    /// mean row spacing over the far half of the card against the near half, which is the
924    /// property that survives whatever the total row count turns out to be.
925    #[test]
926    fn adaptive_rows_concentrate_where_the_curve_bends() {
927        let curve = test_curve(900.0);
928        for budget in [0.1, 0.05, 0.02] {
929            let req = plain_request((200.0, 800.0), budget, 800);
930            let report =
931                adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
932            assert!(report.budget_met, "{budget} mil should be reachable");
933            assert!(report.rows.len() >= 4, "need enough rows to halve");
934
935            let gaps: Vec<f64> = report
936                .rows
937                .windows(2)
938                .map(|p| p[1].range - p[0].range)
939                .collect();
940            let half = gaps.len() / 2;
941            let near: f64 = gaps[..half].iter().sum::<f64>() / half as f64;
942            let far: f64 = gaps[gaps.len() - half..].iter().sum::<f64>() / half as f64;
943            assert!(
944                far < near,
945                "budget {budget}: far-half spacing {far:.1} m is not tighter than near-half \
946                 {near:.1} m -- the card is not adapting, gaps {gaps:?}"
947            );
948        }
949    }
950
951    /// The fixed-step comparison, MEASURED rather than assumed -- and it does not come out
952    /// the way the task brief predicted.
953    ///
954    /// The brief specified this as `smooth_trajectory_beats_fixed_step`: adaptive at 0.1 mil
955    /// on 200-800 m should need fewer rows than the smallest uniform card meeting the same
956    /// budget. It does not. Measured over five domains x four budgets, greedy worst-point
957    /// insertion lost 10, tied 5 and won 5, and its row counts cluster on 5 / 9 / 17.
958    ///
959    /// That is a property of the pinned algorithm, not a defect in it. One insertion splits
960    /// an interval into parts of length `l*h` and `(1-l)*h` carrying `l^2` and `(1-l)^2` of
961    /// the old error, so the best any single insertion can do to an interval's error is
962    /// divide it by four -- a bisection. When a trajectory's curvature barely varies (over
963    /// 200-800 m this load's does so by under 2x, which is exactly what "smooth" means),
964    /// every interval needs refining at once and the card doubles, while a uniform card is
965    /// free to pick any row count at all. Adaptive placement wins where curvature varies
966    /// sharply; on a smooth mid-range trajectory its value is the MEASURED error bound, the
967    /// anchors and not having to guess a step -- not a shorter card.
968    ///
969    /// Two assertions, bounding the finding from BOTH sides, because a one-sided bound is
970    /// not a pin. The upper bound is a regression backstop (bisection granularity must never
971    /// cost more than a doubling). The directional one is the finding itself: adaptive does
972    /// not meaningfully beat uniform here. Neither pins exact arithmetic a physics change
973    /// would move -- the `+ 1` slack keeps a ULP-level shift at the current 5-vs-5 tie from
974    /// firing spuriously -- but if adaptive ever genuinely wins, the directional assertion
975    /// fails, and that is the signal that this comment, the public `adaptive_card` docs and
976    /// the product guidance built on them have all gone stale.
977    #[test]
978    fn fixed_step_comparison_is_measured_not_assumed() {
979        let curve = test_curve(900.0);
980        let (start_m, end_m, budget) = (200.0, 800.0, 0.1);
981
982        let req = plain_request((start_m, end_m), budget, 800);
983        let report =
984            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
985        assert!(report.budget_met);
986        let adaptive_rows = report.rows.len();
987
988        // Smallest uniform card meeting the same budget on the same audited points, found
989        // by trial in the test -- the most generous possible fixed-step baseline.
990        let audited = independent_audited(&curve, start_m, end_m);
991        let uniform_rows = smallest_uniform_card(&curve, &audited, start_m, end_m, budget, 400)
992            .expect("some uniform step must meet the budget");
993
994        assert!(
995            adaptive_rows <= 2 * uniform_rows,
996            "adaptive used {adaptive_rows} rows against a {uniform_rows}-row uniform card; \
997             bisection granularity should never cost more than a doubling"
998        );
999        assert!(
1000            adaptive_rows + 1 >= uniform_rows,
1001            "adaptive ({adaptive_rows} rows) now beats uniform ({uniform_rows} rows) at the \
1002             brief's own parameters -- the insertion rule has been improved, so the finding in \
1003             this test's doc comment, the \"not a shorter card\" disclosure on `adaptive_card`, \
1004             and the product guidance built on it are ALL stale and must be revisited"
1005        );
1006    }
1007
1008    /// Anchors are promises, not suggestions -- and the same request must produce the same
1009    /// card every time (no RNG, no hash-order dependence anywhere in the search).
1010    #[test]
1011    fn anchors_always_present_and_determinism() {
1012        let curve = test_curve(900.0);
1013        let anchors = vec![300.0, 512.5, 777.0];
1014        let mut req = plain_request((200.0, 800.0), 0.15, 500);
1015        req.anchors_m.clone_from(&anchors);
1016
1017        let first =
1018            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1019        let second =
1020            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1021
1022        for anchor in &anchors {
1023            assert!(
1024                first.rows.iter().any(|row| row.range == *anchor),
1025                "anchor {anchor} m is missing from the card"
1026            );
1027        }
1028        // Both domain ends are rows too.
1029        assert_eq!(first.rows.first().map(|r| r.range), Some(200.0));
1030        assert_eq!(first.rows.last().map(|r| r.range), Some(800.0));
1031
1032        assert_eq!(first.method, second.method);
1033        assert_eq!(first.assumptions, second.assumptions);
1034        assert_eq!(first.budget_met, second.budget_met);
1035        assert_eq!(first.rows_capped, second.rows_capped);
1036        assert_eq!(first.rows.len(), second.rows.len());
1037        // Bit-for-bit, not approximately: determinism means identical, not close.
1038        assert_eq!(
1039            first.worst_elevation_error.to_bits(),
1040            second.worst_elevation_error.to_bits()
1041        );
1042        assert_eq!(
1043            first.worst_windage_error.to_bits(),
1044            second.worst_windage_error.to_bits()
1045        );
1046        assert_eq!(
1047            first.worst_error_range_m.to_bits(),
1048            second.worst_error_range_m.to_bits()
1049        );
1050        for (a, b) in first.rows.iter().zip(second.rows.iter()) {
1051            assert_eq!(a.range.to_bits(), b.range.to_bits());
1052            assert_eq!(a.drop_adj.map(f64::to_bits), b.drop_adj.map(f64::to_bits));
1053            assert_eq!(a.wind_adj.map(f64::to_bits), b.wind_adj.map(f64::to_bits));
1054        }
1055    }
1056
1057    /// A budget below the half-click floor cannot be met by adding rows, and the search
1058    /// must SAY so rather than grind. The iteration assertion is the fault-injection probe:
1059    /// with the irreducible-error stop removed the loop stops making progress and runs to
1060    /// the runaway backstop, blowing this bound.
1061    #[test]
1062    fn quantization_floor_is_honest_and_terminates() {
1063        let curve = test_curve(900.0);
1064        let step = HoldCurve::SAMPLE_INTERVAL_M;
1065        // Domain ends chosen ON the native grid so the audited set is exactly 21 points,
1066        // whichever way the ceil/floor rounds at the endpoints.
1067        let (start_m, end_m) = (330.0 * step, 350.0 * step);
1068        let audited_points = 21usize;
1069
1070        let click = ClickValue {
1071            size: 0.1,
1072            base: ClickBase::Mil,
1073        };
1074        let half_click = 0.05;
1075        let budget = 0.001;
1076        let req = AdaptiveRequest {
1077            domain_m: (start_m, end_m),
1078            anchors_m: Vec::new(),
1079            budget: AdaptiveBudget {
1080                elevation: budget,
1081                windage: budget,
1082            },
1083            max_rows: 500, // deliberately not binding: the stop must come from the floor
1084            click: Some((&click, &click)),
1085            elevation_cf: 1.0,
1086            windage_cf: 1.0,
1087            bias_mil: 0.0,
1088        };
1089
1090        let (report, trace) =
1091            adaptive_card_traced(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1092
1093        // Termination, stated as a bound rather than demonstrated by hanging: every
1094        // iteration inserts a distinct audited point or stops, so one pass per point plus
1095        // the final deciding pass is the most the search can legitimately take.
1096        assert!(
1097            trace.iterations <= audited_points + 1,
1098            "search took {} iterations for {audited_points} audited points (cap {}) -- \
1099             the irreducible-error stop is not firing",
1100            trace.iterations,
1101            trace.iteration_cap
1102        );
1103        assert!(
1104            trace.iterations < trace.iteration_cap,
1105            "the runaway backstop, not the irreducible-error stop, ended the search"
1106        );
1107
1108        assert!(!report.budget_met, "0.001 mil is under the 0.05 mil floor");
1109        assert!(
1110            !report.rows_capped,
1111            "the row cap was not binding; the stop must be attributed to the floor"
1112        );
1113        assert!(report.rows.len() <= audited_points);
1114
1115        // The reported worst error IS the measured half-click residue: recomputed here from
1116        // the printed rows alone, with no help from the engine.
1117        let mut row_worst = (0.0_f64, 0.0_f64);
1118        for row in &report.rows {
1119            let point = curve.at_range(row.range).expect("row on the curve");
1120            row_worst.0 = row_worst
1121                .0
1122                .max((row.drop_adj.expect("dial") - point.drop_mil).abs());
1123            row_worst.1 = row_worst
1124                .1
1125                .max((row.wind_adj.expect("dial") - point.wind_mil).abs());
1126        }
1127        assert!(
1128            (report.worst_elevation_error - row_worst.0).abs() < 1e-12,
1129            "worst elevation {} vs independently measured row residue {}",
1130            report.worst_elevation_error,
1131            row_worst.0
1132        );
1133        assert!(report.worst_elevation_error > budget);
1134        assert!(
1135            report.worst_elevation_error <= half_click + 1e-12,
1136            "residue {} exceeded the half-click floor",
1137            report.worst_elevation_error
1138        );
1139        assert!(report.worst_windage_error <= half_click + 1e-12);
1140    }
1141
1142    /// The row cap is honoured and admitted to, never papered over with a met budget.
1143    #[test]
1144    fn max_rows_caps_with_capped_flag() {
1145        let curve = test_curve(900.0);
1146        let req = plain_request((200.0, 800.0), 0.001, 5);
1147        let report =
1148            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1149
1150        assert!(report.rows_capped);
1151        assert!(!report.budget_met);
1152        assert_eq!(report.rows.len(), 5);
1153        assert!(report.worst_elevation_error > 0.001);
1154        assert!(report.worst_error_range_m >= 200.0 && report.worst_error_range_m <= 800.0);
1155    }
1156
1157    /// The method string and all five assumptions, pinned by length AND by exact content at
1158    /// every index -- a consumer that quotes `assumptions[3]` must keep getting the
1159    /// half-click floor and not whatever a later edit shuffled into that slot.
1160    #[test]
1161    fn report_carries_method_and_all_five_assumptions() {
1162        let curve = test_curve(900.0);
1163        let req = plain_request((200.0, 400.0), 0.2, 50);
1164        let report =
1165            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1166
1167        assert_eq!(
1168            report.method,
1169            "greedy_worst_point_insertion_on_holdcurve_grid_v1"
1170        );
1171        assert_eq!(report.assumptions.len(), 5);
1172        assert_eq!(
1173            report.assumptions[0],
1174            "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."
1175        );
1176        assert_eq!(
1177            report.assumptions[1],
1178            "The reader of this card interpolates linearly between adjacent rows."
1179        );
1180        assert_eq!(
1181            report.assumptions[2],
1182            "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."
1183        );
1184        assert_eq!(
1185            report.assumptions[3],
1186            "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."
1187        );
1188        assert_eq!(
1189            report.assumptions[4],
1190            "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed."
1191        );
1192    }
1193
1194    /// The verification grid's one load-bearing assumption: the curve's native samples sit
1195    /// at exact multiples of `SAMPLE_INTERVAL_M`, so `native_grid_m` reproduces them without
1196    /// reaching into the curve's private sample vector.
1197    ///
1198    /// Discriminating, not merely consistent: the interpolated drop is piecewise linear with
1199    /// its kinks AT the sample nodes, so three probes straddling a claimed node are
1200    /// measurably non-collinear while three probes inside one claimed interval are collinear
1201    /// to floating-point noise. A grid that was offset from the real nodes would swap those
1202    /// two outcomes.
1203    #[test]
1204    fn verification_grid_lands_on_the_curves_native_samples() {
1205        let curve = test_curve(900.0);
1206        let step = HoldCurve::SAMPLE_INTERVAL_M;
1207        let max_m = curve.max_sampled_range_m();
1208
1209        // The last sample is an exact multiple of the step, bit-for-bit.
1210        let index = (max_m / step).round();
1211        assert_eq!((index * step).to_bits(), max_m.to_bits());
1212        assert!(curve.at_range(max_m).is_some());
1213        assert!(curve.at_range(max_m + step).is_none());
1214
1215        // Linear drop at a range, reconstructed from the angular reading.
1216        let drop_m_at = |range_m: f64| {
1217            let p = curve.at_range(range_m).expect("probe on the curve");
1218            p.drop_mil / 1000.0 * range_m
1219        };
1220        // A claimed node, out where the curve bends hardest -- drawn from `native_grid_m`
1221        // itself (not independently reconstructed as `800.0 * step`) so a phase bug in the
1222        // function under test would surface in this kink probe too, not only in the separate
1223        // ascending/step-spaced check below.
1224        let node = native_grid_m(step, max_m)[799];
1225        let delta = step / 4.0;
1226
1227        let bend_at = |centre: f64| {
1228            let mid = drop_m_at(centre);
1229            let avg = 0.5 * (drop_m_at(centre - delta) + drop_m_at(centre + delta));
1230            (mid - avg).abs()
1231        };
1232        let at_node = bend_at(node);
1233        let inside_interval = bend_at(node + step / 2.0);
1234
1235        assert!(
1236            inside_interval < 1e-12,
1237            "probes inside one claimed sample interval were not collinear ({inside_interval} m) \
1238             -- the reconstructed grid is offset from the curve's real nodes"
1239        );
1240        assert!(
1241            at_node > 1e-9 && at_node > 100.0 * inside_interval.max(f64::MIN_POSITIVE),
1242            "no interpolation kink at the claimed node ({at_node} m) -- \
1243             the reconstructed grid is offset from the curve's real nodes"
1244        );
1245
1246        // And the reconstructed grid is inside the domain, ascending, and step-spaced.
1247        let grid = native_grid_m(300.0, 300.0 + 10.0 * step);
1248        assert!(grid.len() >= 10);
1249        for pair in grid.windows(2) {
1250            assert!((pair[1] - pair[0] - step).abs() < 1e-12);
1251        }
1252    }
1253
1254    /// The locked composition order (MBA-1360 x MBA-1358 x MBA-724): bias joins the TRUE
1255    /// angular need first, the tracking correction divides second, quantization is last.
1256    /// The wrong-order value is computed too, so the test provably fails if the pipeline
1257    /// is ever reordered.
1258    #[test]
1259    fn printed_pipeline_keeps_the_locked_bias_then_cf_then_quantize_order() {
1260        let curve = test_curve(900.0);
1261        let click = ClickValue {
1262            size: 0.1,
1263            base: ClickBase::Mil,
1264        };
1265        // Magnitudes chosen so the two composition orders land on DIFFERENT detents: they
1266        // differ by `bias * (1/cf - 1)`, which must clear a half click (0.05 mil) or
1267        // quantization would erase the very thing this test is trying to observe.
1268        let (bias_mil, cf) = (2.0, 0.9);
1269        let req = AdaptiveRequest {
1270            domain_m: (300.0, 600.0),
1271            anchors_m: Vec::new(),
1272            budget: AdaptiveBudget {
1273                elevation: 0.2,
1274                windage: 0.2,
1275            },
1276            max_rows: 200,
1277            click: Some((&click, &click)),
1278            elevation_cf: cf,
1279            windage_cf: 1.0,
1280            bias_mil,
1281        };
1282        let report =
1283            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1284
1285        let row = &report.rows[1];
1286        let true_mil = curve
1287            .at_range(row.range)
1288            .expect("row on the curve")
1289            .drop_mil;
1290
1291        let right_order = ((true_mil + bias_mil) / cf / 0.1).round() * 0.1;
1292        let wrong_order = (((true_mil / cf) + bias_mil) / 0.1).round() * 0.1;
1293        let dialed = row.drop_adj.expect("dial");
1294
1295        assert!(
1296            (dialed - right_order).abs() < 1e-12,
1297            "printed {dialed} is not (true + bias) / cf quantized ({right_order})"
1298        );
1299        assert!(
1300            (right_order - wrong_order).abs() > 1e-9,
1301            "the chosen bias/CF make both orders agree; this test would not catch a swap"
1302        );
1303
1304        // Windage carries its own CF and no bias, and is quantized on the same lattice.
1305        let true_wind = curve
1306            .at_range(row.range)
1307            .expect("row on the curve")
1308            .wind_mil;
1309        let expected_wind = (true_wind / 0.1).round() * 0.1;
1310        assert!((row.wind_adj.expect("dial") - expected_wind).abs() < 1e-12);
1311    }
1312
1313    /// MOA cards are drawn on this crate's locked printed-table ratio (MBA-724), never on
1314    /// the exact-angle 3437.7467.
1315    #[test]
1316    fn moa_cards_use_the_locked_3438_ratio() {
1317        assert_eq!(CardAdjustmentUnit::Mil.from_mil_factor(), 1.0);
1318        assert_eq!(CardAdjustmentUnit::Moa.from_mil_factor(), 3438.0 / 1000.0);
1319        assert_ne!(
1320            CardAdjustmentUnit::Moa.from_mil_factor(),
1321            3437.7467 / 1000.0
1322        );
1323
1324        let curve = test_curve(900.0);
1325        let req = plain_request((300.0, 600.0), 0.5, 200);
1326        let report =
1327            adaptive_card(&curve, &req, CardAdjustmentUnit::Moa).expect("card should build");
1328        let row = &report.rows[0];
1329        let true_mil = curve
1330            .at_range(row.range)
1331            .expect("row on the curve")
1332            .drop_mil;
1333        assert_eq!(
1334            row.drop_adj.expect("dial").to_bits(),
1335            (true_mil * (3438.0 / 1000.0)).to_bits()
1336        );
1337    }
1338
1339    /// Every rejection is structured and specific -- a range a shooter asked for and did
1340    /// not get back must come back as a reason, never as a silently shortened card.
1341    #[test]
1342    fn request_validation_reports_every_structured_error() {
1343        let curve = test_curve(900.0);
1344        let curve_max_m = curve.max_sampled_range_m();
1345
1346        let mut req = plain_request((200.0, 800.0), 0.1, 0);
1347        assert_eq!(
1348            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1349            CardError::ZeroMaxRows
1350        );
1351
1352        req = plain_request((200.0, 800.0), 0.1, 50);
1353        req.budget.elevation = 0.0;
1354        assert_eq!(
1355            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1356            CardError::NonPositiveBudget {
1357                axis: "elevation",
1358                value: 0.0
1359            }
1360        );
1361        req = plain_request((200.0, 800.0), 0.1, 50);
1362        req.budget.windage = f64::NAN;
1363        assert!(matches!(
1364            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1365            Err(CardError::NonPositiveBudget {
1366                axis: "windage",
1367                ..
1368            })
1369        ));
1370
1371        for domain in [(800.0, 200.0), (0.0, 500.0), (-10.0, 500.0), (300.0, 300.0)] {
1372            let req = plain_request(domain, 0.1, 50);
1373            assert!(
1374                matches!(
1375                    adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1376                    Err(CardError::EmptyOrInvertedDomain { .. })
1377                ),
1378                "domain {domain:?} must be rejected"
1379            );
1380        }
1381
1382        let req = plain_request((200.0, curve_max_m + 1.0), 0.1, 50);
1383        assert_eq!(
1384            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1385            CardError::DomainOutsideCurve {
1386                requested_m: curve_max_m + 1.0,
1387                curve_max_m
1388            }
1389        );
1390
1391        let mut req = plain_request((200.0, 800.0), 0.1, 50);
1392        req.anchors_m = vec![900.0];
1393        assert_eq!(
1394            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1395            CardError::AnchorOutsideDomain {
1396                anchor_m: 900.0,
1397                start_m: 200.0,
1398                end_m: 800.0
1399            }
1400        );
1401
1402        // Every variant renders as a sentence, so a CLI can print the reason verbatim.
1403        assert!(CardError::ZeroMaxRows
1404            .to_string()
1405            .contains("at least one row"));
1406    }
1407
1408    /// An out-of-band ELEVATION tracking CF is rejected with the exact variant and payload.
1409    ///
1410    /// `95.0` is the specific realistic slip this guards: a percentage typed where the ratio
1411    /// `0.95` belongs. Unvalidated it does not blow up -- it divides every printed value to
1412    /// ~0, so every measured error is ~0 and the card comes back `budget_met: true` while
1413    /// being entirely wrong. The assertion below therefore also pins that the request is
1414    /// refused rather than answered.
1415    #[test]
1416    fn out_of_band_elevation_tracking_cf_is_rejected() {
1417        let curve = test_curve(900.0);
1418        for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1419            let mut req = plain_request((200.0, 800.0), 0.1, 50);
1420            req.elevation_cf = bad;
1421            let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1422                .expect_err("an out-of-band elevation CF must be refused, not answered");
1423            match err {
1424                CardError::InvalidTrackingCf { axis, value } => {
1425                    assert_eq!(axis, "elevation");
1426                    // NaN never equals itself; compare bit patterns so the payload is pinned
1427                    // for every case including the non-finite ones.
1428                    assert_eq!(
1429                        value.to_bits(),
1430                        bad.to_bits(),
1431                        "payload must echo the input"
1432                    );
1433                }
1434                other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1435            }
1436        }
1437        // The band's interior is accepted, so the guard is not simply refusing everything.
1438        let mut req = plain_request((200.0, 800.0), 0.1, 50);
1439        req.elevation_cf = 0.95;
1440        assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1441    }
1442
1443    /// Same for the WINDAGE axis -- a per-axis check, because one shared guard covering only
1444    /// the elevation field would pass an elevation-only test and still ship the bug.
1445    #[test]
1446    fn out_of_band_windage_tracking_cf_is_rejected() {
1447        let curve = test_curve(900.0);
1448        for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1449            let mut req = plain_request((200.0, 800.0), 0.1, 50);
1450            req.windage_cf = bad;
1451            let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1452                .expect_err("an out-of-band windage CF must be refused, not answered");
1453            match err {
1454                CardError::InvalidTrackingCf { axis, value } => {
1455                    assert_eq!(axis, "windage");
1456                    assert_eq!(
1457                        value.to_bits(),
1458                        bad.to_bits(),
1459                        "payload must echo the input"
1460                    );
1461                }
1462                other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1463            }
1464        }
1465        let mut req = plain_request((200.0, 800.0), 0.1, 50);
1466        req.windage_cf = 1.05;
1467        assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1468
1469        // Elevation is reported first when both axes are bad, so the message names one
1470        // concrete axis rather than a vague "a tracking factor".
1471        let mut req = plain_request((200.0, 800.0), 0.1, 50);
1472        req.elevation_cf = 95.0;
1473        req.windage_cf = 95.0;
1474        assert_eq!(
1475            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1476            CardError::InvalidTrackingCf {
1477                axis: "elevation",
1478                value: 95.0
1479            }
1480        );
1481        // And it renders as a sentence that names the ratio-vs-percentage trap.
1482        let text = CardError::InvalidTrackingCf {
1483            axis: "elevation",
1484            value: 95.0,
1485        }
1486        .to_string();
1487        assert!(
1488            text.contains("elevation") && text.contains("0.5") && text.contains("1.5"),
1489            "{text}"
1490        );
1491    }
1492
1493    /// Task 12: `adaptive-card -o json` prints this report pretty-printed VERBATIM (no
1494    /// hand-rebuilt `json!` object, unlike the four Task 9 surfaces), so the derived
1495    /// `Serialize` impl IS the wire contract. Pins the field names a CLI/binding consumer
1496    /// would rely on, and that a row's `None` fields serialize as `null` (never dropped),
1497    /// matching `CardRow`'s existing "every surface populates only the fields it has"
1498    /// convention -- a `skip_serializing_if` would silently make an adaptive row's JSON
1499    /// shape depend on which fields happened to be absent.
1500    #[test]
1501    fn report_serializes_verbatim_with_stable_field_names() {
1502        let curve = test_curve(900.0);
1503        let req = plain_request((200.0, 400.0), 0.2, 50);
1504        let report =
1505            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1506
1507        let json = serde_json::to_value(&report).expect("report must serialize");
1508        assert_eq!(json["schema_version"], ADAPTIVE_CARD_SCHEMA_VERSION_V1);
1509        assert_eq!(
1510            json["method"],
1511            "greedy_worst_point_insertion_on_holdcurve_grid_v1"
1512        );
1513        assert_eq!(
1514            json["assumptions"]
1515                .as_array()
1516                .expect("assumptions array")
1517                .len(),
1518            5
1519        );
1520        assert_eq!(json["budget_met"], report.budget_met);
1521        assert_eq!(json["rows_capped"], report.rows_capped);
1522        assert!(json.get("worst_elevation_error").is_some());
1523        assert!(json.get("worst_windage_error").is_some());
1524        assert!(json.get("worst_error_range_m").is_some());
1525        assert!(json.get("verification_grid_step_m").is_some());
1526
1527        let rows = json["rows"].as_array().expect("rows array");
1528        assert_eq!(rows.len(), report.rows.len());
1529        let first = &rows[0];
1530        assert!(first["range"].is_number());
1531        assert!(
1532            first["drop_adj"].is_number(),
1533            "a populated Some(..) field must serialize as a number"
1534        );
1535        // Fields every adaptive row leaves `None` serialize as explicit JSON null, not as an
1536        // absent key -- a consumer can tell "never populated by this engine" from "absent
1537        // because of a version skew" only if the key is always present.
1538        assert!(first["come_up"].is_null());
1539        assert!(first["lead_adj"].is_null());
1540        assert_eq!(first["wind_columns"], serde_json::json!([]));
1541    }
1542
1543    /// Review fix I-4 (review of `4e69435`): the test above pinned only 5 of `CardRow`'s 11
1544    /// fields (`range`, `drop_adj`, `come_up`, `lead_adj`, `wind_columns`) -- `drop_linear`,
1545    /// `wind_linear`, `wind_adj`, `velocity`, `energy`, `time` were unpinned key names.
1546    /// `CardRow` is a *shared* internal type Task 9 itself created by unifying four other
1547    /// structs one task ago; a further rename during refactoring is a live possibility, and
1548    /// it would silently invalidate a wire published under `ADAPTIVE_CARD_SCHEMA_VERSION_V1`
1549    /// (and the 45-line worked JSON example in CLI_USAGE.md) with nothing here noticing.
1550    ///
1551    /// Every field gets its own pairwise-distinct sentinel (all `Some`, unlike the report-level
1552    /// test above which reads a real, physics-derived row) so a field-name<->value
1553    /// transposition -- not just a missing key -- fails this, and the row's key COUNT is
1554    /// pinned too, so a future field addition/removal is caught even if its name happens to
1555    /// collide with an existing sentinel value.
1556    #[test]
1557    fn card_row_field_names_bind_to_their_sentinel_values_in_json() {
1558        let row = CardRow {
1559            range: 111.1,
1560            drop_linear: Some(222.2),
1561            drop_adj: Some(333.3),
1562            come_up: Some(444.4),
1563            wind_linear: Some(555.5),
1564            wind_adj: Some(666.6),
1565            velocity: Some(777.7),
1566            energy: Some(888.8),
1567            time: Some(9.99),
1568            lead_adj: Some(101.1),
1569            wind_columns: vec![1.0, 2.0, 3.0],
1570        };
1571        let json = serde_json::to_value(&row).expect("row must serialize");
1572
1573        assert_eq!(json["range"], 111.1);
1574        assert_eq!(json["drop_linear"], 222.2);
1575        assert_eq!(json["drop_adj"], 333.3);
1576        assert_eq!(json["come_up"], 444.4);
1577        assert_eq!(json["wind_linear"], 555.5);
1578        assert_eq!(json["wind_adj"], 666.6);
1579        assert_eq!(json["velocity"], 777.7);
1580        assert_eq!(json["energy"], 888.8);
1581        assert_eq!(json["time"], 9.99);
1582        assert_eq!(json["lead_adj"], 101.1);
1583        assert_eq!(json["wind_columns"], serde_json::json!([1.0, 2.0, 3.0]));
1584
1585        // No extra keys, and none silently dropped: exactly CardRow's 11 fields.
1586        assert_eq!(
1587            json.as_object()
1588                .expect("row must serialize to a JSON object")
1589                .len(),
1590            11
1591        );
1592    }
1593}