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