Skip to main content

ballistics_engine/
optic.rs

1//! Turret and reticle geometry: click detents, revolutions, physical travel, current dial
2//! state, and reticle hold limits (MBA-1348).
3//!
4//! Before this module the crate's entire optic model was `crate::adjustment::ClickValue`
5//! plus two tracking correction factors (elevation/windage CF), applied ad hoc wherever a
6//! dialed number needed to become a true angular one. Real turrets have more structure
7//! than that: click detents grouped into revolutions, an optional zero stop, finite
8//! mechanical travel, and a *current* dialed offset from zero. Real reticles have a finite
9//! usable hold extent too. `OpticProfile` gives all of that a home so a later dial/hold/
10//! hybrid engagement planner has somewhere to read it from. This module is pure data and
11//! validation — it does not itself decide how to engage a target.
12//!
13//! # MIL, always
14//!
15//! Every angular field in this module — travel, turret state, hold bounds — is in
16//! milliradians. There is no unit-selection knob here; presenting other units (MOA, SMOA,
17//! whole clicks) is a front-end concern, the same way `crate::adjustment` already handles
18//! it for the CLI and the WASM terminal.
19//!
20//! # DIAL-space vs TRUE-angular — the one distinction this module exists to encode
21//!
22//! Two different kinds of "mil" appear in this crate and they are NOT interchangeable:
23//!
24//! - **Turret quantities** — `clicks_per_revolution`, `TravelLimits`, `TurretState`, and
25//!   clicks generally — live in **DIAL-space**: the number engraved on the turret, the
26//!   number a shooter actually dials. A scope with a tracking correction factor (CF)
27//!   below 1.0 delivers LESS true angular motion per dialed unit than the engraving
28//!   claims, so it takes MORE dial to reach a given true angle. This crate's established
29//!   convention for that relationship — see `crate::adjustment::zero_banner_dial_values`
30//!   and `crate::truing::scale_report_dial_values` — is that dial-space OUTPUTS are
31//!   obtained by DIVIDING a true angular value by the CF. This module's turret types
32//!   inherit that convention unchanged: they do not carry or apply a CF themselves, they
33//!   simply live in the same DIAL-space that convention produces.
34//! - **Reticle holds** elsewhere in the crate (`crate::reticle::ReticleHold`) are **TRUE
35//!   angular** and are never CF-scaled — a mil subtension etched in glass does not care
36//!   how the turret happens to be calibrated.
37//!
38//! A hold and a dialed correction for the identical point of impact are therefore
39//! numerically different whenever CF != 1.0. Code that mixes the two spaces without an
40//! explicit conversion will silently compute nonsense.
41//!
42//! # Travel and turret state are measured from the CURRENT ZERO, not the mechanical stop
43//!
44//! `TravelLimits` and `TurretState` are offsets from wherever the shooter has zeroed the
45//! rifle, not from the turret's mechanical bottom. That is the fact a shooter actually
46//! knows and can act on in the field — "I have 28 mil of up travel left from my zero" —
47//! not a fact about the turret's total mechanical range, which also depends on where in
48//! that range the zero happens to sit and is not tracked here.
49//!
50//! # `reticle_hold_bounds` is an explicit input, never derived
51//!
52//! See `HoldBounds` for why: `crate::reticle`'s `off_reticle` bounding box is a property
53//! of which marks someone chose to author, grown by a fixed margin, not a property of the
54//! scope's actual usable optical extent. It must never be reused as a stand-in for a real
55//! spec.
56//!
57//! # `plan_corrections` and the CF rule
58//!
59//! [`plan_corrections`] turns a TRUE angular [`AngularCorrection`] into ranked, executable
60//! [`DialPlan`]s: dial the whole correction in whole clicks, hold the whole correction on
61//! the reticle, or split it (dial what the turret can reach, hold the TRUE angular
62//! remainder). Getting the DIAL-space/TRUE-angular distinction above right in every arm is
63//! the entire reason this function exists, so the rule is restated here in full and
64//! followed literally everywhere below:
65//!
66//! - Turret travel, turret state, and click counts all live in DIAL space, as established
67//!   above.
68//! - A tracking correction factor (CF) maps one space to the other. To EXECUTE a TRUE
69//!   angular need of `corr_true` mil on a turret whose CF is `cf`, the DIAL target is
70//!   `corr_dial = corr_true / cf` — the CF DIVIDES going from a true need to a dial target,
71//!   the same direction as `crate::adjustment::zero_banner_dial_values` and
72//!   `crate::truing::scale_report_dial_values`. That target is quantized onto whole clicks
73//!   IN DIAL SPACE, via `quantize_angle(corr_dial, &ClickValue { size:
74//!   click_size_mil(click), base: ClickBase::Mil })` — a SYNTHETIC click value in mil so
75//!   the reconstruction identity (`clicks as f64 * size + residual == corr_dial`) holds in
76//!   mil regardless of whether the real click graduation is mil, MOA, or SMOA. Whatever
77//!   whole click count results, the dial then EXECUTES `clicks as f64 *
78//!   click_size_mil(click) * cf` TRUE angular mil — the CF MULTIPLIES going from dial
79//!   clicks back to true angular, the OPPOSITE direction from how it was applied going in.
80//! - Reticle holds are TRUE angular and are NEVER CF-scaled, anywhere: a hold component is
81//!   computed and bounds-checked entirely in true mil, never quantized onto a click.
82//!
83//! `cf_dial_space_worked_example` (this module's test suite) pins the hand-derived numbers
84//! for a CF of 0.98: a 5.0 true-mil correction on 0.1-mil clicks needs a dial target of
85//! `5.0 / 0.98 = 5.10204...` mil, which quantizes to 51 clicks; those 51 clicks EXECUTE
86//! `51 * 0.1 * 0.98 = 4.998` true mil, leaving a 0.002 true-mil hybrid hold. Swapping the
87//! multiply/divide directions, or holding the DIAL-space remainder (`corr_dial - clicks *
88//! size`) instead of the TRUE one (`corr_true - dial_mil_true`), silently produces a
89//! different, wrong number here — this module exists specifically to make that mistake
90//! impossible to make quietly.
91//!
92//! # Honesty: nothing is silently clamped
93//!
94//! A plan whose dial component cannot reach its target given the optic's declared travel
95//! is still returned — clamped to the travel it actually has — but its `feasible` field is
96//! `false` and the clamp is recorded in `limits_hit` as a [`LimitViolation`]. The same is
97//! true of a hold that exceeds `reticle_hold_bounds` or [`Preferences::max_hold_mil`]. A
98//! plan's `residual_mil` is always computed against what it actually executes, never
99//! against the original request, so a clamped plan's residual honestly reflects the
100//! resulting miss (see `infeasible_is_reported_never_silently_clamped`).
101//!
102//! Declared travel/hold data is trusted exactly as given; its ABSENCE is a different,
103//! weaker claim than a KNOWN, EXCEEDED limit, and gets its own [`LimitKind`]
104//! (`NoTravelData`/`NoHoldBoundData`) rather than being silently treated as "unlimited."
105//! Whether a missing or exceeded limit turns off a strategy's OWN `feasible` flag depends
106//! on what that strategy actually promises: [`Strategy::DialAll`] promises to deliver the
107//! WHOLE correction by dialing alone, so it cannot affirm that promise without knowing
108//! (and fitting inside) its travel. [`Strategy::HoldAll`] and [`Strategy::Hybrid`] promise
109//! (respectively) the whole correction, or the dial shortfall's exact remainder, via the
110//! reticle, so THEY cannot affirm their promise without knowing (and fitting inside) hold
111//! bounds — but a `Hybrid` plan's dial component absorbing less than the full correction
112//! because travel ran out is not a broken promise (the hold is DEFINED to absorb exactly
113//! that shortfall, by construction), so a missing or exceeded TRAVEL limit is recorded on
114//! a `Hybrid` plan purely for disclosure and never by itself makes it infeasible — only
115//! its own hold not fitting does.
116//!
117//! `OpticProfile::zero_stop` is never read by `plan_corrections` at all, at travel-known or
118//! travel-`None` alike: it is descriptive turret metadata (see its own doc comment), and
119//! this module's ONLY source of truth for what the turret can physically reach is
120//! `elevation_travel`/`windage_travel` being `Some` or `None` — a zero-stopped turret with
121//! no declared `elevation_travel` is treated exactly like a non-zero-stopped one with no
122//! declared travel: unverifiable, not "safely bounded at zero because it says zero_stop."
123//!
124//! # Correction-space is not reticle-space (the hold-bound mapping)
125//!
126//! `hold_mil` above is this module's own correction-space: `+` means "the point of impact
127//! needs to move up/right," matching `AngularCorrection` and a dial's own convention (a
128//! positive dial adjustment moves point of impact up/right). `HoldBounds`
129//! (`up_mil`/`down_mil`/`left_mil`/`right_mil`) is `crate::reticle`'s RETICLE-space
130//! instead, pinned at `src/reticle.rs:30-42`: `down_mil` is positive BELOW the optical
131//! center, `right_mil` is positive to the shooter's RIGHT of it, and a holdover mark
132//! (compensating a bullet that falls) sits at POSITIVE `down_mil` — below center. These
133//! two spaces are OPPOSITELY signed on BOTH axes, not just one: a positive (UP)
134//! correction's hold mark sits BELOW center (consumes `down_mil`), exactly like every BDC
135//! reticle's long-range holdover marks, which sit below center, never above it; a positive
136//! (RIGHT) correction's hold mark sits to the LEFT of center (consumes `left_mil`) by the
137//! identical argument, mirrored. `AxisComputation` names its two fields
138//! `bound_for_positive_correction` / `bound_for_negative_correction` (rather than e.g.
139//! "`hold_up`") specifically so this mapping is stated at the type level and cannot
140//! silently re-invert; see the
141//! `hold_bound_mapping_matches_reticle_space_not_correction_space` test, which is built to
142//! fail if it does.
143
144use serde::{Deserialize, Serialize};
145use thiserror::Error;
146
147use crate::adjustment::{click_size_mil, quantize_angle, ClickBase, ClickValue};
148
149/// A shooter's complete turret and reticle geometry (MBA-1348).
150///
151/// Every angular field anywhere in this type is in MIL. Turret-related fields
152/// (`clicks_per_revolution`, `elevation_travel`, `windage_travel`, `turret_state`) are in
153/// DIAL-space; `reticle_hold_bounds` is TRUE angular. See the module docs for what that
154/// distinction means and why it matters.
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct OpticProfile {
157    /// Elevation turret's click graduation (its engraved size, e.g. 0.1 mil or 1/4 MOA).
158    /// Serializes as `crate::adjustment::ClickValue`'s own suffixed string (`"0.1mil"`),
159    /// not a structural object. `validate` requires a positive size.
160    pub elevation_click: ClickValue,
161    /// Windage turret's click graduation. Same serialized form and `validate` rule as
162    /// `elevation_click`.
163    pub windage_click: ClickValue,
164    /// Click detents per full turret revolution, for turrets whose cap marks revolutions
165    /// at all (many hunting turrets do not, hence `Option`). Must be at least 1 when
166    /// present — `Some(0)` is rejected by `validate`, since a revolution with zero clicks
167    /// in it is not a revolution.
168    pub clicks_per_revolution: Option<u32>,
169    /// Whether the elevation turret hard-stops at `elevation_travel.down_mil`, so the
170    /// shooter cannot dial below zero at all. Descriptive metadata about the mechanism:
171    /// `validate` does not require `elevation_travel` to be `Some` just because
172    /// `zero_stop` is `true`, or vice versa.
173    pub zero_stop: bool,
174    /// Elevation travel remaining in each direction **from the current zero setting**
175    /// (not from the mechanical bottom of the turret) — DIAL-space, mil.
176    pub elevation_travel: Option<TravelLimits>,
177    /// Windage travel from the current zero — DIAL-space, mil. `down_mil` is LEFT travel,
178    /// `up_mil` is RIGHT travel (see `TravelLimits`).
179    pub windage_travel: Option<TravelLimits>,
180    /// The turret's current dialed offset from zero — DIAL-space, mil.
181    pub turret_state: Option<TurretState>,
182    /// The reticle's usable hold extent — TRUE angular, mil. See `HoldBounds`: this is an
183    /// EXPLICIT input and is never derived from a `crate::reticle::ReticleDescription`.
184    pub reticle_hold_bounds: Option<HoldBounds>,
185}
186
187/// Remaining mechanical turret travel in each direction from the current zero setting,
188/// DIAL-space mil. Both fields are magnitudes — never negative — measured outward from
189/// zero: `down_mil` is travel available going down (elevation) or left (windage);
190/// `up_mil` is travel available going up (elevation) or right (windage). `validate`
191/// rejects negative and non-finite values.
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193pub struct TravelLimits {
194    pub down_mil: f64,
195    pub up_mil: f64,
196}
197
198/// The turret's current dialed position, as signed offsets from zero — DIAL-space, mil.
199/// Positive `elevation_mil` is dialed up from zero; positive `windage_mil` is dialed right
200/// from zero. Unlike `TravelLimits` these ARE signed (a turret can be dialed to either
201/// side of zero), so `validate` does not reject a negative value on its own — it only
202/// rejects a state whose magnitude on an axis exceeds that axis's declared
203/// `TravelLimits`, when both are present.
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205pub struct TurretState {
206    pub elevation_mil: f64,
207    pub windage_mil: f64,
208}
209
210/// A reticle's usable hold extent in each direction, TRUE angular mil, relative to the
211/// reticle's own center. All four fields are magnitudes — never negative.
212///
213/// This is an EXPLICIT input describing the scope's actual usable optical extent —
214/// typically read off the manufacturer's spec sheet or a bench measurement — and must
215/// NEVER be derived from `crate::reticle::ReticleDescription`. That type's `off_reticle`
216/// bounding box is grown from wherever someone chose to author hash marks, padded by a
217/// fixed margin fraction; it describes an authoring choice, not the glass. A reticle can
218/// be drawn with marks stopping well short of where the image circle actually vignettes,
219/// or drawn past it. Treating one as a proxy for the other would silently misstate how
220/// far a shooter can actually hold.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct HoldBounds {
223    pub up_mil: f64,
224    pub down_mil: f64,
225    pub left_mil: f64,
226    pub right_mil: f64,
227}
228
229/// Why an `OpticProfile` failed `validate`.
230#[derive(Debug, Clone, PartialEq, Error)]
231pub enum OpticError {
232    /// A field that must be finite was NaN or infinite.
233    #[error("{field} must be finite")]
234    NonFinite { field: &'static str },
235    /// A travel or hold-bound magnitude was negative.
236    #[error("{field} must not be negative (got {value})")]
237    NegativeLimit { field: &'static str, value: f64 },
238    /// A click graduation's `size` was zero or negative. `ClickValue`'s fields are `pub`,
239    /// so this is reachable by direct construction even though `parse_click_value`
240    /// (`crate::adjustment`) already excludes it on every string-parsed or deserialized
241    /// click — see that function and `ClickValue`'s `Deserialize` impl.
242    #[error("{field} must be a positive click size (got {size})")]
243    NonPositiveClickSize { field: &'static str, size: f64 },
244    /// `clicks_per_revolution` was `Some(0)`. Zero clicks is not a revolution.
245    #[error("clicks_per_revolution must be at least 1 when present, got 0")]
246    ZeroClicksPerRevolution,
247    /// `turret_state`'s value on `axis` fell outside that axis's declared
248    /// `TravelLimits` (both measured from the same zero).
249    #[error(
250        "{axis} turret state at {dialed_mil} mil from zero is outside its travel of \
251         -{down_mil}..={up_mil} mil"
252    )]
253    StateOutsideTravel {
254        axis: &'static str,
255        dialed_mil: f64,
256        down_mil: f64,
257        up_mil: f64,
258    },
259    /// A tracking correction factor (`elevation_cf`/`windage_cf` in [`plan_corrections`])
260    /// was zero, negative, or non-finite (MBA-1348 review fix). `plan_corrections` divides
261    /// a TRUE angular correction by the CF to get a DIAL target (see the module's "CF
262    /// rule" doc section); a zero or negative CF turns that into infinity, NaN, or a
263    /// direction-inverting garbage value that would silently propagate into a "plan" that
264    /// looks like ordinary output. Rejected outright rather than merely warned about --
265    /// `crate::adjustment::tracking_cf_in_range`'s tighter `(0.5, 1.5)` plausibility band
266    /// is deliberately NOT re-enforced here, matching this crate's existing precedent
267    /// (`crate::truing::scale_report_dial_values` also takes a bare, unchecked `dial_cf`)
268    /// of treating that band as an advisory, caller/CLI-level concern, not a hard
269    /// library-level bound -- unlike `card::AdaptiveRequest`'s hard enforcement of the same
270    /// band (see its own doc comment): the planner does not enforce it here because
271    /// `plan_corrections`'s residual stays honest under a wild CF, while the card engine
272    /// does enforce it because a large finite CF there would otherwise produce a confident
273    /// `budget_met: true` on a card that does not actually meet its stated error budget.
274    #[error("{field} must be a positive, finite tracking factor (got {value})")]
275    NonPositiveTrackingFactor { field: &'static str, value: f64 },
276}
277
278impl OpticProfile {
279    /// Checks internal consistency: every angular field is finite, both click sizes are
280    /// positive, every travel/hold-bound magnitude is non-negative, `clicks_per_revolution`
281    /// is a sane count, and — when both are present — `turret_state` lies within
282    /// `elevation_travel` / `windage_travel`.
283    ///
284    /// This cannot and does not check that the profile matches any real, physical scope;
285    /// it only rejects shapes that are self-contradictory or would corrupt downstream
286    /// arithmetic (a zero-length revolution, NaN propagation, a dialed state the turret
287    /// could not physically reach given its own declared travel, or a zero/negative click
288    /// size that would turn a later `quantize_angle` division into infinity or NaN).
289    pub fn validate(&self) -> Result<(), OpticError> {
290        require_finite("elevation_click.size", self.elevation_click.size)?;
291        require_positive_click_size("elevation_click.size", self.elevation_click.size)?;
292        require_finite("windage_click.size", self.windage_click.size)?;
293        require_positive_click_size("windage_click.size", self.windage_click.size)?;
294
295        if self.clicks_per_revolution == Some(0) {
296            return Err(OpticError::ZeroClicksPerRevolution);
297        }
298
299        if let Some(travel) = &self.elevation_travel {
300            validate_travel("elevation_travel.down_mil", "elevation_travel.up_mil", travel)?;
301        }
302        if let Some(travel) = &self.windage_travel {
303            validate_travel("windage_travel.down_mil", "windage_travel.up_mil", travel)?;
304        }
305        if let Some(state) = &self.turret_state {
306            require_finite("turret_state.elevation_mil", state.elevation_mil)?;
307            require_finite("turret_state.windage_mil", state.windage_mil)?;
308        }
309        if let Some(bounds) = &self.reticle_hold_bounds {
310            for (field, value) in [
311                ("reticle_hold_bounds.up_mil", bounds.up_mil),
312                ("reticle_hold_bounds.down_mil", bounds.down_mil),
313                ("reticle_hold_bounds.left_mil", bounds.left_mil),
314                ("reticle_hold_bounds.right_mil", bounds.right_mil),
315            ] {
316                require_finite(field, value)?;
317                require_non_negative(field, value)?;
318            }
319        }
320
321        // Cross-field: the dialed state must be reachable given the declared travel, on
322        // each axis independently. Runs last, after every field involved has already been
323        // proven finite and non-negative above, so no NaN/negative can slip into the
324        // comparison.
325        if let (Some(state), Some(travel)) = (&self.turret_state, &self.elevation_travel) {
326            check_state_within_travel("elevation", state.elevation_mil, travel)?;
327        }
328        if let (Some(state), Some(travel)) = (&self.turret_state, &self.windage_travel) {
329            check_state_within_travel("windage", state.windage_mil, travel)?;
330        }
331
332        Ok(())
333    }
334}
335
336/// `value` must be finite, or `field` (its dotted name, e.g. `"elevation_travel.up_mil"`)
337/// is reported non-finite.
338fn require_finite(field: &'static str, value: f64) -> Result<(), OpticError> {
339    if value.is_finite() {
340        Ok(())
341    } else {
342        Err(OpticError::NonFinite { field })
343    }
344}
345
346/// `value` must be a non-negative magnitude, or `field` is reported as a negative limit.
347/// Callers must check finiteness first: `NaN >= 0.0` is `false`, so this alone would
348/// misreport a non-finite value as merely negative.
349fn require_non_negative(field: &'static str, value: f64) -> Result<(), OpticError> {
350    if value >= 0.0 {
351        Ok(())
352    } else {
353        Err(OpticError::NegativeLimit { field, value })
354    }
355}
356
357/// `size` must be strictly positive (a zero or negative click graduation is meaningless
358/// and would make `quantize_angle`'s `angle / click.size` divide by zero or flip sign).
359/// Callers must check finiteness first, for the same reason as `require_non_negative`:
360/// `NaN > 0.0` is `false`, so this alone would misreport a non-finite size as merely
361/// non-positive.
362fn require_positive_click_size(field: &'static str, size: f64) -> Result<(), OpticError> {
363    if size > 0.0 {
364        Ok(())
365    } else {
366        Err(OpticError::NonPositiveClickSize { field, size })
367    }
368}
369
370/// `value` must be a strictly positive, finite tracking correction factor, or `field` is
371/// reported via [`OpticError::NonPositiveTrackingFactor`] (MBA-1348 review fix). A single
372/// check for both finiteness and positivity, since a CF is consumed by DIVISION
373/// (`plan_corrections`' "the CF rule"): zero divides to infinity, negative flips every
374/// direction, and either would otherwise reach `quantize_angle` as silent garbage.
375fn require_positive_tracking_factor(field: &'static str, value: f64) -> Result<(), OpticError> {
376    if value.is_finite() && value > 0.0 {
377        Ok(())
378    } else {
379        Err(OpticError::NonPositiveTrackingFactor { field, value })
380    }
381}
382
383/// Finiteness and non-negativity for one `TravelLimits`, under the caller's own dotted
384/// field names for `down_mil` / `up_mil` (so the same helper serves both the elevation
385/// and windage axes without hand-rolling the check twice).
386fn validate_travel(
387    down_field: &'static str,
388    up_field: &'static str,
389    travel: &TravelLimits,
390) -> Result<(), OpticError> {
391    require_finite(down_field, travel.down_mil)?;
392    require_finite(up_field, travel.up_mil)?;
393    require_non_negative(down_field, travel.down_mil)?;
394    require_non_negative(up_field, travel.up_mil)?;
395    Ok(())
396}
397
398/// `dialed_mil` (already known finite) must lie within `[-travel.down_mil,
399/// travel.up_mil]` (already known finite and non-negative), the closed interval the
400/// turret can physically reach from zero on this axis.
401fn check_state_within_travel(
402    axis: &'static str,
403    dialed_mil: f64,
404    travel: &TravelLimits,
405) -> Result<(), OpticError> {
406    if dialed_mil > travel.up_mil || dialed_mil < -travel.down_mil {
407        Err(OpticError::StateOutsideTravel {
408            axis,
409            dialed_mil,
410            down_mil: travel.down_mil,
411            up_mil: travel.up_mil,
412        })
413    } else {
414        Ok(())
415    }
416}
417
418/// Splits a DIAL-space click count measured from zero into `(revolutions,
419/// clicks_within_the_revolution)`, for a turret whose cap marks revolutions every
420/// `clicks_per_revolution` clicks — e.g. rendering "2 revolutions + 7 clicks" instead of a
421/// bare click count the shooter has to do the division on in their head under time
422/// pressure.
423///
424/// `clicks_from_zero` is DIAL-space, measured from the CURRENT ZERO (see the module
425/// docs) — like every other turret quantity here, NOT from the turret's mechanical
426/// bottom.
427///
428/// Returns `None` for:
429/// - a negative `clicks_from_zero`. Turret revolution indicators count UP from zero, so a
430///   setting below zero has no revolution to report: "-7 clicks" is unambiguous, while
431///   any revolutions-plus-clicks decomposition of a negative count invents a direction of
432///   revolution the turret does not have. Callers with a below-zero setting should render
433///   the plain (negative) click count directly instead of calling this function.
434/// - `clicks_per_revolution == 0`, which has no meaningful revolution length (and would
435///   be a division by zero). `OpticProfile::validate` rejects `Some(0)` for the analogous
436///   reason, but this free function takes a bare `u32`, not an `OpticProfile`, and so has
437///   no other way to reject an invalid revolution length.
438///
439/// Otherwise returns `Some((revolutions, clicks_in_revolution))` with
440/// `clicks_in_revolution < clicks_per_revolution` and the reconstruction identity
441/// `revolutions * clicks_per_revolution + clicks_in_revolution == clicks_from_zero`
442/// (ordinary non-negative integer division and remainder).
443pub fn revolution_annotation(
444    clicks_from_zero: i64,
445    clicks_per_revolution: u32,
446) -> Option<(u32, u32)> {
447    if clicks_from_zero < 0 || clicks_per_revolution == 0 {
448        return None;
449    }
450    let cpr = i64::from(clicks_per_revolution);
451    let revolutions = clicks_from_zero / cpr;
452    let clicks_in_revolution = clicks_from_zero % cpr;
453    // clicks_from_zero >= 0 and cpr > 0 (checked above), so both quotient and remainder
454    // are non-negative; a value that does not fit u32 is not a real turret setting.
455    Some((revolutions as u32, clicks_in_revolution as u32))
456}
457
458// ============================================================================================
459// MBA-1348 Task 4: `plan_corrections` -- dial / hold / hybrid execution planning
460// ============================================================================================
461
462/// Schema version for [`DialPlanReportV1`]'s payload shape (MBA-1348).
463pub const DIAL_PLAN_SCHEMA_VERSION_V1: u32 = 1;
464
465/// A TRUE angular correction to deliver on target -- the output of a solve, not yet
466/// realized as any turret or reticle instruction. Positive `elevation_mil` is UP; positive
467/// `windage_mil` is RIGHT. Never CF-scaled -- see the module's "CF rule" doc section.
468#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
469pub struct AngularCorrection {
470    pub elevation_mil: f64,
471    pub windage_mil: f64,
472}
473
474/// Caller preferences steering [`plan_corrections`]'s ranking -- never its underlying
475/// arithmetic, which is identical regardless of these values. See "Ranking is
476/// deterministic" in [`plan_corrections`]'s own doc comment.
477///
478/// `#[derive(Default)]` (`prefer_hold: false, max_hold_mil: None` -- prefer dial, no extra
479/// hold cap) rather than a hand-written impl: `bool`'s and `Option`'s own defaults already
480/// produce exactly this (MBA-1348 review fix -- confirmed equivalent, not just
481/// clippy-driven).
482#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
483pub struct Preferences {
484    /// When ranking ties on `residual_linear_at_range_m`, prefer holding over dialing.
485    /// `false` (the [`Default`] impl's value) prefers dialing.
486    pub prefer_hold: bool,
487    /// An optional blanket cap on any single hold component's magnitude, TRUE angular mil,
488    /// applied together with (the tighter of it and) `OpticProfile::reticle_hold_bounds`.
489    /// `None` (the default) applies no cap beyond `reticle_hold_bounds` itself.
490    pub max_hold_mil: Option<f64>,
491}
492
493/// Which of a shot's two independent angular axes an [`AxisInstruction`] or
494/// [`LimitViolation`] belongs to (MBA-1348). Unrelated to `crate::reticle_import`'s
495/// private, importer-internal `Axis` of the same name in a different module.
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
497#[serde(rename_all = "snake_case")]
498pub enum Axis {
499    Elevation,
500    Windage,
501}
502
503/// How a [`DialPlan`] proposes to deliver a correction (MBA-1348).
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
505#[serde(rename_all = "snake_case")]
506pub enum Strategy {
507    /// Dial the whole correction in whole clicks; hold nothing.
508    DialAll,
509    /// Dial nothing; hold the whole correction on the reticle.
510    HoldAll,
511    /// Dial the whole clicks the turret can reach (travel-clamped if necessary); hold the
512    /// TRUE angular remainder on the reticle. Always reconstructs the correction exactly --
513    /// see the module's "CF rule" doc section.
514    Hybrid,
515}
516
517/// Why a [`DialPlan`] component was clamped, or could not be verified feasible (MBA-1348).
518/// Paired with the offending [`Axis`] in a [`LimitViolation`].
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "snake_case")]
521pub enum LimitKind {
522    /// The dial target exceeded the axis's declared `TravelLimits` and was clamped to it.
523    TravelExceeded,
524    /// The hold component exceeded the axis's declared `HoldBounds` and/or
525    /// `Preferences::max_hold_mil`. Holds are never clamped -- see `Strategy::HoldAll`.
526    HoldBoundExceeded,
527    /// A nonzero dial target was needed, but the optic profile declares no `TravelLimits`
528    /// for this axis, so reachability could not be verified. NOT the same claim as
529    /// `TravelExceeded` -- see the module's "Honesty" doc section.
530    NoTravelData,
531    /// A nonzero hold was needed, but neither `HoldBounds` nor `Preferences::max_hold_mil`
532    /// is declared for this axis, so fitting the reticle could not be verified.
533    NoHoldBoundData,
534}
535
536/// One recorded reason a [`DialPlan`] was clamped or could not be verified feasible
537/// (MBA-1348). Always DISCLOSED in `DialPlan::limits_hit`, never silently absorbed.
538#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
539pub struct LimitViolation {
540    pub axis: Axis,
541    pub kind: LimitKind,
542    /// The signed value that triggered this record, BEFORE any clamp: DIAL-space mil for
543    /// `TravelExceeded`/`NoTravelData` (comparable to `available_mil`, also dial-space);
544    /// TRUE angular mil for `HoldBoundExceeded`/`NoHoldBoundData`.
545    pub needed_mil: f64,
546    /// The declared limit's magnitude on the exceeded side, when known. `None` for
547    /// `NoTravelData`/`NoHoldBoundData` -- that absence is the entire point of those kinds.
548    pub available_mil: Option<f64>,
549}
550
551/// `AxisInstruction::direction`: which way the DELTA actually dialed turns the turret
552/// (MBA-1348). Wire form is lowercase (`"up"`/`"down"`/`"left"`/`"right"`) via
553/// `#[serde(rename_all)]` -- unchanged from this type's original plain-`&str` form (MBA-1348
554/// review fix M1), but now a real enum so `AxisInstruction`/`DialPlan`/`DialPlanReportV1`
555/// can derive `Deserialize` again (a `&'static str` field cannot: serde's blanket impl for
556/// `&'a str` ties `'a` to the deserializer's own input lifetime, which is essentially never
557/// `'static`).
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
559#[serde(rename_all = "snake_case")]
560pub enum Direction {
561    Up,
562    Down,
563    Left,
564    Right,
565}
566
567/// One axis's executable instruction within a [`DialPlan`] (MBA-1348).
568#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
569pub struct AxisInstruction {
570    pub axis: Axis,
571    /// For the DELTA actually dialed (`delta_clicks`'s sign) -- not for the correction's
572    /// own sign.
573    pub direction: Direction,
574    /// Clicks to turn from `OpticProfile::turret_state` (or zero, if absent) to
575    /// `target_clicks_from_zero`. Positive is up/right, matching `direction`.
576    pub delta_clicks: i64,
577    /// The DIAL setting this instruction ends at, measured from zero -- NOT from
578    /// `turret_state`. See the `turret_state_shifts_delta_but_not_target` test.
579    pub target_clicks_from_zero: i64,
580    /// `revolution_annotation(target_clicks_from_zero, clicks_per_revolution)`: `None`
581    /// when `clicks_per_revolution` is undeclared, or `target_clicks_from_zero` is
582    /// negative (see that function's own contract).
583    pub end_revolution: Option<(u32, u32)>,
584    /// What the dial EXECUTES in TRUE angular mil: `target_clicks_from_zero as f64 *
585    /// click_size_mil(click) * cf`. Zero for `Strategy::HoldAll`.
586    pub dial_mil_true: f64,
587    /// The TRUE angular reticle hold component, mil (+ = over/right). Zero for
588    /// `Strategy::DialAll`.
589    pub hold_mil: f64,
590    /// `correction − dial_mil_true − hold_mil`: the honest, post-execution miss on this
591    /// axis. Exactly `0.0` for `Strategy::Hybrid` (an identity by construction -- `hold_mil`
592    /// is DEFINED as whatever makes this exact -- not an approximation that merely happens
593    /// to come out small).
594    pub residual_mil: f64,
595}
596
597/// One ranked, executable plan for delivering a two-axis [`AngularCorrection`] (MBA-1348).
598/// `instructions` is always exactly `[elevation, windage]`.
599#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
600pub struct DialPlan {
601    pub strategy: Strategy,
602    pub instructions: [AxisInstruction; 2],
603    /// RSS of both axes' `residual_mil`, mil-to-linear at `DialPlanReportV1::range_m` by
604    /// the small-angle approximation (assumption 0). The ranking key AMONG EQUALLY-FEASIBLE
605    /// plans -- `feasible` itself is checked first (MBA-1348 review fix I4) -- see
606    /// [`plan_corrections`]'s doc comment.
607    pub residual_linear_at_range_m: f64,
608    /// `false` whenever a [`LimitViolation`] this strategy's OWN promise depends on is
609    /// present in `limits_hit` -- see the module's "Honesty" doc section for what each
610    /// strategy promises, and therefore what gates it.
611    pub feasible: bool,
612    pub limits_hit: Vec<LimitViolation>,
613}
614
615/// [`plan_corrections`]'s versioned report (MBA-1348). Carries `method` and `assumptions`
616/// in the payload itself -- this train's cross-cutting honesty principle (spec §2) -- not
617/// only in prose documentation.
618///
619/// Derives `Deserialize` as well as `Serialize` (MBA-1348 review fix M1): the original
620/// `AxisInstruction::direction: &'static str` field made this impossible (see
621/// [`Direction`]'s own doc), which is why that field is now a proper enum. Nothing in this
622/// train currently reads a `DialPlanReportV1` back from JSON -- Task 6's CLI only ever
623/// writes one out -- but there is no longer a structural reason it couldn't.
624#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
625pub struct DialPlanReportV1 {
626    pub schema_version: u32,
627    pub method: String,
628    pub assumptions: Vec<String>,
629    pub range_m: f64,
630    /// Ranked best-first -- see "Ranking is deterministic" in [`plan_corrections`]'s doc
631    /// comment.
632    pub plans: Vec<DialPlan>,
633}
634
635/// The largest whole-click count reachable within `boundary_mil` of DIAL-space travel
636/// without exceeding it (MBA-1348).
637///
638/// Ordinarily `(boundary_mil / click_mil).floor()`, EXCEPT when that ratio is within one
639/// billionth (`1e-9`) of a click of a whole number, where it rounds to that whole number
640/// instead -- so floating-point noise in the division (e.g. `0.4 / 0.1` landing a hair
641/// under `4.0`) cannot silently discard a click of real, declared travel (MBA-1348 review
642/// fix M2: corrected from an earlier, wrong "click-thousandth" description of this same
643/// `1e-9` tolerance). A genuinely fractional boundary (e.g. 0.45 mil of travel on a 0.1 mil
644/// click) still floors: 4, never rounding UP past a hard mechanical limit to 5.
645fn max_clicks_within(boundary_mil: f64, click_mil: f64) -> i64 {
646    let raw = boundary_mil / click_mil;
647    let nearest = raw.round();
648    if (nearest * click_mil - boundary_mil).abs() <= click_mil * 1e-9 {
649        nearest as i64
650    } else {
651        raw.floor() as i64
652    }
653}
654
655/// Quantizes `corr_true / cf` onto whole DIAL-space clicks (the CF rule's "way in": see the
656/// module doc), clamping to `travel` when declared and the unclamped target would exceed
657/// it, and recording exactly one [`LimitViolation`] when either the clamp happened or a
658/// nonzero target could not be checked because `travel` is `None` (MBA-1348). Returns
659/// `(clamped_target_clicks, corr_dial, violation)` -- `corr_dial` (the pre-quantization
660/// continuous dial-space value) is reused by callers as `LimitViolation::needed_mil`.
661fn quantize_and_clamp(
662    axis: Axis,
663    corr_true: f64,
664    click_mil: f64,
665    cf: f64,
666    travel: Option<&TravelLimits>,
667) -> (i64, f64, Option<LimitViolation>) {
668    let corr_dial = corr_true / cf;
669    let synthetic = ClickValue { size: click_mil, base: ClickBase::Mil };
670    let target = quantize_angle(corr_dial, &synthetic).clicks;
671    let violation = match travel {
672        None if target == 0 => None,
673        None => Some(LimitViolation {
674            axis,
675            kind: LimitKind::NoTravelData,
676            needed_mil: corr_dial,
677            available_mil: None,
678        }),
679        Some(t) => {
680            let max_up = max_clicks_within(t.up_mil, click_mil);
681            let max_down = max_clicks_within(t.down_mil, click_mil);
682            if target > max_up {
683                return (
684                    max_up,
685                    corr_dial,
686                    Some(LimitViolation {
687                        axis,
688                        kind: LimitKind::TravelExceeded,
689                        needed_mil: corr_dial,
690                        available_mil: Some(t.up_mil),
691                    }),
692                );
693            } else if target < -max_down {
694                return (
695                    -max_down,
696                    corr_dial,
697                    Some(LimitViolation {
698                        axis,
699                        kind: LimitKind::TravelExceeded,
700                        needed_mil: corr_dial,
701                        available_mil: Some(t.down_mil),
702                    }),
703                );
704            }
705            None
706        }
707    };
708    (target, corr_dial, violation)
709}
710
711/// Bounds-checks a TRUE angular hold value against this axis's directional `HoldBounds`
712/// magnitude and `Preferences::max_hold_mil` (the tighter of the two, when both are
713/// declared), recording exactly one [`LimitViolation`] when the hold exceeds it, or (for a
714/// nonzero hold) when NEITHER is declared at all (MBA-1348). Holds are never clamped -- see
715/// `Strategy::HoldAll` -- so this never alters `hold_true_mil`, only reports on it.
716fn check_hold_bounds(
717    axis: Axis,
718    hold_true_mil: f64,
719    bound_positive: Option<f64>,
720    bound_negative: Option<f64>,
721    max_hold_mil: Option<f64>,
722) -> Option<LimitViolation> {
723    if hold_true_mil == 0.0 {
724        return None;
725    }
726    let directional_bound = if hold_true_mil > 0.0 { bound_positive } else { bound_negative };
727    let effective_bound = match (directional_bound, max_hold_mil) {
728        (Some(a), Some(b)) => Some(a.min(b)),
729        (Some(a), None) => Some(a),
730        (None, Some(b)) => Some(b),
731        (None, None) => None,
732    };
733    match effective_bound {
734        None => Some(LimitViolation {
735            axis,
736            kind: LimitKind::NoHoldBoundData,
737            needed_mil: hold_true_mil,
738            available_mil: None,
739        }),
740        Some(bound) if hold_true_mil.abs() > bound => Some(LimitViolation {
741            axis,
742            kind: LimitKind::HoldBoundExceeded,
743            needed_mil: hold_true_mil,
744            available_mil: Some(bound),
745        }),
746        Some(_) => None,
747    }
748}
749
750fn end_revolution_for(target_clicks: i64, cpr: Option<u32>) -> Option<(u32, u32)> {
751    cpr.and_then(|c| revolution_annotation(target_clicks, c))
752}
753
754/// `Direction::Up`/`Down` for elevation, `Left`/`Right` for windage, from the sign of
755/// `delta_clicks`. Zero is reported as the positive variant (`Up`/`Right`) -- an
756/// instruction to turn zero clicks has no natural direction of its own.
757fn direction_for(axis: Axis, delta_clicks: i64) -> Direction {
758    match (axis, delta_clicks < 0) {
759        (Axis::Elevation, true) => Direction::Down,
760        (Axis::Elevation, false) => Direction::Up,
761        (Axis::Windage, true) => Direction::Left,
762        (Axis::Windage, false) => Direction::Right,
763    }
764}
765
766/// Per-axis DIAL-space quantization plus every input a [`Strategy`] needs for that axis,
767/// computed exactly once per axis per [`plan_corrections`] call and shared across all three
768/// strategies (MBA-1348) -- `target_clicks`/`travel_violation` are IDENTICAL for
769/// `Strategy::DialAll` and `Strategy::Hybrid` by construction, never recomputed twice.
770struct AxisComputation {
771    axis: Axis,
772    corr_true: f64,
773    click_mil: f64,
774    cf: f64,
775    /// The whole-click target from zero, after travel-clamping (if travel is declared).
776    target_clicks: i64,
777    travel_violation: Option<LimitViolation>,
778    /// `OpticProfile::turret_state`'s click-quantized position on this axis, or 0.
779    state_clicks: i64,
780    cpr: Option<u32>,
781    /// The `HoldBounds` magnitude that bounds a POSITIVE `hold_mil` on this axis --
782    /// `down_mil` for elevation, `left_mil` for windage (MBA-1348 review fix C1: NOT
783    /// `up_mil`/`right_mil` -- correction-space and reticle-space are opposite-signed
784    /// conventions, see the module's "Honesty" doc section, which cites `src/reticle.rs`).
785    bound_for_positive_correction: Option<f64>,
786    /// The `HoldBounds` magnitude that bounds a NEGATIVE `hold_mil` on this axis --
787    /// `up_mil` for elevation, `right_mil` for windage. See `bound_for_positive_correction`.
788    bound_for_negative_correction: Option<f64>,
789}
790
791// Private per-axis helper: every parameter is a distinct, independently-varying input this
792// module's own tests exercise in isolation (click graduation, CF, travel, turret state,
793// hold bounds, revolution count) -- collapsing them into a params struct would just move
794// the same nine fields into a second, purely-local type with no independent meaning.
795#[allow(clippy::too_many_arguments)]
796fn compute_axis(
797    axis: Axis,
798    corr_true: f64,
799    click: &ClickValue,
800    cf: f64,
801    travel: Option<&TravelLimits>,
802    state_mil: Option<f64>,
803    bound_for_positive_correction: Option<f64>,
804    bound_for_negative_correction: Option<f64>,
805    cpr: Option<u32>,
806) -> AxisComputation {
807    let click_mil = click_size_mil(click);
808    let (target_clicks, _corr_dial, travel_violation) =
809        quantize_and_clamp(axis, corr_true, click_mil, cf, travel);
810    let state_clicks = state_mil.map_or(0, |mil| {
811        quantize_angle(mil, &ClickValue { size: click_mil, base: ClickBase::Mil }).clicks
812    });
813    AxisComputation {
814        axis,
815        corr_true,
816        click_mil,
817        cf,
818        target_clicks,
819        travel_violation,
820        state_clicks,
821        cpr,
822        bound_for_positive_correction,
823        bound_for_negative_correction,
824    }
825}
826
827/// Builds one axis's [`AxisInstruction`] for `strategy` from a shared [`AxisComputation`],
828/// plus any [`LimitViolation`]s to fold into the plan's `limits_hit` and whether THIS axis,
829/// under THIS strategy, satisfies the strategy's own feasibility promise (MBA-1348) -- see
830/// the module's "Honesty" doc section for why `Strategy::Hybrid`'s feasibility ignores its
831/// own (purely disclosed) travel violation.
832fn build_axis_instruction(
833    strategy: Strategy,
834    ac: &AxisComputation,
835    prefs: &Preferences,
836) -> (AxisInstruction, Vec<LimitViolation>, bool) {
837    match strategy {
838        Strategy::DialAll => {
839            let clicks = ac.target_clicks;
840            let dial_true = clicks as f64 * ac.click_mil * ac.cf;
841            let residual = ac.corr_true - dial_true;
842            let delta_clicks = clicks - ac.state_clicks;
843            let feasible = ac.travel_violation.is_none();
844            let violations = ac.travel_violation.into_iter().collect();
845            let instr = AxisInstruction {
846                axis: ac.axis,
847                direction: direction_for(ac.axis, delta_clicks),
848                delta_clicks,
849                target_clicks_from_zero: clicks,
850                end_revolution: end_revolution_for(clicks, ac.cpr),
851                dial_mil_true: dial_true,
852                hold_mil: 0.0,
853                residual_mil: residual,
854            };
855            (instr, violations, feasible)
856        }
857        Strategy::HoldAll => {
858            let hold = ac.corr_true;
859            let hold_violation = check_hold_bounds(
860                ac.axis,
861                hold,
862                ac.bound_for_positive_correction,
863                ac.bound_for_negative_correction,
864                prefs.max_hold_mil,
865            );
866            let feasible = hold_violation.is_none();
867            let target_clicks = 0_i64;
868            let delta_clicks = target_clicks - ac.state_clicks;
869            let violations = hold_violation.into_iter().collect();
870            let instr = AxisInstruction {
871                axis: ac.axis,
872                direction: direction_for(ac.axis, delta_clicks),
873                delta_clicks,
874                target_clicks_from_zero: target_clicks,
875                end_revolution: end_revolution_for(target_clicks, ac.cpr),
876                dial_mil_true: 0.0,
877                hold_mil: hold,
878                residual_mil: 0.0,
879            };
880            (instr, violations, feasible)
881        }
882        Strategy::Hybrid => {
883            let clicks = ac.target_clicks;
884            let dial_true = clicks as f64 * ac.click_mil * ac.cf;
885            let hold = ac.corr_true - dial_true;
886            let residual = ac.corr_true - dial_true - hold;
887            let hold_violation = check_hold_bounds(
888                ac.axis,
889                hold,
890                ac.bound_for_positive_correction,
891                ac.bound_for_negative_correction,
892                prefs.max_hold_mil,
893            );
894            // Hybrid's OWN promise is that dial+hold reconstruct the correction exactly
895            // (always true by construction -- `residual` above), so a travel violation on
896            // its dial component is disclosed but does not gate ITS feasibility; only its
897            // hold not fitting does. See the module's "Honesty" doc section.
898            let feasible = hold_violation.is_none();
899            let delta_clicks = clicks - ac.state_clicks;
900            let mut violations: Vec<LimitViolation> = ac.travel_violation.into_iter().collect();
901            violations.extend(hold_violation);
902            let instr = AxisInstruction {
903                axis: ac.axis,
904                direction: direction_for(ac.axis, delta_clicks),
905                delta_clicks,
906                target_clicks_from_zero: clicks,
907                end_revolution: end_revolution_for(clicks, ac.cpr),
908                dial_mil_true: dial_true,
909                hold_mil: hold,
910                residual_mil: residual,
911            };
912            (instr, violations, feasible)
913        }
914    }
915}
916
917/// mil→linear at `range_m` by the small-angle approximation, RSS'd across both axes:
918/// `sqrt((e/1000*range)^2 + (w/1000*range)^2)` (assumption 0).
919fn residual_linear_at_range(e_res_mil: f64, w_res_mil: f64, range_m: f64) -> f64 {
920    let e = e_res_mil / 1000.0 * range_m;
921    let w = w_res_mil / 1000.0 * range_m;
922    (e * e + w * w).sqrt()
923}
924
925fn build_plan(
926    strategy: Strategy,
927    elevation: &AxisComputation,
928    windage: &AxisComputation,
929    prefs: &Preferences,
930    range_m: f64,
931) -> DialPlan {
932    let (e_instr, e_viol, e_feasible) = build_axis_instruction(strategy, elevation, prefs);
933    let (w_instr, w_viol, w_feasible) = build_axis_instruction(strategy, windage, prefs);
934    let mut limits_hit = e_viol;
935    limits_hit.extend(w_viol);
936    DialPlan {
937        strategy,
938        residual_linear_at_range_m: residual_linear_at_range(
939            e_instr.residual_mil,
940            w_instr.residual_mil,
941            range_m,
942        ),
943        instructions: [e_instr, w_instr],
944        feasible: e_feasible && w_feasible,
945        limits_hit,
946    }
947}
948
949/// Ascending sort key for `prefs.prefer_hold`: `false` (prefer dial) ranks
950/// `DialAll < Hybrid < HoldAll`; `true` reverses that. See
951/// `ranking_is_deterministic_and_preference_respected`.
952fn preference_rank(strategy: Strategy, prefer_hold: bool) -> u8 {
953    let dial_first = match strategy {
954        Strategy::DialAll => 0,
955        Strategy::Hybrid => 1,
956        Strategy::HoldAll => 2,
957    };
958    if prefer_hold { 2 - dial_first } else { dial_first }
959}
960
961/// `Strategy`'s own declaration order (`DialAll, HoldAll, Hybrid`), the final ranking
962/// tiebreak once residual and preference are both exhausted.
963fn declaration_rank(strategy: Strategy) -> u8 {
964    match strategy {
965        Strategy::DialAll => 0,
966        Strategy::HoldAll => 1,
967        Strategy::Hybrid => 2,
968    }
969}
970
971/// Turns a TRUE angular [`AngularCorrection`] into ranked, executable dial/hold/hybrid
972/// plans for a real optic (MBA-1348) -- see the module's "`plan_corrections` and the CF
973/// rule" and "Honesty" doc sections for the conventions this function follows in every arm.
974///
975/// Returns exactly three plans, one per [`Strategy`], in `DialPlanReportV1::plans`, ranked
976/// best-first: `feasible: true` plans always sort before `feasible: false` ones (MBA-1348
977/// review fix I4 -- `HoldAll`/`Hybrid` carry a residual of exactly `0.0` even when
978/// infeasible, so residual alone cannot be the primary key without risking an unexecutable
979/// `plans[0]`); among equally-feasible plans, ascending
980/// [`DialPlan::residual_linear_at_range_m`] (via `f64::total_cmp`, so ranking never panics
981/// regardless of input); ties are broken by `prefs.prefer_hold` (`false` ranks
982/// `DialAll < Hybrid < HoldAll`, `true` reverses that), and any remaining tie by
983/// `Strategy`'s own declaration order (`DialAll, HoldAll, Hybrid`) -- see
984/// `ranking_is_deterministic_and_preference_respected` and
985/// `infeasible_plans_never_outrank_a_feasible_one`.
986///
987/// `Err(OpticError)` when `optic.validate()` fails; when `correction`, `range_m`, or
988/// `prefs.max_hold_mil` is not finite; when `range_m` / `max_hold_mil` is negative; or when
989/// `elevation_cf` / `windage_cf` is zero, negative, or non-finite
990/// ([`OpticError::NonPositiveTrackingFactor`], MBA-1348 review fix I5 -- the CF rule
991/// divides by it, so a non-positive value is not merely implausible, it is a hard
992/// arithmetic failure). Otherwise `elevation_cf`/`windage_cf` are trusted exactly as given,
993/// like `crate::truing::scale_report_dial_values` -- this function does not re-apply
994/// `crate::adjustment::tracking_cf_in_range`'s tighter `(0.5, 1.5)` plausibility band;
995/// enforcing that band (if at all) remains a caller/CLI concern, matching that existing
996/// precedent.
997pub fn plan_corrections(
998    correction: AngularCorrection,
999    optic: &OpticProfile,
1000    range_m: f64,
1001    elevation_cf: f64,
1002    windage_cf: f64,
1003    prefs: &Preferences,
1004) -> Result<DialPlanReportV1, OpticError> {
1005    optic.validate()?;
1006    require_finite("correction.elevation_mil", correction.elevation_mil)?;
1007    require_finite("correction.windage_mil", correction.windage_mil)?;
1008    require_finite("range_m", range_m)?;
1009    require_non_negative("range_m", range_m)?;
1010    require_positive_tracking_factor("elevation_cf", elevation_cf)?;
1011    require_positive_tracking_factor("windage_cf", windage_cf)?;
1012    if let Some(max_hold) = prefs.max_hold_mil {
1013        require_finite("max_hold_mil", max_hold)?;
1014        require_non_negative("max_hold_mil", max_hold)?;
1015    }
1016
1017    let (hold_up, hold_down, hold_left, hold_right) = match &optic.reticle_hold_bounds {
1018        Some(b) => (Some(b.up_mil), Some(b.down_mil), Some(b.left_mil), Some(b.right_mil)),
1019        None => (None, None, None, None),
1020    };
1021
1022    // MBA-1348 review fix (C1, CRITICAL): a POSITIVE (up/right) correction-space
1023    // `hold_mil` is realized by a reticle mark on the OPPOSITE-named side -- a holdover
1024    // for an UP correction sits BELOW center (`down_mil`); a hold for a RIGHT correction
1025    // sits to the LEFT of center (`left_mil`). See the module's "Honesty" doc section
1026    // (which cites `src/reticle.rs:30-42`) for the full derivation. `hold_up`/`hold_down`/
1027    // `hold_left`/`hold_right` below are therefore passed CROSSED, not straight.
1028    let elevation = compute_axis(
1029        Axis::Elevation,
1030        correction.elevation_mil,
1031        &optic.elevation_click,
1032        elevation_cf,
1033        optic.elevation_travel.as_ref(),
1034        optic.turret_state.as_ref().map(|s| s.elevation_mil),
1035        hold_down,
1036        hold_up,
1037        optic.clicks_per_revolution,
1038    );
1039    let windage = compute_axis(
1040        Axis::Windage,
1041        correction.windage_mil,
1042        &optic.windage_click,
1043        windage_cf,
1044        optic.windage_travel.as_ref(),
1045        optic.turret_state.as_ref().map(|s| s.windage_mil),
1046        hold_left,
1047        hold_right,
1048        optic.clicks_per_revolution,
1049    );
1050
1051    let mut plans = vec![
1052        build_plan(Strategy::DialAll, &elevation, &windage, prefs, range_m),
1053        build_plan(Strategy::HoldAll, &elevation, &windage, prefs, range_m),
1054        build_plan(Strategy::Hybrid, &elevation, &windage, prefs, range_m),
1055    ];
1056    // MBA-1348 review fix (I4): `feasible` must be the PRIMARY key. `HoldAll`/`Hybrid`
1057    // carry a residual of exactly 0.0 even when INFEASIBLE (residual is about
1058    // reconstruction, not achievability), so without this, an infeasible plan could rank
1059    // ahead of the one plan that actually works -- and Task 6's CLI surfaces `plans[0]` as
1060    // THE recommendation. `false < true`, so `!feasible` sorts feasible plans first.
1061    plans.sort_by(|a, b| {
1062        (!a.feasible)
1063            .cmp(&!b.feasible)
1064            .then_with(|| a.residual_linear_at_range_m.total_cmp(&b.residual_linear_at_range_m))
1065            .then_with(|| {
1066                preference_rank(a.strategy, prefs.prefer_hold)
1067                    .cmp(&preference_rank(b.strategy, prefs.prefer_hold))
1068            })
1069            .then_with(|| declaration_rank(a.strategy).cmp(&declaration_rank(b.strategy)))
1070    });
1071
1072    Ok(DialPlanReportV1 {
1073        schema_version: DIAL_PLAN_SCHEMA_VERSION_V1,
1074        method: "dial_space_quantization_v1".to_string(),
1075        assumptions: vec![
1076            "Linear miss at range uses the small-angle approximation (mil / 1000 * range); \
1077             it is not exact at extreme angles."
1078                .to_string(),
1079            "Elevation and windage are planned independently; no cant-induced coupling \
1080             between axes is modeled."
1081                .to_string(),
1082            "Reticle holds are assumed continuous and unquantized, unlike turret clicks."
1083                .to_string(),
1084            "Travel limits and turret state are trusted exactly as declared in the optic \
1085             profile, not sensed or independently verified."
1086                .to_string(),
1087            "MOA-graduated clicks convert to milliradians using the locked printed-table \
1088             constant 3438, not the exact geometric 3437.7467."
1089                .to_string(),
1090        ],
1091        range_m,
1092        plans,
1093    })
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099    use crate::adjustment::ClickBase;
1100
1101    /// A full, realistic profile: 0.1 mil clicks on both turrets, 10 clicks/revolution, a
1102    /// zero stop, 0.4 mil of down travel and 28.0 of up (typical of a zero-stopped
1103    /// elevation turret with most of its range above zero), +-6 mil of windage travel,
1104    /// dialed to zero on both axes, and modest hold bounds beyond the turrets' own range.
1105    fn baseline_profile() -> OpticProfile {
1106        OpticProfile {
1107            elevation_click: ClickValue { size: 0.1, base: ClickBase::Mil },
1108            windage_click: ClickValue { size: 0.1, base: ClickBase::Mil },
1109            clicks_per_revolution: Some(10),
1110            zero_stop: true,
1111            elevation_travel: Some(TravelLimits { down_mil: 0.4, up_mil: 28.0 }),
1112            windage_travel: Some(TravelLimits { down_mil: 6.0, up_mil: 6.0 }),
1113            turret_state: Some(TurretState { elevation_mil: 0.0, windage_mil: 0.0 }),
1114            reticle_hold_bounds: Some(HoldBounds {
1115                up_mil: 5.0,
1116                down_mil: 10.0,
1117                left_mil: 6.0,
1118                right_mil: 6.0,
1119            }),
1120        }
1121    }
1122
1123    #[test]
1124    fn validate_accepts_a_full_realistic_profile() {
1125        assert_eq!(baseline_profile().validate(), Ok(()));
1126    }
1127
1128    #[test]
1129    fn validate_rejects_negative_travel() {
1130        let mut down_negative = baseline_profile();
1131        down_negative.elevation_travel = Some(TravelLimits { down_mil: -0.4, up_mil: 28.0 });
1132        assert!(
1133            matches!(
1134                down_negative.validate(),
1135                Err(OpticError::NegativeLimit { field: "elevation_travel.down_mil", value })
1136                    if value == -0.4
1137            ),
1138            "{:?}",
1139            down_negative.validate()
1140        );
1141
1142        let mut up_negative = baseline_profile();
1143        up_negative.windage_travel = Some(TravelLimits { down_mil: 6.0, up_mil: -6.0 });
1144        assert!(
1145            matches!(
1146                up_negative.validate(),
1147                Err(OpticError::NegativeLimit { field: "windage_travel.up_mil", value })
1148                    if value == -6.0
1149            ),
1150            "{:?}",
1151            up_negative.validate()
1152        );
1153
1154        let mut hold_negative = baseline_profile();
1155        hold_negative.reticle_hold_bounds = Some(HoldBounds {
1156            up_mil: -5.0,
1157            down_mil: 10.0,
1158            left_mil: 6.0,
1159            right_mil: 6.0,
1160        });
1161        assert!(
1162            matches!(
1163                hold_negative.validate(),
1164                Err(OpticError::NegativeLimit { field: "reticle_hold_bounds.up_mil", .. })
1165            ),
1166            "{:?}",
1167            hold_negative.validate()
1168        );
1169    }
1170
1171    #[test]
1172    fn validate_rejects_zero_clicks_per_revolution() {
1173        let mut zero_cpr = baseline_profile();
1174        zero_cpr.clicks_per_revolution = Some(0);
1175        assert!(matches!(
1176            zero_cpr.validate(),
1177            Err(OpticError::ZeroClicksPerRevolution)
1178        ));
1179
1180        // Fencepost: 1 is the smallest ACCEPTED value.
1181        let mut one_cpr = baseline_profile();
1182        one_cpr.clicks_per_revolution = Some(1);
1183        assert_eq!(one_cpr.validate(), Ok(()));
1184
1185        // A turret with no revolution marks at all is also fine.
1186        let mut no_cpr = baseline_profile();
1187        no_cpr.clicks_per_revolution = None;
1188        assert_eq!(no_cpr.validate(), Ok(()));
1189    }
1190
1191    #[test]
1192    fn validate_rejects_non_positive_click_size() {
1193        // `ClickValue`'s fields are `pub`, so a zero or negative size is reachable by
1194        // direct construction even though `parse_click_value` already excludes it.
1195        for bad_size in [0.0, -0.1] {
1196            let mut elevation_bad = baseline_profile();
1197            elevation_bad.elevation_click.size = bad_size;
1198            assert!(
1199                matches!(
1200                    elevation_bad.validate(),
1201                    Err(OpticError::NonPositiveClickSize { field: "elevation_click.size", size })
1202                        if size == bad_size
1203                ),
1204                "size {bad_size}: {:?}",
1205                elevation_bad.validate()
1206            );
1207
1208            let mut windage_bad = baseline_profile();
1209            windage_bad.windage_click.size = bad_size;
1210            assert!(
1211                matches!(
1212                    windage_bad.validate(),
1213                    Err(OpticError::NonPositiveClickSize { field: "windage_click.size", size })
1214                        if size == bad_size
1215                ),
1216                "size {bad_size}: {:?}",
1217                windage_bad.validate()
1218            );
1219        }
1220
1221        // Fencepost: a very small but positive size is accepted.
1222        let mut tiny = baseline_profile();
1223        tiny.elevation_click.size = f64::MIN_POSITIVE;
1224        assert_eq!(tiny.validate(), Ok(()));
1225    }
1226
1227    #[test]
1228    fn validate_rejects_non_finite_fields() {
1229        type Mutator = fn(&mut OpticProfile);
1230        let cases: &[(&str, Mutator)] = &[
1231            ("elevation_click.size", |p| p.elevation_click.size = f64::NAN),
1232            ("windage_click.size", |p| p.windage_click.size = f64::INFINITY),
1233            ("elevation_travel.down_mil", |p| {
1234                p.elevation_travel.as_mut().unwrap().down_mil = f64::NAN
1235            }),
1236            ("elevation_travel.up_mil", |p| {
1237                p.elevation_travel.as_mut().unwrap().up_mil = f64::INFINITY
1238            }),
1239            ("windage_travel.down_mil", |p| {
1240                p.windage_travel.as_mut().unwrap().down_mil = f64::NEG_INFINITY
1241            }),
1242            ("windage_travel.up_mil", |p| {
1243                p.windage_travel.as_mut().unwrap().up_mil = f64::NAN
1244            }),
1245            ("turret_state.elevation_mil", |p| {
1246                p.turret_state.as_mut().unwrap().elevation_mil = f64::NAN
1247            }),
1248            ("turret_state.windage_mil", |p| {
1249                p.turret_state.as_mut().unwrap().windage_mil = f64::INFINITY
1250            }),
1251            ("reticle_hold_bounds.up_mil", |p| {
1252                p.reticle_hold_bounds.as_mut().unwrap().up_mil = f64::NAN
1253            }),
1254            ("reticle_hold_bounds.down_mil", |p| {
1255                p.reticle_hold_bounds.as_mut().unwrap().down_mil = f64::NAN
1256            }),
1257            ("reticle_hold_bounds.left_mil", |p| {
1258                p.reticle_hold_bounds.as_mut().unwrap().left_mil = f64::NAN
1259            }),
1260            ("reticle_hold_bounds.right_mil", |p| {
1261                p.reticle_hold_bounds.as_mut().unwrap().right_mil = f64::NAN
1262            }),
1263        ];
1264
1265        for (field, mutate) in cases {
1266            let mut profile = baseline_profile();
1267            mutate(&mut profile);
1268            let result = profile.validate();
1269            assert!(
1270                matches!(&result, Err(OpticError::NonFinite { field: f }) if f == field),
1271                "field {field}: expected Err(NonFinite {{ field: {field:?} }}), got {result:?}"
1272            );
1273        }
1274    }
1275
1276    #[test]
1277    fn validate_rejects_turret_state_outside_travel() {
1278        let mut above_up = baseline_profile();
1279        above_up.turret_state = Some(TurretState { elevation_mil: 28.1, windage_mil: 0.0 });
1280        assert!(
1281            matches!(
1282                above_up.validate(),
1283                Err(OpticError::StateOutsideTravel {
1284                    axis: "elevation",
1285                    dialed_mil,
1286                    down_mil,
1287                    up_mil
1288                }) if dialed_mil == 28.1 && down_mil == 0.4 && up_mil == 28.0
1289            ),
1290            "{:?}",
1291            above_up.validate()
1292        );
1293
1294        let mut below_down = baseline_profile();
1295        below_down.turret_state = Some(TurretState { elevation_mil: -0.5, windage_mil: 0.0 });
1296        assert!(matches!(
1297            below_down.validate(),
1298            Err(OpticError::StateOutsideTravel { axis: "elevation", .. })
1299        ));
1300
1301        let mut windage_out = baseline_profile();
1302        windage_out.turret_state = Some(TurretState { elevation_mil: 0.0, windage_mil: 6.1 });
1303        assert!(matches!(
1304            windage_out.validate(),
1305            Err(OpticError::StateOutsideTravel { axis: "windage", .. })
1306        ));
1307
1308        // Exactly at either boundary is accepted -- a closed interval.
1309        let mut at_boundary = baseline_profile();
1310        at_boundary.turret_state = Some(TurretState { elevation_mil: 28.0, windage_mil: -6.0 });
1311        assert_eq!(at_boundary.validate(), Ok(()));
1312
1313        // No declared travel on an axis means nothing to be "outside" on that axis.
1314        let mut untethered = baseline_profile();
1315        untethered.elevation_travel = None;
1316        untethered.turret_state = Some(TurretState { elevation_mil: 999.0, windage_mil: 0.0 });
1317        assert_eq!(untethered.validate(), Ok(()));
1318    }
1319
1320    #[test]
1321    fn revolution_annotation_matches_known_points_at_cpr_ten() {
1322        assert_eq!(revolution_annotation(0, 10), Some((0, 0)));
1323        assert_eq!(revolution_annotation(9, 10), Some((0, 9)));
1324        assert_eq!(revolution_annotation(10, 10), Some((1, 0)));
1325        assert_eq!(revolution_annotation(27, 10), Some((2, 7)));
1326    }
1327
1328    #[test]
1329    fn revolution_annotation_reconstructs_the_input_over_a_range() {
1330        for cpr in [1_u32, 3, 10, 60] {
1331            for input in 0_i64..100 {
1332                let (revolutions, clicks_in_rev) = revolution_annotation(input, cpr)
1333                    .unwrap_or_else(|| panic!("expected Some for input {input}, cpr {cpr}"));
1334                assert!(
1335                    clicks_in_rev < cpr,
1336                    "clicks_in_rev {clicks_in_rev} must be < cpr {cpr}"
1337                );
1338                assert_eq!(
1339                    i64::from(revolutions) * i64::from(cpr) + i64::from(clicks_in_rev),
1340                    input,
1341                    "reconstruction broke at input {input}, cpr {cpr}"
1342                );
1343            }
1344        }
1345    }
1346
1347    #[test]
1348    fn revolution_annotation_negative_input_is_none() {
1349        for clicks in [-1_i64, -7, -10, -27, i64::MIN] {
1350            assert_eq!(
1351                revolution_annotation(clicks, 10),
1352                None,
1353                "clicks_from_zero {clicks} must be None, not a misleading revolution count"
1354            );
1355        }
1356    }
1357
1358    #[test]
1359    fn revolution_annotation_zero_clicks_per_revolution_is_none_not_a_panic() {
1360        assert_eq!(revolution_annotation(0, 0), None);
1361        assert_eq!(revolution_annotation(5, 0), None);
1362        assert_eq!(revolution_annotation(-5, 0), None);
1363    }
1364
1365    // MBA-1348: OpticProfile's JSON wire form -- Task 5 stores this in profiles, so its
1366    // shape (including how absent turret data is represented) is pinned here, not left
1367    // to whatever serde's defaults happen to produce.
1368    #[test]
1369    fn optic_profile_round_trips_through_json() {
1370        let profile = baseline_profile();
1371        let json = serde_json::to_string(&profile).unwrap();
1372        let parsed: OpticProfile = serde_json::from_str(&json).unwrap();
1373        assert_eq!(parsed, profile);
1374    }
1375
1376    #[test]
1377    fn optic_profile_json_pins_click_fields_to_the_suffixed_string() {
1378        let profile = baseline_profile();
1379        let json = serde_json::to_value(&profile).unwrap();
1380        assert_eq!(json["elevation_click"], serde_json::json!("0.1mil"));
1381        assert_eq!(json["windage_click"], serde_json::json!("0.1mil"));
1382    }
1383
1384    #[test]
1385    fn optic_profile_json_pins_option_field_presence_and_absence() {
1386        // Every optional field is `Some` in the baseline profile: present as its real
1387        // value, not omitted.
1388        let present = serde_json::to_value(baseline_profile()).unwrap();
1389        for field in [
1390            "clicks_per_revolution",
1391            "elevation_travel",
1392            "windage_travel",
1393            "turret_state",
1394            "reticle_hold_bounds",
1395        ] {
1396            let value = &present[field];
1397            assert!(
1398                !value.is_null(),
1399                "{field} should be present as a real value in the baseline profile, got {value:?}"
1400            );
1401        }
1402
1403        // `None` serializes as an explicit JSON `null` -- there is no
1404        // `#[serde(skip_serializing_if = "Option::is_none")]` on this type, so a `None`
1405        // field key is still PRESENT in the object, just null-valued, not omitted.
1406        let mut profile = baseline_profile();
1407        profile.clicks_per_revolution = None;
1408        profile.elevation_travel = None;
1409        profile.windage_travel = None;
1410        profile.turret_state = None;
1411        profile.reticle_hold_bounds = None;
1412        let absent = serde_json::to_value(&profile).unwrap();
1413        for field in [
1414            "clicks_per_revolution",
1415            "elevation_travel",
1416            "windage_travel",
1417            "turret_state",
1418            "reticle_hold_bounds",
1419        ] {
1420            assert_eq!(
1421                absent.get(field),
1422                Some(&serde_json::Value::Null),
1423                "{field} should be a present-but-null key, not an omitted one"
1424            );
1425        }
1426
1427        // And the None-shaped profile still round-trips.
1428        let json = serde_json::to_string(&profile).unwrap();
1429        let parsed: OpticProfile = serde_json::from_str(&json).unwrap();
1430        assert_eq!(parsed, profile);
1431    }
1432}
1433
1434// MBA-1348 Task 4: `plan_corrections` and its supporting types. Kept in its own test module,
1435// separate from `mod tests` above (Task 3's turret-model tests), matching this crate's
1436// existing practice of splitting a large file's tests by concern (e.g.
1437// `error_budget::window_helper_tests`).
1438#[cfg(test)]
1439mod plan_corrections_tests {
1440    use super::*;
1441    use crate::adjustment::ClickBase;
1442
1443    /// Same numbers as `tests::baseline_profile` (Task 3) -- 0.1 mil clicks on both
1444    /// turrets, 10 clicks/revolution, a zero stop, 0.4 mil of down travel and 28.0 of up,
1445    /// +-6 mil of windage travel, dialed to zero on both axes, and hold bounds of 5 up /
1446    /// 10 down / 6 left / 6 right -- redefined locally (rather than reused across modules)
1447    /// so this module doesn't depend on `tests`' private helper visibility.
1448    fn baseline_profile() -> OpticProfile {
1449        OpticProfile {
1450            elevation_click: ClickValue { size: 0.1, base: ClickBase::Mil },
1451            windage_click: ClickValue { size: 0.1, base: ClickBase::Mil },
1452            clicks_per_revolution: Some(10),
1453            zero_stop: true,
1454            elevation_travel: Some(TravelLimits { down_mil: 0.4, up_mil: 28.0 }),
1455            windage_travel: Some(TravelLimits { down_mil: 6.0, up_mil: 6.0 }),
1456            turret_state: Some(TurretState { elevation_mil: 0.0, windage_mil: 0.0 }),
1457            reticle_hold_bounds: Some(HoldBounds {
1458                up_mil: 5.0,
1459                down_mil: 10.0,
1460                left_mil: 6.0,
1461                right_mil: 6.0,
1462            }),
1463        }
1464    }
1465
1466    fn plan_for(strategy: Strategy, report: &DialPlanReportV1) -> DialPlan {
1467        report
1468            .plans
1469            .iter()
1470            .find(|p| p.strategy == strategy)
1471            .unwrap_or_else(|| panic!("no {strategy:?} plan in report"))
1472            .clone()
1473    }
1474
1475    // Spec §7 acceptance criterion: "exact-click dope produces zero residual."
1476    //
1477    // `elevation_mil` is constructed as `23.0 * 0.1`, NOT the literal `2.3`, so it is
1478    // BIT-IDENTICAL to `clicks as f64 * click.size` after quantization -- matching
1479    // `crate::adjustment::tests::quantize_exact_multiples_have_residual_exactly_zero`'s own
1480    // established pattern for the same reason: `2.3_f64 - 23.0 * 0.1 == -4.440892098500626e-16`,
1481    // NOT zero, so the decimal literal and the click-multiple product are numerically equal
1482    // but not bit-identical. This is a hand-verified floating-point fact (checked directly
1483    // in Python's IEEE-754 binary64 floats, identical semantics to Rust `f64`), not a
1484    // stylistic choice -- the brief's own "residual_mil == 0.0 bit-exact" requirement is
1485    // only achievable this way.
1486    #[test]
1487    fn exact_click_dope_has_residual_exactly_zero() {
1488        let corr = AngularCorrection { elevation_mil: 23.0 * 0.1, windage_mil: 0.0 };
1489        let optic = baseline_profile();
1490        let report =
1491            plan_corrections(corr, &optic, 600.0, 1.0, 1.0, &Preferences::default()).unwrap();
1492        let dial_all = plan_for(Strategy::DialAll, &report);
1493        assert_eq!(dial_all.instructions[0].target_clicks_from_zero, 23);
1494        assert_eq!(dial_all.instructions[0].residual_mil, 0.0, "must be bit-exact zero");
1495        assert_eq!(dial_all.instructions[0].residual_mil.to_bits(), 0.0_f64.to_bits());
1496        assert!(dial_all.feasible, "{dial_all:?}");
1497        assert!(dial_all.limits_hit.is_empty(), "{dial_all:?}");
1498    }
1499
1500    // Spec §7: "fractional-click dope reports the chosen rounding and remaining
1501    // angular/linear error at range."
1502    //
1503    // Hand-derived (and Python-verified against IEEE-754 binary64 arithmetic): 2.34 / 0.1 =
1504    // 23.4, rounds to 23 clicks; residual = 2.34 - 23*0.1 = 0.03999999999999959 (~0.04,
1505    // matching `2.34 - 2.3` to within 1e-12 as the brief specifies); linear error at 600 m
1506    // = |residual| / 1000 * 600 = 0.023999999999999754 (~0.024, within 1e-9).
1507    #[test]
1508    fn fractional_click_reports_rounding_and_linear_error() {
1509        let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
1510        let optic = baseline_profile();
1511        let report =
1512            plan_corrections(corr, &optic, 600.0, 1.0, 1.0, &Preferences::default()).unwrap();
1513
1514        let dial_all = plan_for(Strategy::DialAll, &report);
1515        assert_eq!(dial_all.instructions[0].target_clicks_from_zero, 23);
1516        let expected_residual = 2.34 - 2.3;
1517        assert!(
1518            (dial_all.instructions[0].residual_mil - expected_residual).abs() < 1e-12,
1519            "residual {} vs hand-derived {}",
1520            dial_all.instructions[0].residual_mil,
1521            expected_residual
1522        );
1523        assert!(
1524            (dial_all.residual_linear_at_range_m - 0.024).abs() < 1e-9,
1525            "residual_linear_at_range_m = {}",
1526            dial_all.residual_linear_at_range_m
1527        );
1528
1529        let hybrid = plan_for(Strategy::Hybrid, &report);
1530        assert_eq!(
1531            hybrid.instructions[0].residual_mil, 0.0,
1532            "Hybrid's residual is an identity by construction, always bit-exact zero"
1533        );
1534        assert!(
1535            (hybrid.instructions[0].hold_mil - 0.04).abs() < 1e-9,
1536            "hold_mil = {}",
1537            hybrid.instructions[0].hold_mil
1538        );
1539    }
1540
1541    // Spec §7: "MIL and MOA produce physically equivalent results."
1542    #[test]
1543    fn mil_and_moa_optics_are_physically_equivalent() {
1544        let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
1545        let prefs = Preferences::default();
1546
1547        let mil_optic = baseline_profile();
1548        let mil_report = plan_corrections(corr, &mil_optic, 100.0, 1.0, 1.0, &prefs).unwrap();
1549
1550        let mut moa_optic = baseline_profile();
1551        moa_optic.elevation_click = ClickValue { size: 0.25, base: ClickBase::Moa };
1552        let moa_report = plan_corrections(corr, &moa_optic, 100.0, 1.0, 1.0, &prefs).unwrap();
1553
1554        for (label, report, click) in [
1555            ("mil", &mil_report, &mil_optic.elevation_click),
1556            ("moa", &moa_report, &moa_optic.elevation_click),
1557        ] {
1558            let hybrid = plan_for(Strategy::Hybrid, report);
1559            let e = &hybrid.instructions[0];
1560            assert!(
1561                (e.dial_mil_true + e.hold_mil - corr.elevation_mil).abs() < 1e-12,
1562                "{label}: dial_true {} + hold {} should reconstruct corr {} to 1e-12",
1563                e.dial_mil_true,
1564                e.hold_mil,
1565                corr.elevation_mil
1566            );
1567            assert_eq!(e.residual_mil, 0.0, "{label}: Hybrid residual must be bit-exact zero");
1568
1569            let dial_all = plan_for(Strategy::DialAll, report);
1570            let click_mil = click_size_mil(click);
1571            assert!(
1572                dial_all.instructions[0].residual_mil.abs() <= click_mil / 2.0,
1573                "{label}: DialAll residual {} must be within half a click ({})",
1574                dial_all.instructions[0].residual_mil,
1575                click_mil / 2.0
1576            );
1577        }
1578    }
1579
1580    // The brief's pinned worked example -- hand-derived numbers, verified against Rust's
1581    // exact IEEE-754 f64 semantics before writing this test:
1582    //   corr_dial  = 5.0 / 0.98               = 5.1020408163265305
1583    //   clicks     = round(corr_dial / 0.1)   = 51
1584    //   dial_true  = 51 * 0.1 * 0.98          = 4.998
1585    //   hybrid hold = 5.0 - 4.998             = 0.0019999999999997797  (~0.002, 1e-12)
1586    // If the CF multiply/divide directions were swapped, or Hybrid held the DIAL-space
1587    // remainder instead of the TRUE one, these numbers would come out different and wrong
1588    // -- see this task's required fault-injection verification.
1589    #[test]
1590    fn cf_dial_space_worked_example() {
1591        let corr = AngularCorrection { elevation_mil: 5.0, windage_mil: 0.0 };
1592        let optic = baseline_profile();
1593        let report =
1594            plan_corrections(corr, &optic, 100.0, 0.98, 1.0, &Preferences::default()).unwrap();
1595
1596        let dial_all = plan_for(Strategy::DialAll, &report);
1597        let e = &dial_all.instructions[0];
1598        assert_eq!(e.target_clicks_from_zero, 51, "51 clicks: 5.0/0.98 = 5.10204... -> round");
1599        assert!(
1600            (e.dial_mil_true - 4.998).abs() < 1e-12,
1601            "dial_mil_true = {} (expected 51*0.1*0.98 = 4.998)",
1602            e.dial_mil_true
1603        );
1604
1605        let hybrid = plan_for(Strategy::Hybrid, &report);
1606        let eh = &hybrid.instructions[0];
1607        assert_eq!(eh.target_clicks_from_zero, 51);
1608        assert!(
1609            (eh.hold_mil - 0.002).abs() < 1e-12,
1610            "hybrid hold_mil = {} (expected 5.0 - 4.998 = 0.002)",
1611            eh.hold_mil
1612        );
1613        assert_eq!(eh.residual_mil, 0.0, "Hybrid residual must be bit-exact zero");
1614        assert!(hybrid.feasible, "{hybrid:?}");
1615    }
1616
1617    // Spec §7: "revolution and click counts are correct across zero-stop and travel
1618    // limits" + the brief's specific cpr-10 / 0.4-down / 28-up worked scenario.
1619    #[test]
1620    fn revolutions_and_zero_stop() {
1621        let optic = baseline_profile(); // cpr 10, up 28.0, down 0.4, click 0.1 mil
1622        let prefs = Preferences::default();
1623
1624        // --- 27 clicks: end_revolution == Some((2, 7)) ---
1625        // corr constructed as 27.0 * 0.1 for the same bit-exactness reason as the
1626        // exact-click test above.
1627        let up_corr = AngularCorrection { elevation_mil: 27.0 * 0.1, windage_mil: 0.0 };
1628        let up_report = plan_corrections(up_corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
1629        let dial_all = plan_for(Strategy::DialAll, &up_report);
1630        assert_eq!(dial_all.instructions[0].target_clicks_from_zero, 27);
1631        assert_eq!(dial_all.instructions[0].end_revolution, Some((2, 7)));
1632        assert!(dial_all.feasible, "{dial_all:?}");
1633
1634        // --- down 1.0 mil needs -10 clicks; only 0.4 mil (4 clicks) of down travel ---
1635        let down_corr = AngularCorrection { elevation_mil: -1.0, windage_mil: 0.0 };
1636        let down_report = plan_corrections(down_corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
1637
1638        let dial_all = plan_for(Strategy::DialAll, &down_report);
1639        let e = &dial_all.instructions[0];
1640        assert_eq!(e.target_clicks_from_zero, -4, "clamped to the 0.4 mil / 0.1 mil = 4 clicks available");
1641        assert!(!dial_all.feasible, "DialAll must be infeasible when travel-clamped");
1642        assert_eq!(dial_all.limits_hit.len(), 1);
1643        assert!(matches!(
1644            dial_all.limits_hit[0],
1645            LimitViolation {
1646                axis: Axis::Elevation,
1647                kind: LimitKind::TravelExceeded,
1648                needed_mil,
1649                available_mil: Some(available_mil),
1650            } if (needed_mil - (-1.0)).abs() < 1e-12 && available_mil == 0.4
1651        ), "{:?}", dial_all.limits_hit[0]);
1652
1653        let hybrid = plan_for(Strategy::Hybrid, &down_report);
1654        let eh = &hybrid.instructions[0];
1655        assert_eq!(eh.target_clicks_from_zero, -4, "Hybrid dials the same clamped -4 clicks");
1656        assert!(
1657            eh.hold_mil.is_sign_negative() && (eh.hold_mil - (-0.6)).abs() < 1e-9,
1658            "Hybrid holds the rest: hold_mil = {} (expected -0.6)",
1659            eh.hold_mil
1660        );
1661        assert_eq!(eh.residual_mil, 0.0, "Hybrid residual must be bit-exact zero even when clamped");
1662        // The travel violation is still recorded on Hybrid (disclosure)...
1663        assert!(
1664            hybrid.limits_hit.iter().any(|v| matches!(v.kind, LimitKind::TravelExceeded)),
1665            "{:?}",
1666            hybrid.limits_hit
1667        );
1668        // ...but Hybrid's own feasibility depends ONLY on whether the hold fits bounds, NOT
1669        // on the travel clamp -- this is the crux of the brief's "feasible iff hold fits
1670        // bounds". The hold is -0.6 (a DOWN hold), which consumes baseline's up_mil bound
1671        // (5.0, the mark ABOVE center a downward correction's hold sits at -- see the
1672        // module's "Honesty" doc section) -- |-0.6| fits comfortably within 5.0.
1673        assert!(
1674            hybrid.feasible,
1675            "Hybrid must remain feasible: the hold (-0.6) fits within the 5.0 mil up_mil \
1676             bound, even though its dial component was travel-clamped: {hybrid:?}"
1677        );
1678    }
1679
1680    // Spec §7: "an infeasible request is reported as infeasible rather than silently
1681    // clamped" -- the brief's own acceptance test name.
1682    #[test]
1683    fn infeasible_is_reported_never_silently_clamped() {
1684        let optic = baseline_profile(); // down travel 0.4 mil
1685        let corr = AngularCorrection { elevation_mil: -1.0, windage_mil: 0.0 };
1686        let report =
1687            plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default()).unwrap();
1688
1689        let dial_all = plan_for(Strategy::DialAll, &report);
1690        assert!(!dial_all.feasible);
1691        let e = &dial_all.instructions[0];
1692        // The clamped dial only executes -4 clicks * 0.1 mil * 1.0 cf = -0.4 true mil, NOT
1693        // the requested -1.0. The reported residual must equal the REAL shortfall against
1694        // what was actually executed, never something smaller that pretends the clamp
1695        // didn't happen.
1696        // Review fix M4: tightened from a 1e-12 tolerance to bit-exact -- hand-verified
1697        // (Python, identical IEEE-754 semantics) that `-4.0 * 0.1 == -0.4` and
1698        // `-1.0 - (-0.4) == -0.6` are both exact for these specific numbers, so an
1699        // approximate assert here was strictly weaker than what the numbers actually give.
1700        let clamped_dial_true = e.target_clicks_from_zero as f64 * 0.1 * 1.0;
1701        assert_eq!(clamped_dial_true, -0.4);
1702        let honest_residual = corr.elevation_mil - clamped_dial_true;
1703        assert_eq!(
1704            e.residual_mil, honest_residual,
1705            "residual_mil must equal corr_true - clamped_dial_true exactly, not a smaller, \
1706             optimistic number"
1707        );
1708        assert!(
1709            e.residual_mil.abs() > 0.5,
1710            "a silently-optimistic implementation might under-report this; the real miss \
1711             is large (-0.6 mil): residual_mil = {}",
1712            e.residual_mil
1713        );
1714
1715        // No plan anywhere in the ranked list is feasible: true while ALSO carrying a
1716        // recorded violation that its own strategy's feasibility depends on (DialAll's own
1717        // travel violation, HoldAll's own hold violation). This is a general property of
1718        // the whole report, not just DialAll.
1719        for plan in &report.plans {
1720            match plan.strategy {
1721                Strategy::DialAll | Strategy::HoldAll => {
1722                    assert_eq!(
1723                        plan.feasible,
1724                        plan.limits_hit.is_empty(),
1725                        "{:?}: DialAll/HoldAll feasibility must exactly track limits_hit",
1726                        plan.strategy
1727                    );
1728                }
1729                Strategy::Hybrid => {} // see revolutions_and_zero_stop: gated on hold only
1730            }
1731        }
1732    }
1733
1734    // Spec §7 + the brief's own ranking rule: ascending residual_linear_at_range_m, ties
1735    // broken by `prefer_hold` (false => DialAll < Hybrid < HoldAll, true => reverse), final
1736    // tie by Strategy's declaration order. No float sort_by panic regardless (total_cmp).
1737    #[test]
1738    fn ranking_is_deterministic_and_preference_respected() {
1739        // --- A full 3-way tie: an exact-click correction makes ALL THREE strategies'
1740        // residual (and therefore residual_linear_at_range_m) exactly 0.0 -- DialAll lands
1741        // on an exact click (no quantization error), and HoldAll/Hybrid are ALWAYS exactly
1742        // 0.0 by construction regardless of the correction. This isolates the preference
1743        // tiebreak completely: nothing here is decided by the primary residual key.
1744        let optic = baseline_profile();
1745        let corr = AngularCorrection { elevation_mil: 23.0 * 0.1, windage_mil: 0.0 };
1746
1747        let prefer_dial = Preferences { prefer_hold: false, max_hold_mil: None };
1748        let report_dial =
1749            plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &prefer_dial).unwrap();
1750        let strategies: Vec<Strategy> = report_dial.plans.iter().map(|p| p.strategy).collect();
1751        assert_eq!(
1752            strategies,
1753            vec![Strategy::DialAll, Strategy::Hybrid, Strategy::HoldAll],
1754            "prefer_hold=false must rank DialAll < Hybrid < HoldAll when fully tied"
1755        );
1756
1757        let prefer_hold = Preferences { prefer_hold: true, max_hold_mil: None };
1758        let report_hold =
1759            plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &prefer_hold).unwrap();
1760        let strategies: Vec<Strategy> = report_hold.plans.iter().map(|p| p.strategy).collect();
1761        assert_eq!(
1762            strategies,
1763            vec![Strategy::HoldAll, Strategy::Hybrid, Strategy::DialAll],
1764            "prefer_hold=true must reverse the order to HoldAll < Hybrid < DialAll"
1765        );
1766
1767        // --- A NON-tied scenario: DialAll's residual (~0.04) is strictly worse than
1768        // HoldAll/Hybrid's (always exactly 0.0), so the PRIMARY ascending-residual key must
1769        // place DialAll last regardless of preference -- preference only ever breaks ties,
1770        // it never overrides a genuine residual difference.
1771        let frac_corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
1772        for prefs in [prefer_dial, prefer_hold] {
1773            let report =
1774                plan_corrections(frac_corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
1775            assert_eq!(
1776                report.plans.last().unwrap().strategy,
1777                Strategy::DialAll,
1778                "DialAll's nonzero residual must always rank it last, prefer_hold={}",
1779                prefs.prefer_hold
1780            );
1781            // Ranked ascending by residual_linear_at_range_m, non-decreasing throughout.
1782            for pair in report.plans.windows(2) {
1783                assert!(
1784                    pair[0].residual_linear_at_range_m <= pair[1].residual_linear_at_range_m,
1785                    "{:?}",
1786                    report.plans
1787                );
1788            }
1789        }
1790    }
1791
1792    // Spec §2 (every report carries method + assumptions) + the Plan-A lesson: a
1793    // `contains("word")` check alone is satisfiable by a DIFFERENT sentence, so this pins
1794    // length AND full-string equality at every index (mirrors card.rs's own
1795    // `report_carries_method_and_all_five_assumptions`) -- a consumer that quotes
1796    // `assumptions[2]` must keep getting the unquantized-holds sentence, not whatever a
1797    // later edit shuffled into that slot.
1798    #[test]
1799    fn report_carries_method_and_all_five_assumptions() {
1800        let optic = baseline_profile();
1801        let corr = AngularCorrection { elevation_mil: 2.3, windage_mil: 0.0 };
1802        let report =
1803            plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default()).unwrap();
1804
1805        assert_eq!(report.schema_version, DIAL_PLAN_SCHEMA_VERSION_V1);
1806        assert_eq!(report.method, "dial_space_quantization_v1");
1807        assert_eq!(report.assumptions.len(), 5, "{:?}", report.assumptions);
1808        assert_eq!(
1809            report.assumptions[0],
1810            "Linear miss at range uses the small-angle approximation (mil / 1000 * range); it is not exact at extreme angles."
1811        );
1812        assert_eq!(
1813            report.assumptions[1],
1814            "Elevation and windage are planned independently; no cant-induced coupling between axes is modeled."
1815        );
1816        assert_eq!(
1817            report.assumptions[2],
1818            "Reticle holds are assumed continuous and unquantized, unlike turret clicks."
1819        );
1820        assert_eq!(
1821            report.assumptions[3],
1822            "Travel limits and turret state are trusted exactly as declared in the optic profile, not sensed or independently verified."
1823        );
1824        assert_eq!(
1825            report.assumptions[4],
1826            "MOA-graduated clicks convert to milliradians using the locked printed-table constant 3438, not the exact geometric 3437.7467."
1827        );
1828    }
1829
1830    // The brief's own acceptance test: turret_state shifts delta_clicks but never
1831    // target_clicks_from_zero.
1832    #[test]
1833    fn turret_state_shifts_delta_but_not_target() {
1834        let corr = AngularCorrection { elevation_mil: 23.0 * 0.1, windage_mil: 0.0 };
1835        let prefs = Preferences::default();
1836
1837        let zeroed = baseline_profile(); // turret_state elevation_mil: 0.0
1838        let report_zeroed = plan_corrections(corr, &zeroed, 100.0, 1.0, 1.0, &prefs).unwrap();
1839        let dial_zeroed = plan_for(Strategy::DialAll, &report_zeroed);
1840
1841        let mut dialed = baseline_profile();
1842        dialed.turret_state = Some(TurretState { elevation_mil: 1.0, windage_mil: 0.0 });
1843        let report_dialed = plan_corrections(corr, &dialed, 100.0, 1.0, 1.0, &prefs).unwrap();
1844        let dial_dialed = plan_for(Strategy::DialAll, &report_dialed);
1845
1846        assert_eq!(dial_zeroed.instructions[0].target_clicks_from_zero, 23);
1847        assert_eq!(dial_dialed.instructions[0].target_clicks_from_zero, 23,
1848            "target_clicks_from_zero must NOT move just because turret_state changed");
1849        assert_eq!(dial_zeroed.instructions[0].delta_clicks, 23);
1850        assert_eq!(
1851            dial_dialed.instructions[0].delta_clicks, 13,
1852            "1.0 mil dialed = 10 clicks of state; delta_clicks must drop by exactly 10 (23 -> 13)"
1853        );
1854
1855        // Review fix I3: `end_revolution` must be computed from `target_clicks_from_zero`
1856        // (23, unchanged by turret_state -- cpr 10 -> revolution_annotation(23, 10) ==
1857        // (2, 3)), never from `delta_clicks` (23 for the zeroed case but 13 for the dialed
1858        // one, which at cpr 10 would give the WRONG (1, 3)). A silent switch to delta would
1859        // misreport a real, dialed-scope shooter's revolution count.
1860        assert_eq!(dial_zeroed.instructions[0].end_revolution, Some((2, 3)));
1861        assert_eq!(
1862            dial_dialed.instructions[0].end_revolution,
1863            Some((2, 3)),
1864            "end_revolution must stay (2, 3) with turret_state dialed, not shift to \
1865             delta_clicks=13's (1, 3)"
1866        );
1867    }
1868
1869    // ---- Beyond the brief: NoTravelData / NoHoldBoundData disclosure ----
1870    //
1871    // Neither LimitKind is exercised by the brief's nine named tests, but both appear in
1872    // the `LimitKind` interface the brief itself specifies, so their behavior is a real
1873    // part of this task's surface, not an incidental implementation detail. Absence of
1874    // declared data is a WEAKER claim than a known, exceeded limit (see the module's
1875    // "Honesty" doc section): it is disclosed in `limits_hit`, and it gates exactly the
1876    // strategies whose OWN feasibility promise depends on that data.
1877
1878    #[test]
1879    fn missing_travel_data_is_disclosed_and_gates_dial_all_but_not_hybrid() {
1880        let mut optic = baseline_profile();
1881        optic.elevation_travel = None; // no travel data at all on this axis
1882        let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
1883        let report =
1884            plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default()).unwrap();
1885
1886        let dial_all = plan_for(Strategy::DialAll, &report);
1887        assert!(
1888            dial_all.limits_hit.iter().any(|v| matches!(
1889                v,
1890                LimitViolation { axis: Axis::Elevation, kind: LimitKind::NoTravelData, available_mil: None, .. }
1891            )),
1892            "{:?}",
1893            dial_all.limits_hit
1894        );
1895        assert!(!dial_all.feasible, "DialAll cannot affirm feasibility without travel data");
1896
1897        let hybrid = plan_for(Strategy::Hybrid, &report);
1898        assert!(
1899            hybrid.limits_hit.iter().any(|v| matches!(v.kind, LimitKind::NoTravelData)),
1900            "Hybrid still discloses the missing data: {:?}",
1901            hybrid.limits_hit
1902        );
1903        assert!(
1904            hybrid.feasible,
1905            "Hybrid's feasibility does not depend on travel data at all, only its hold \
1906             fitting bounds (it does, here): {hybrid:?}"
1907        );
1908
1909        // A trivial (zero) correction needs no travel data at all, so nothing is disclosed.
1910        let zero_corr = AngularCorrection { elevation_mil: 0.0, windage_mil: 0.0 };
1911        let zero_report =
1912            plan_corrections(zero_corr, &optic, 100.0, 1.0, 1.0, &Preferences::default())
1913                .unwrap();
1914        let zero_dial_all = plan_for(Strategy::DialAll, &zero_report);
1915        assert!(zero_dial_all.limits_hit.is_empty(), "{:?}", zero_dial_all.limits_hit);
1916        assert!(zero_dial_all.feasible);
1917    }
1918
1919    #[test]
1920    fn missing_hold_bound_data_is_disclosed_and_gates_hold_all_and_hybrid() {
1921        let mut optic = baseline_profile();
1922        optic.reticle_hold_bounds = None; // no hold bound data at all
1923        let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
1924        let prefs = Preferences::default(); // max_hold_mil also None
1925        let report = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
1926
1927        let hold_all = plan_for(Strategy::HoldAll, &report);
1928        assert!(
1929            hold_all.limits_hit.iter().any(|v| matches!(
1930                v,
1931                LimitViolation { axis: Axis::Elevation, kind: LimitKind::NoHoldBoundData, available_mil: None, .. }
1932            )),
1933            "{:?}",
1934            hold_all.limits_hit
1935        );
1936        assert!(!hold_all.feasible);
1937
1938        let hybrid = plan_for(Strategy::Hybrid, &report);
1939        assert!(
1940            hybrid.limits_hit.iter().any(|v| matches!(v.kind, LimitKind::NoHoldBoundData)),
1941            "{:?}",
1942            hybrid.limits_hit
1943        );
1944        assert!(!hybrid.feasible, "Hybrid's hold ALSO cannot be verified here: {hybrid:?}");
1945
1946        // But `max_hold_mil` alone is enough to make the hold checkable again -- 3.0 is
1947        // comfortably above the 2.34 mil hold actually needed, so this must fit, not merely
1948        // become checkable-and-still-exceeded.
1949        let capped = Preferences { prefer_hold: false, max_hold_mil: Some(3.0) };
1950        let capped_report = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &capped).unwrap();
1951        let capped_hold_all = plan_for(Strategy::HoldAll, &capped_report);
1952        assert!(
1953            capped_hold_all.limits_hit.is_empty(),
1954            "2.34 fits within the 3.0 cap, so nothing should be recorded at all: {:?}",
1955            capped_hold_all.limits_hit
1956        );
1957        assert!(capped_hold_all.feasible, "{:?}", capped_hold_all);
1958    }
1959
1960    #[test]
1961    fn max_clicks_within_handles_exact_and_fractional_boundaries() {
1962        // Exact multiples: floating-point noise in the division must not discard a click.
1963        assert_eq!(max_clicks_within(0.4, 0.1), 4);
1964        assert_eq!(max_clicks_within(28.0, 0.1), 280);
1965        assert_eq!(max_clicks_within(6.0, 0.1), 60);
1966        // A genuinely fractional boundary floors -- never rounds up past a hard limit.
1967        assert_eq!(max_clicks_within(0.45, 0.1), 4);
1968        assert_eq!(max_clicks_within(0.05, 0.1), 0);
1969    }
1970
1971    #[test]
1972    fn preferences_default_prefers_dial_with_no_hold_cap() {
1973        let p = Preferences::default();
1974        assert!(!p.prefer_hold);
1975        assert_eq!(p.max_hold_mil, None);
1976    }
1977
1978    #[test]
1979    fn plan_corrections_rejects_an_invalid_profile() {
1980        let mut optic = baseline_profile();
1981        optic.elevation_click.size = 0.0; // rejected by OpticProfile::validate
1982        let corr = AngularCorrection { elevation_mil: 1.0, windage_mil: 0.0 };
1983        let result = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default());
1984        assert!(matches!(result, Err(OpticError::NonPositiveClickSize { .. })), "{result:?}");
1985    }
1986
1987    // ==== 2026-08 review fixes ====
1988    //
1989    // A reviewer hand-verified the CF rule and found the arithmetic core correct, but
1990    // found the hold-bound mapping inverted on BOTH axes (C1, critical) and several gaps
1991    // this task's original 14 tests could not have caught because nothing exercised an
1992    // asymmetric hold bound, a nonzero windage correction, HoldAll's own hold value, an
1993    // infeasible-but-zero-residual plan, a non-positive CF, or a non-unit-CF travel
1994    // violation. Every test below is a discriminating test for one of those gaps: each is
1995    // built so the specific bug it targets makes it fail, not just a generic re-assertion
1996    // of already-covered behavior.
1997
1998    // C1 (CRITICAL): `hold_mil` is correction-space (+ = up/right, matching a dial's own
1999    // convention); `HoldBounds` is `crate::reticle`'s RETICLE-space (`src/reticle.rs:30-42`:
2000    // `down_mil` positive BELOW center, `right_mil` positive to the shooter's RIGHT). These
2001    // are OPPOSITELY signed on both axes: a positive (up) correction's hold mark sits BELOW
2002    // center (consumes `down_mil`, not `up_mil`) -- exactly how a BDC reticle's long-range
2003    // holdover marks sit below center, never above. A positive (right) correction's hold
2004    // mark sits to the LEFT of center (consumes `left_mil`, not `right_mil`) by the mirrored
2005    // argument. See the module's "Honesty" doc section for the full derivation.
2006    //
2007    // Baseline's elevation hold bounds are deliberately asymmetric (5.0 up / 10.0 down), so
2008    // this test actually discriminates the two possible mappings: a +7.0 mil correction
2009    // fits (7.0 <= 10.0 available below center) while a -7.0 mil correction does not
2010    // (7.0 > 5.0 available above center) -- the OLD, inverted mapping gets BOTH of these
2011    // backwards (it would have accepted -7.0 and rejected +7.0). Windage needs its own
2012    // asymmetric profile since baseline's windage bounds (6.0/6.0) are symmetric and
2013    // therefore cannot discriminate the mapping on their own.
2014    #[test]
2015    fn hold_bound_mapping_matches_reticle_space_not_correction_space() {
2016        let optic = baseline_profile(); // elevation hold bounds: up 5.0 / down 10.0
2017        let prefs = Preferences::default();
2018
2019        let up_report = plan_corrections(
2020            AngularCorrection { elevation_mil: 7.0, windage_mil: 0.0 },
2021            &optic,
2022            100.0,
2023            1.0,
2024            1.0,
2025            &prefs,
2026        )
2027        .unwrap();
2028        let up_hold_all = plan_for(Strategy::HoldAll, &up_report);
2029        assert!(
2030            up_hold_all.feasible,
2031            "+7.0 mil (up) consumes down_mil=10.0 (available) and must fit: {up_hold_all:?}"
2032        );
2033        assert!(up_hold_all.limits_hit.is_empty(), "{:?}", up_hold_all.limits_hit);
2034
2035        let down_report = plan_corrections(
2036            AngularCorrection { elevation_mil: -7.0, windage_mil: 0.0 },
2037            &optic,
2038            100.0,
2039            1.0,
2040            1.0,
2041            &prefs,
2042        )
2043        .unwrap();
2044        let down_hold_all = plan_for(Strategy::HoldAll, &down_report);
2045        assert!(
2046            !down_hold_all.feasible,
2047            "-7.0 mil (down) consumes up_mil=5.0 (available) and must NOT fit: {down_hold_all:?}"
2048        );
2049        let down_violation = down_hold_all
2050            .limits_hit
2051            .iter()
2052            .find(|v| v.axis == Axis::Elevation && matches!(v.kind, LimitKind::HoldBoundExceeded))
2053            .unwrap_or_else(|| panic!("{:?}", down_hold_all.limits_hit));
2054        assert_eq!(down_violation.available_mil, Some(5.0));
2055
2056        // Windage: same asymmetry, mirrored -- a +right correction consumes left_mil, a
2057        // -left correction consumes right_mil.
2058        let mut windage_optic = baseline_profile();
2059        {
2060            let bounds = windage_optic.reticle_hold_bounds.as_mut().unwrap();
2061            bounds.left_mil = 8.0;
2062            bounds.right_mil = 3.0;
2063        }
2064
2065        let right_report = plan_corrections(
2066            AngularCorrection { elevation_mil: 0.0, windage_mil: 7.0 },
2067            &windage_optic,
2068            100.0,
2069            1.0,
2070            1.0,
2071            &prefs,
2072        )
2073        .unwrap();
2074        let right_hold_all = plan_for(Strategy::HoldAll, &right_report);
2075        assert!(
2076            right_hold_all.feasible,
2077            "+7.0 mil (right) consumes left_mil=8.0 (available) and must fit: {right_hold_all:?}"
2078        );
2079
2080        let left_report = plan_corrections(
2081            AngularCorrection { elevation_mil: 0.0, windage_mil: -7.0 },
2082            &windage_optic,
2083            100.0,
2084            1.0,
2085            1.0,
2086            &prefs,
2087        )
2088        .unwrap();
2089        let left_hold_all = plan_for(Strategy::HoldAll, &left_report);
2090        assert!(
2091            !left_hold_all.feasible,
2092            "-7.0 mil (left) consumes right_mil=3.0 (available) and must NOT fit: {left_hold_all:?}"
2093        );
2094        let left_violation = left_hold_all
2095            .limits_hit
2096            .iter()
2097            .find(|v| v.axis == Axis::Windage && matches!(v.kind, LimitKind::HoldBoundExceeded))
2098            .unwrap_or_else(|| panic!("{:?}", left_hold_all.limits_hit));
2099        assert_eq!(left_violation.available_mil, Some(3.0));
2100    }
2101
2102    // The "danger case" the review specifically flagged: a travel-clamped Hybrid whose
2103    // hold the glass genuinely cannot support must be `feasible: false` -- the inverted
2104    // mapping could certify exactly that as feasible (whichever bound is larger always
2105    // "fits", regardless of which side the hold is actually on).
2106    #[test]
2107    fn travel_clamped_hybrid_with_an_unsupportable_hold_is_infeasible() {
2108        let optic = baseline_profile(); // down travel 0.4 mil; hold bounds up 5.0 / down 10.0
2109        // -7.4 mil needs -74 clicks; only 4 clicks (0.4 mil) of down travel exist, so
2110        // Hybrid clamps to -4 clicks (-0.4 true mil) and must hold the remaining exactly
2111        // -7.0 (hand-verified: -4.0*0.1 == -0.4 exactly, -7.4-(-0.4) == -7.0 exactly).
2112        // This magnitude is chosen to land BETWEEN the two candidate bounds and so
2113        // genuinely discriminate them: the CORRECT mapping checks a down-hold against
2114        // up_mil=5.0 (7.0 > 5.0 -> infeasible, correctly, since the glass cannot show 7.0
2115        // mil of upward hold), while the OLD, inverted mapping checked it against
2116        // down_mil=10.0 (7.0 <= 10.0 -> would have wrongly certified this feasible). See
2117        // the fault-injection transcript in the task report: re-inverting the mapping
2118        // makes this test fail, exactly as this comment predicts.
2119        let corr = AngularCorrection { elevation_mil: -7.4, windage_mil: 0.0 };
2120        let report =
2121            plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default()).unwrap();
2122        let hybrid = plan_for(Strategy::Hybrid, &report);
2123        let e = &hybrid.instructions[0];
2124        assert_eq!(e.target_clicks_from_zero, -4);
2125        assert_eq!(e.hold_mil, -7.0, "hold_mil = {}", e.hold_mil);
2126        assert!(
2127            !hybrid.feasible,
2128            "a -7.0 mil hold exceeds the correct up_mil=5.0 bound and must be infeasible, \
2129             even though it would fit the WRONG down_mil=10.0 bound an inverted mapping \
2130             would have checked it against: {hybrid:?}"
2131        );
2132    }
2133
2134    // I1: the original 14 tests all used `windage_mil: 0.0`, so `instructions[1]` was never
2135    // read -- a bug that transposed elevation's click/CF into the windage arm (or dropped
2136    // the windage term from the RSS) would have passed every one of them. Deliberately
2137    // different per-axis click gradutions and CFs so a transposition changes the numbers.
2138    //
2139    // Hand-derived (Python-verified against IEEE-754 binary64 arithmetic):
2140    //   Elevation: 2.34 true mil / cf 0.98 -> corr_dial = 2.387755102040816
2141    //              / 0.1 mil click -> round to 24 clicks
2142    //              dial_true = 24*0.1*0.98 = 2.3520000000000003
2143    //              residual  = 2.34 - 2.352 = -0.012000000000000455 (elevation_cf != 1.0
2144    //              here, unlike most other tests, so this residual differs from the
2145    //              cf=1.0, 23-click result used elsewhere in this file)
2146    //   Windage:  -1.3 true mil / cf 1.05 -> corr_dial = -1.2380952380952381
2147    //              0.25 MOA click -> click_mil = 0.25*1000/3438 = 0.07271669575334497
2148    //              corr_dial / click_mil = -17.026... -> round to -17 clicks
2149    //              dial_true = -17*0.07271669575334497*1.05 = -1.2979930191972078
2150    //              residual  = -1.3 - (-1.2979930191972078) = -0.0020069808027922686
2151    //   residual_linear_at_range_m @ 400 m:
2152    //              e_lin = 0.012000000000000455/1000*400 = 0.0048000000000000004
2153    //              w_lin = 0.0020069808027922686/1000*400 = 0.0008027923211169075
2154    //              rss   = sqrt(e_lin^2 + w_lin^2) = 0.004866669858419206
2155    #[test]
2156    fn windage_arm_uses_its_own_click_cf_and_contributes_to_the_rss() {
2157        let mut optic = baseline_profile();
2158        optic.windage_click = ClickValue { size: 0.25, base: ClickBase::Moa };
2159        let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: -1.3 };
2160        let report = plan_corrections(corr, &optic, 400.0, 0.98, 1.05, &Preferences::default())
2161            .unwrap();
2162
2163        let dial_all = plan_for(Strategy::DialAll, &report);
2164        let e = &dial_all.instructions[0];
2165        assert_eq!(e.axis, Axis::Elevation);
2166        assert_eq!(e.target_clicks_from_zero, 24);
2167        assert_eq!(e.direction, Direction::Up);
2168
2169        let w = &dial_all.instructions[1];
2170        assert_eq!(w.axis, Axis::Windage);
2171        assert_eq!(w.target_clicks_from_zero, -17);
2172        assert!(
2173            (w.dial_mil_true - (-1.2979930191972078)).abs() < 1e-12,
2174            "windage dial_mil_true = {}",
2175            w.dial_mil_true
2176        );
2177        assert_eq!(w.hold_mil, 0.0, "DialAll never holds");
2178        assert_eq!(w.direction, Direction::Left, "-17 clicks must read as Left, not Up/Down");
2179
2180        assert!(
2181            (dial_all.residual_linear_at_range_m - 0.004866669858419206).abs() < 1e-9,
2182            "residual_linear_at_range_m = {} -- both axes' residuals are nonzero here, so \
2183             this must be a real two-term RSS, not a single-axis pass-through",
2184            dial_all.residual_linear_at_range_m
2185        );
2186
2187        // Hybrid's windage hold is nonzero and independently exercises the SAME per-axis
2188        // parameters through `build_axis_instruction`'s separate Hybrid arm.
2189        let hybrid = plan_for(Strategy::Hybrid, &report);
2190        let wh = &hybrid.instructions[1];
2191        assert!(
2192            (wh.hold_mil - (-0.0020069808027922686)).abs() < 1e-9,
2193            "hybrid windage hold_mil = {}",
2194            wh.hold_mil
2195        );
2196        assert_eq!(wh.residual_mil, 0.0);
2197    }
2198
2199    // I2: HoldAll's hold was never asserted in the original 14 tests, so CF-scaling it
2200    // (`corr_true / cf` instead of `corr_true`) would have passed all of them -- the exact
2201    // bug the brief names ("Reticle holds are TRUE angular and are NEVER CF-scaled").
2202    // Checked at cf = 0.98 specifically: at cf == 1.0 a scaling bug is invisible (dividing
2203    // by 1.0 is a no-op).
2204    #[test]
2205    fn hold_all_hold_is_never_cf_scaled() {
2206        let optic = baseline_profile();
2207        let corr = AngularCorrection { elevation_mil: 5.0, windage_mil: 0.0 };
2208        let report = plan_corrections(corr, &optic, 100.0, 0.98, 1.0, &Preferences::default())
2209            .unwrap();
2210        let hold_all = plan_for(Strategy::HoldAll, &report);
2211        assert_eq!(
2212            hold_all.instructions[0].hold_mil, 5.0,
2213            "must equal corr_true exactly (bit-exact copy, no arithmetic at all) -- NOT \
2214             corr_true / cf = {}",
2215            5.0_f64 / 0.98
2216        );
2217    }
2218
2219    // I4: `HoldAll`/`Hybrid` carry `residual_mil == 0.0` (and therefore
2220    // `residual_linear_at_range_m == 0.0`) even when INFEASIBLE -- residual is about exact
2221    // reconstruction, not achievability. Without `feasible` as the PRIMARY ranking key, an
2222    // infeasible zero-residual plan could rank ahead of the only plan that actually works,
2223    // and Task 6's CLI surfaces `plans[0]` as THE recommendation.
2224    #[test]
2225    fn infeasible_plans_never_outrank_a_feasible_one() {
2226        let optic = baseline_profile();
2227        // 2.34 (not an exact click multiple) so Hybrid's hold is nonzero, and DialAll's
2228        // residual is nonzero -- both meaningfully exercised, not vacuously all-zero.
2229        let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: 0.0 };
2230        // max_hold_mil: Some(0.0) makes ANY nonzero hold infeasible on HoldAll and Hybrid.
2231        // DialAll never touches hold at all, so its feasibility depends only on travel,
2232        // which comfortably fits (23 clicks * 0.1 mil = 2.3 <= 28.0 mil up travel).
2233        let prefs = Preferences { prefer_hold: false, max_hold_mil: Some(0.0) };
2234        let report = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &prefs).unwrap();
2235
2236        let dial_all = plan_for(Strategy::DialAll, &report);
2237        let hold_all = plan_for(Strategy::HoldAll, &report);
2238        let hybrid = plan_for(Strategy::Hybrid, &report);
2239        assert!(dial_all.feasible, "{dial_all:?}");
2240        assert!(!hold_all.feasible, "{hold_all:?}");
2241        assert!(!hybrid.feasible, "{hybrid:?}");
2242        assert_eq!(hold_all.residual_linear_at_range_m, 0.0);
2243        assert_eq!(hybrid.residual_linear_at_range_m, 0.0);
2244        assert!(dial_all.residual_linear_at_range_m > 0.0);
2245
2246        assert_eq!(
2247            report.plans[0].strategy,
2248            Strategy::DialAll,
2249            "the only feasible plan must rank first, never an infeasible zero-residual one \
2250             (a residual-only ranking would put Hybrid or HoldAll here instead): {:?}",
2251            report.plans
2252        );
2253        assert!(report.plans[0].feasible);
2254    }
2255
2256    // I5: a zero, negative, or non-finite CF divides `corr_true` into infinity, NaN, or a
2257    // direction-inverting result -- a hard arithmetic failure, not merely an implausible
2258    // tracking factor, so it is rejected outright rather than only warned about.
2259    #[test]
2260    fn non_positive_or_non_finite_tracking_factor_is_rejected() {
2261        let optic = baseline_profile();
2262        let corr = AngularCorrection { elevation_mil: 1.0, windage_mil: 0.0 };
2263        for bad_cf in [0.0, -1.0, -0.5, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2264            let elevation_result =
2265                plan_corrections(corr, &optic, 100.0, bad_cf, 1.0, &Preferences::default());
2266            assert!(
2267                matches!(
2268                    elevation_result,
2269                    Err(OpticError::NonPositiveTrackingFactor { field: "elevation_cf", .. })
2270                ),
2271                "cf={bad_cf}: {elevation_result:?}"
2272            );
2273            let windage_result =
2274                plan_corrections(corr, &optic, 100.0, 1.0, bad_cf, &Preferences::default());
2275            assert!(
2276                matches!(
2277                    windage_result,
2278                    Err(OpticError::NonPositiveTrackingFactor { field: "windage_cf", .. })
2279                ),
2280                "cf={bad_cf}: {windage_result:?}"
2281            );
2282        }
2283        // A positive CF far outside `tracking_cf_in_range`'s advisory (0.5, 1.5) band is
2284        // NOT rejected here -- only <= 0 or non-finite is a hard library-level error; the
2285        // plausibility band stays an advisory, caller/CLI-level concern (see the module doc
2286        // and `OpticError::NonPositiveTrackingFactor`'s own doc comment).
2287        let lenient = plan_corrections(corr, &optic, 100.0, 0.001, 1.0, &Preferences::default());
2288        assert!(lenient.is_ok(), "{lenient:?}");
2289    }
2290
2291    // M3: `LimitViolation::needed_mil` must be DIAL-space (`corr_true / cf`), never
2292    // TRUE-space (`corr_true`) -- indistinguishable at cf == 1.0 (every other test in this
2293    // file happens to use cf == 1.0 for its travel-violation checks), so this must run at
2294    // cf != 1.0 to actually discriminate the two.
2295    #[test]
2296    fn travel_violation_needed_mil_is_dial_space_not_true_space_at_nonunit_cf() {
2297        let mut optic = baseline_profile();
2298        optic.elevation_travel = Some(TravelLimits { down_mil: 0.1, up_mil: 0.1 });
2299        let corr = AngularCorrection { elevation_mil: 1.0, windage_mil: 0.0 }; // TRUE mil
2300        let cf = 0.5;
2301        let report =
2302            plan_corrections(corr, &optic, 100.0, cf, 1.0, &Preferences::default()).unwrap();
2303        let dial_all = plan_for(Strategy::DialAll, &report);
2304        assert!(!dial_all.feasible, "{dial_all:?}");
2305        let violation = dial_all
2306            .limits_hit
2307            .iter()
2308            .find(|v| matches!(v.kind, LimitKind::TravelExceeded))
2309            .unwrap_or_else(|| panic!("{:?}", dial_all.limits_hit));
2310        let corr_dial = corr.elevation_mil / cf; // 2.0
2311        assert_eq!(
2312            violation.needed_mil, corr_dial,
2313            "needed_mil must be DIAL-space (corr_true/cf = {corr_dial}), not TRUE-space \
2314             ({})",
2315            corr.elevation_mil
2316        );
2317    }
2318
2319    // M1: `direction` moved from `&'static str` to a real `Direction` enum specifically so
2320    // `AxisInstruction`/`DialPlan`/`DialPlanReportV1` could derive `Deserialize` again.
2321    // Pins that the wire form is UNCHANGED (still lowercase `"up"`/`"down"`/`"left"`/
2322    // `"right"`) and that a full report now genuinely round-trips through JSON.
2323    #[test]
2324    fn direction_wire_form_is_unchanged_and_the_full_report_round_trips() {
2325        for (direction, expected_json) in [
2326            (Direction::Up, "\"up\""),
2327            (Direction::Down, "\"down\""),
2328            (Direction::Left, "\"left\""),
2329            (Direction::Right, "\"right\""),
2330        ] {
2331            assert_eq!(serde_json::to_string(&direction).unwrap(), expected_json);
2332        }
2333
2334        let optic = baseline_profile();
2335        let corr = AngularCorrection { elevation_mil: 2.34, windage_mil: -1.3 };
2336        let report = plan_corrections(corr, &optic, 100.0, 1.0, 1.0, &Preferences::default())
2337            .unwrap();
2338        let json = serde_json::to_string(&report).unwrap();
2339        let parsed: DialPlanReportV1 = serde_json::from_str(&json).unwrap();
2340        assert_eq!(parsed, report);
2341    }
2342}