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