Expand description
Turret and reticle geometry: click detents, revolutions, physical travel, current dial state, and reticle hold limits (MBA-1348).
Before this module the crate’s entire optic model was crate::adjustment::ClickValue
plus two tracking correction factors (elevation/windage CF), applied ad hoc wherever a
dialed number needed to become a true angular one. Real turrets have more structure
than that: click detents grouped into revolutions, an optional zero stop, finite
mechanical travel, and a current dialed offset from zero. Real reticles have a finite
usable hold extent too. OpticProfile gives all of that a home so a later dial/hold/
hybrid engagement planner has somewhere to read it from. This module is pure data and
validation — it does not itself decide how to engage a target.
§MIL, always
Every angular field in this module — travel, turret state, hold bounds — is in
milliradians. There is no unit-selection knob here; presenting other units (MOA, SMOA,
whole clicks) is a front-end concern, the same way crate::adjustment already handles
it for the CLI and the WASM terminal.
§DIAL-space vs TRUE-angular — the one distinction this module exists to encode
Two different kinds of “mil” appear in this crate and they are NOT interchangeable:
- Turret quantities —
clicks_per_revolution,TravelLimits,TurretState, and clicks generally — live in DIAL-space: the number engraved on the turret, the number a shooter actually dials. A scope with a tracking correction factor (CF) below 1.0 delivers LESS true angular motion per dialed unit than the engraving claims, so it takes MORE dial to reach a given true angle. This crate’s established convention for that relationship — seecrate::adjustment::zero_banner_dial_valuesandcrate::truing::scale_report_dial_values— is that dial-space OUTPUTS are obtained by DIVIDING a true angular value by the CF. This module’s turret types inherit that convention unchanged: they do not carry or apply a CF themselves, they simply live in the same DIAL-space that convention produces. - Reticle holds elsewhere in the crate (
crate::reticle::ReticleHold) are TRUE angular and are never CF-scaled — a mil subtension etched in glass does not care how the turret happens to be calibrated.
A hold and a dialed correction for the identical point of impact are therefore numerically different whenever CF != 1.0. Code that mixes the two spaces without an explicit conversion will silently compute nonsense.
§Travel and turret state are measured from the CURRENT ZERO, not the mechanical stop
TravelLimits and TurretState are offsets from wherever the shooter has zeroed the
rifle, not from the turret’s mechanical bottom. That is the fact a shooter actually
knows and can act on in the field — “I have 28 mil of up travel left from my zero” —
not a fact about the turret’s total mechanical range, which also depends on where in
that range the zero happens to sit and is not tracked here.
§reticle_hold_bounds is an explicit input, never derived
See HoldBounds for why: crate::reticle’s off_reticle bounding box is a property
of which marks someone chose to author, grown by a fixed margin, not a property of the
scope’s actual usable optical extent. It must never be reused as a stand-in for a real
spec.
§plan_corrections and the CF rule
plan_corrections turns a TRUE angular AngularCorrection into ranked, executable
DialPlans: dial the whole correction in whole clicks, hold the whole correction on
the reticle, or split it (dial what the turret can reach, hold the TRUE angular
remainder). Getting the DIAL-space/TRUE-angular distinction above right in every arm is
the entire reason this function exists, so the rule is restated here in full and
followed literally everywhere below:
- Turret travel, turret state, and click counts all live in DIAL space, as established above.
- A tracking correction factor (CF) maps one space to the other. To EXECUTE a TRUE
angular need of
corr_truemil on a turret whose CF iscf, the DIAL target iscorr_dial = corr_true / cf— the CF DIVIDES going from a true need to a dial target, the same direction ascrate::adjustment::zero_banner_dial_valuesandcrate::truing::scale_report_dial_values. That target is quantized onto whole clicks IN DIAL SPACE, viaquantize_angle(corr_dial, &ClickValue { size: click_size_mil(click), base: ClickBase::Mil })— a SYNTHETIC click value in mil so the reconstruction identity (clicks as f64 * size + residual == corr_dial) holds in mil regardless of whether the real click graduation is mil, MOA, or SMOA. Whatever whole click count results, the dial then EXECUTESclicks as f64 * click_size_mil(click) * cfTRUE angular mil — the CF MULTIPLIES going from dial clicks back to true angular, the OPPOSITE direction from how it was applied going in. - Reticle holds are TRUE angular and are NEVER CF-scaled, anywhere: a hold component is computed and bounds-checked entirely in true mil, never quantized onto a click.
cf_dial_space_worked_example (this module’s test suite) pins the hand-derived numbers
for a CF of 0.98: a 5.0 true-mil correction on 0.1-mil clicks needs a dial target of
5.0 / 0.98 = 5.10204... mil, which quantizes to 51 clicks; those 51 clicks EXECUTE
51 * 0.1 * 0.98 = 4.998 true mil, leaving a 0.002 true-mil hybrid hold. Swapping the
multiply/divide directions, or holding the DIAL-space remainder (corr_dial - clicks * size) instead of the TRUE one (corr_true - dial_mil_true), silently produces a
different, wrong number here — this module exists specifically to make that mistake
impossible to make quietly.
§Honesty: nothing is silently clamped
A plan whose dial component cannot reach its target given the optic’s declared travel
is still returned — clamped to the travel it actually has — but its feasible field is
false and the clamp is recorded in limits_hit as a LimitViolation. The same is
true of a hold that exceeds reticle_hold_bounds or Preferences::max_hold_mil. A
plan’s residual_mil is always computed against what it actually executes, never
against the original request, so a clamped plan’s residual honestly reflects the
resulting miss (see infeasible_is_reported_never_silently_clamped).
Declared travel/hold data is trusted exactly as given; its ABSENCE is a different,
weaker claim than a KNOWN, EXCEEDED limit, and gets its own LimitKind
(NoTravelData/NoHoldBoundData) rather than being silently treated as “unlimited.”
Whether a missing or exceeded limit turns off a strategy’s OWN feasible flag depends
on what that strategy actually promises: Strategy::DialAll promises to deliver the
WHOLE correction by dialing alone, so it cannot affirm that promise without knowing
(and fitting inside) its travel. Strategy::HoldAll and Strategy::Hybrid promise
(respectively) the whole correction, or the dial shortfall’s exact remainder, via the
reticle, so THEY cannot affirm their promise without knowing (and fitting inside) hold
bounds — but a Hybrid plan’s dial component absorbing less than the full correction
because travel ran out is not a broken promise (the hold is DEFINED to absorb exactly
that shortfall, by construction), so a missing or exceeded TRAVEL limit is recorded on
a Hybrid plan purely for disclosure and never by itself makes it infeasible — only
its own hold not fitting does.
OpticProfile::zero_stop is never read by plan_corrections at all, at travel-known or
travel-None alike: it is descriptive turret metadata (see its own doc comment), and
this module’s ONLY source of truth for what the turret can physically reach is
elevation_travel/windage_travel being Some or None — a zero-stopped turret with
no declared elevation_travel is treated exactly like a non-zero-stopped one with no
declared travel: unverifiable, not “safely bounded at zero because it says zero_stop.”
§Correction-space is not reticle-space (the hold-bound mapping)
hold_mil above is this module’s own correction-space: + means “the point of impact
needs to move up/right,” matching AngularCorrection and a dial’s own convention (a
positive dial adjustment moves point of impact up/right). HoldBounds
(up_mil/down_mil/left_mil/right_mil) is crate::reticle’s RETICLE-space
instead, pinned at src/reticle.rs:30-42: down_mil is positive BELOW the optical
center, right_mil is positive to the shooter’s RIGHT of it, and a holdover mark
(compensating a bullet that falls) sits at POSITIVE down_mil — below center. These
two spaces are OPPOSITELY signed on BOTH axes, not just one: a positive (UP)
correction’s hold mark sits BELOW center (consumes down_mil), exactly like every BDC
reticle’s long-range holdover marks, which sit below center, never above it; a positive
(RIGHT) correction’s hold mark sits to the LEFT of center (consumes left_mil) by the
identical argument, mirrored. AxisComputation names its two fields
bound_for_positive_correction / bound_for_negative_correction (rather than e.g.
“hold_up”) specifically so this mapping is stated at the type level and cannot
silently re-invert; see the
hold_bound_mapping_matches_reticle_space_not_correction_space test, which is built to
fail if it does.
Structs§
- Angular
Correction - A TRUE angular correction to deliver on target – the output of a solve, not yet
realized as any turret or reticle instruction. Positive
elevation_milis UP; positivewindage_milis RIGHT. Never CF-scaled – see the module’s “CF rule” doc section. - Axis
Instruction - One axis’s executable instruction within a
DialPlan(MBA-1348). - Dial
Plan - One ranked, executable plan for delivering a two-axis
AngularCorrection(MBA-1348).instructionsis always exactly[elevation, windage]. - Dial
Plan Report V1 plan_corrections’s versioned report (MBA-1348). Carriesmethodandassumptionsin the payload itself – this train’s cross-cutting honesty principle (spec §2) – not only in prose documentation.- Hold
Bounds - A reticle’s usable hold extent in each direction, TRUE angular mil, relative to the reticle’s own center. All four fields are magnitudes — never negative.
- Limit
Violation - One recorded reason a
DialPlanwas clamped or could not be verified feasible (MBA-1348). Always DISCLOSED inDialPlan::limits_hit, never silently absorbed. - Optic
Profile - A shooter’s complete turret and reticle geometry (MBA-1348).
- Preferences
- Caller preferences steering
plan_corrections’s ranking – never its underlying arithmetic, which is identical regardless of these values. See “Ranking is deterministic” inplan_corrections’s own doc comment. - Travel
Limits - Remaining mechanical turret travel in each direction from the current zero setting,
DIAL-space mil. Both fields are magnitudes — never negative — measured outward from
zero:
down_milis travel available going down (elevation) or left (windage);up_milis travel available going up (elevation) or right (windage).validaterejects negative and non-finite values. - Turret
State - The turret’s current dialed position, as signed offsets from zero — DIAL-space, mil.
Positive
elevation_milis dialed up from zero; positivewindage_milis dialed right from zero. UnlikeTravelLimitsthese ARE signed (a turret can be dialed to either side of zero), sovalidatedoes not reject a negative value on its own — it only rejects a state whose magnitude on an axis exceeds that axis’s declaredTravelLimits, when both are present.
Enums§
- Axis
- Which of a shot’s two independent angular axes an
AxisInstructionorLimitViolationbelongs to (MBA-1348). Unrelated tocrate::reticle_import’s private, importer-internalAxisof the same name in a different module. - Direction
AxisInstruction::direction: which way the DELTA actually dialed turns the turret (MBA-1348). Wire form is lowercase ("up"/"down"/"left"/"right") via#[serde(rename_all)]– unchanged from this type’s original plain-&strform (MBA-1348 review fix M1), but now a real enum soAxisInstruction/DialPlan/DialPlanReportV1can deriveDeserializeagain (a&'static strfield cannot: serde’s blanket impl for&'a strties'ato the deserializer’s own input lifetime, which is essentially never'static).- Limit
Kind - Why a
DialPlancomponent was clamped, or could not be verified feasible (MBA-1348). Paired with the offendingAxisin aLimitViolation. - Optic
Error - Why an
OpticProfilefailedvalidate. - Strategy
- How a
DialPlanproposes to deliver a correction (MBA-1348).
Constants§
- DIAL_
PLAN_ SCHEMA_ VERSION_ V1 - Schema version for
DialPlanReportV1’s payload shape (MBA-1348).
Functions§
- plan_
corrections - Turns a TRUE angular
AngularCorrectioninto ranked, executable dial/hold/hybrid plans for a real optic (MBA-1348) – see the module’s “plan_correctionsand the CF rule” and “Honesty” doc sections for the conventions this function follows in every arm. - revolution_
annotation - Splits a DIAL-space click count measured from zero into
(revolutions, clicks_within_the_revolution), for a turret whose cap marks revolutions everyclicks_per_revolutionclicks — e.g. rendering “2 revolutions + 7 clicks” instead of a bare click count the shooter has to do the division on in their head under time pressure.