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