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