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