#[derive(Debug, Clone, Serialize)]
pub struct CardRow {
pub range: f64,
pub drop_linear: Option<f64>,
pub drop_adj: Option<f64>,
pub come_up: Option<f64>,
pub wind_linear: Option<f64>,
pub wind_adj: Option<f64>,
pub velocity: Option<f64>,
pub energy: Option<f64>,
pub time: Option<f64>,
pub lead_adj: Option<f64>,
pub wind_columns: Vec<f64>,
}
use crate::adjustment::{click_size_mil, quantize_angle, ClickBase, ClickValue};
use crate::hold_curve::HoldCurve;
use serde::Serialize;
use std::fmt;
pub const ADAPTIVE_CARD_SCHEMA_VERSION_V1: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AdaptiveBudget {
pub elevation: f64,
pub windage: f64,
}
#[derive(Debug, Clone)]
pub struct AdaptiveRequest<'a> {
pub domain_m: (f64, f64),
pub anchors_m: Vec<f64>,
pub budget: AdaptiveBudget,
pub max_rows: usize,
pub click: Option<(&'a ClickValue, &'a ClickValue)>,
pub elevation_cf: f64,
pub windage_cf: f64,
pub bias_mil: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardAdjustmentUnit {
Mil,
Moa,
}
impl CardAdjustmentUnit {
pub fn from_mil_factor(&self) -> f64 {
match self {
Self::Mil => 1.0,
Self::Moa => 3438.0 / 1000.0,
}
}
fn click_base(&self) -> ClickBase {
match self {
Self::Mil => ClickBase::Mil,
Self::Moa => ClickBase::Moa,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CardError {
EmptyOrInvertedDomain { start_m: f64, end_m: f64 },
AnchorOutsideDomain {
anchor_m: f64,
start_m: f64,
end_m: f64,
},
NonPositiveBudget { axis: &'static str, value: f64 },
ZeroMaxRows,
DomainOutsideCurve { requested_m: f64, curve_max_m: f64 },
InvalidTrackingCf { axis: &'static str, value: f64 },
}
impl fmt::Display for CardError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyOrInvertedDomain { start_m, end_m } => write!(
f,
"card domain {start_m} m to {end_m} m must be a forward interval with a positive start"
),
Self::AnchorOutsideDomain {
anchor_m,
start_m,
end_m,
} => write!(
f,
"anchor {anchor_m} m lies outside the card domain {start_m} m to {end_m} m"
),
Self::NonPositiveBudget { axis, value } => {
write!(f, "{axis} budget {value} must be positive and finite")
}
Self::ZeroMaxRows => write!(f, "a card needs room for at least one row"),
Self::DomainOutsideCurve {
requested_m,
curve_max_m,
} => write!(
f,
"range {requested_m} m is past the curve's last sampled point at {curve_max_m} m"
),
Self::InvalidTrackingCf { axis, value } => write!(
f,
"{axis} tracking correction factor {value} must be finite and between 0.5 and 1.5 \
(it is a ratio such as 0.95, not a percentage)"
),
}
}
}
impl std::error::Error for CardError {}
#[derive(Debug, Clone, Serialize)]
pub struct AdaptiveCardReportV1 {
pub schema_version: u32,
pub method: String,
pub assumptions: Vec<String>,
pub rows: Vec<CardRow>,
pub budget_met: bool,
pub rows_capped: bool,
pub worst_elevation_error: f64,
pub worst_windage_error: f64,
pub worst_error_range_m: f64,
pub verification_grid_step_m: f64,
}
#[derive(Debug, Clone, Copy)]
struct PrintedAxis {
unit_factor: f64,
bias_mil: f64,
cf: f64,
click: Option<ClickValue>,
}
impl PrintedAxis {
fn new(unit: CardAdjustmentUnit, bias_mil: f64, cf: f64, click: Option<&ClickValue>) -> Self {
let unit_factor = unit.from_mil_factor();
Self {
unit_factor,
bias_mil,
cf,
click: click.map(|c| ClickValue {
size: click_size_mil(c) * unit_factor,
base: unit.click_base(),
}),
}
}
fn exact(&self, true_mil: f64) -> f64 {
let printed = true_mil * self.unit_factor;
let biased = if self.bias_mil != 0.0 {
printed + self.bias_mil * self.unit_factor
} else {
printed
};
biased / self.cf
}
fn printed(&self, true_mil: f64) -> f64 {
let exact = self.exact(true_mil);
match &self.click {
Some(c) => quantize_angle(exact, c).clicks as f64 * c.size,
None => exact,
}
}
}
#[derive(Debug, Clone, Copy)]
struct AuditPoint {
range_m: f64,
exact_elevation: f64,
exact_windage: f64,
printed_elevation: f64,
printed_windage: f64,
drop_linear_m: f64,
wind_linear_m: f64,
velocity_mps: f64,
energy_j: f64,
time_s: f64,
}
type AxisErrors = (f64, f64);
fn sorted_dedup(mut values: Vec<f64>) -> Vec<f64> {
values.sort_by(f64::total_cmp);
values.dedup();
values
}
fn native_grid_m(start_m: f64, end_m: f64) -> Vec<f64> {
let step = HoldCurve::SAMPLE_INTERVAL_M;
let first = ((start_m / step).ceil() as i64).max(1);
let last = (end_m / step).floor() as i64;
let mut grid = Vec::new();
for i in first..=last {
let g = i as f64 * step;
if g >= start_m && g <= end_m {
grid.push(g);
}
}
grid
}
fn sweep(audit: &[AuditPoint], rows: &[usize]) -> Vec<AxisErrors> {
let mut errors = vec![(0.0, 0.0); audit.len()];
for &r in rows {
let p = &audit[r];
errors[r] = (
(p.printed_elevation - p.exact_elevation).abs(),
(p.printed_windage - p.exact_windage).abs(),
);
}
for pair in rows.windows(2) {
let (lo, hi) = (pair[0], pair[1]);
let (a, b) = (&audit[lo], &audit[hi]);
let span = b.range_m - a.range_m;
for (k, point) in audit.iter().enumerate().take(hi).skip(lo + 1) {
let t = if span > 0.0 {
(point.range_m - a.range_m) / span
} else {
0.0
};
let elevation = a.printed_elevation + (b.printed_elevation - a.printed_elevation) * t;
let windage = a.printed_windage + (b.printed_windage - a.printed_windage) * t;
errors[k] = (
(elevation - point.exact_elevation).abs(),
(windage - point.exact_windage).abs(),
);
}
}
errors
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LoopTrace {
iterations: usize,
iteration_cap: usize,
}
pub fn adaptive_card(
curve: &HoldCurve,
req: &AdaptiveRequest,
unit: CardAdjustmentUnit,
) -> Result<AdaptiveCardReportV1, CardError> {
adaptive_card_traced(curve, req, unit).map(|(report, _)| report)
}
fn adaptive_card_traced(
curve: &HoldCurve,
req: &AdaptiveRequest,
unit: CardAdjustmentUnit,
) -> Result<(AdaptiveCardReportV1, LoopTrace), CardError> {
let (start_m, end_m) = req.domain_m;
if req.max_rows == 0 {
return Err(CardError::ZeroMaxRows);
}
for (axis, value) in [
("elevation", req.budget.elevation),
("windage", req.budget.windage),
] {
if !value.is_finite() || value <= 0.0 {
return Err(CardError::NonPositiveBudget { axis, value });
}
}
for (axis, value) in [
("elevation", req.elevation_cf),
("windage", req.windage_cf),
] {
if !crate::adjustment::tracking_cf_in_range(value) {
return Err(CardError::InvalidTrackingCf { axis, value });
}
}
if !start_m.is_finite() || !end_m.is_finite() || start_m <= 0.0 || end_m <= start_m {
return Err(CardError::EmptyOrInvertedDomain { start_m, end_m });
}
let curve_max_m = curve.max_sampled_range_m();
if end_m > curve_max_m {
return Err(CardError::DomainOutsideCurve {
requested_m: end_m,
curve_max_m,
});
}
for &anchor_m in &req.anchors_m {
if !anchor_m.is_finite() || anchor_m < start_m || anchor_m > end_m {
return Err(CardError::AnchorOutsideDomain {
anchor_m,
start_m,
end_m,
});
}
}
debug_assert!(req.bias_mil.is_finite(), "zero-set bias must be finite");
let (elevation_click, windage_click) = match req.click {
Some((e, w)) => (Some(e), Some(w)),
None => (None, None),
};
let elevation = PrintedAxis::new(unit, req.bias_mil, req.elevation_cf, elevation_click);
let windage = PrintedAxis::new(unit, 0.0, req.windage_cf, windage_click);
let mut seeds = vec![start_m, end_m];
seeds.extend_from_slice(&req.anchors_m);
let mut ranges = native_grid_m(start_m, end_m);
ranges.extend_from_slice(&seeds);
let ranges = sorted_dedup(ranges);
let mut audit = Vec::with_capacity(ranges.len());
for range_m in ranges {
let point = curve
.at_range(range_m)
.ok_or(CardError::DomainOutsideCurve {
requested_m: range_m,
curve_max_m,
})?;
audit.push(AuditPoint {
range_m,
exact_elevation: elevation.exact(point.drop_mil),
exact_windage: windage.exact(point.wind_mil),
printed_elevation: elevation.printed(point.drop_mil),
printed_windage: windage.printed(point.wind_mil),
drop_linear_m: point.drop_mil / 1000.0 * range_m,
wind_linear_m: point.wind_mil / 1000.0 * range_m,
velocity_mps: point.velocity_mps,
energy_j: point.energy_j,
time_s: point.time_s,
});
}
let mut rows: Vec<usize> = sorted_dedup(seeds)
.iter()
.map(|target| {
audit
.partition_point(|p| p.range_m < *target)
.min(audit.len() - 1)
})
.collect();
rows.dedup();
let mut is_row = vec![false; audit.len()];
for &r in &rows {
is_row[r] = true;
}
debug_assert_eq!(rows.first(), Some(&0), "the domain start must seed row 0");
debug_assert_eq!(
rows.last(),
Some(&(audit.len() - 1)),
"the domain end must seed the last row"
);
let iteration_cap = 2 * audit.len() + 8;
let mut iterations = 0usize;
let mut rows_capped = false;
while iterations < iteration_cap {
iterations += 1;
let errors = sweep(&audit, &rows);
let mut any_violation = false;
let mut worst: Option<(f64, usize)> = None;
for (k, &(elevation_error, windage_error)) in errors.iter().enumerate() {
if elevation_error <= req.budget.elevation && windage_error <= req.budget.windage {
continue;
}
any_violation = true;
if is_row[k] {
continue;
}
let excess =
(elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
if worst.is_none_or(|(best, _)| excess > best) {
worst = Some((excess, k));
}
}
if !any_violation {
break;
}
let Some((_, insert_at)) = worst else {
break;
};
if rows.len() >= req.max_rows {
rows_capped = true;
break;
}
rows.insert(rows.partition_point(|&r| r < insert_at), insert_at);
is_row[insert_at] = true;
}
let final_errors = sweep(&audit, &rows);
let mut worst_elevation_error = 0.0_f64;
let mut worst_windage_error = 0.0_f64;
let mut worst_excess = f64::NEG_INFINITY;
let mut worst_error_range_m = audit[0].range_m;
for (k, &(elevation_error, windage_error)) in final_errors.iter().enumerate() {
worst_elevation_error = worst_elevation_error.max(elevation_error);
worst_windage_error = worst_windage_error.max(windage_error);
let excess =
(elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
if excess > worst_excess {
worst_excess = excess;
worst_error_range_m = audit[k].range_m;
}
}
let budget_met =
worst_elevation_error <= req.budget.elevation && worst_windage_error <= req.budget.windage;
let card_rows = rows
.iter()
.map(|&r| {
let p = &audit[r];
CardRow {
range: p.range_m,
drop_linear: Some(p.drop_linear_m),
drop_adj: Some(p.printed_elevation),
come_up: None,
wind_linear: Some(p.wind_linear_m),
wind_adj: Some(p.printed_windage),
velocity: Some(p.velocity_mps),
energy: Some(p.energy_j),
time: Some(p.time_s),
lead_adj: None,
wind_columns: Vec::new(),
}
})
.collect();
let report = AdaptiveCardReportV1 {
schema_version: ADAPTIVE_CARD_SCHEMA_VERSION_V1,
method: "greedy_worst_point_insertion_on_holdcurve_grid_v1".to_string(),
assumptions: adaptive_card_assumptions(),
rows: card_rows,
budget_met,
rows_capped,
worst_elevation_error,
worst_windage_error,
worst_error_range_m,
verification_grid_step_m: HoldCurve::SAMPLE_INTERVAL_M,
};
Ok((
report,
LoopTrace {
iterations,
iteration_cap,
},
))
}
fn adaptive_card_assumptions() -> Vec<String> {
[
"Verification is limited to the hold curve's declared sample grid (verification_grid_step_m) together with the card's own rows; no claim is made about ranges between those audited points.",
"The reader of this card interpolates linearly between adjacent rows.",
"Errors are measured in printed-value space -- the same zero-set bias, tracking-correction division and click quantization the printed rows carry -- so a constant zero-set bias cancels out of the interpolation error and the tracking correction factor is already inside the numbers being compared.",
"Rows quantized to an optic's clicks carry an irreducible error of up to half a click at the rows themselves, which no number of added rows can remove.",
"A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed.",
]
.iter()
.map(|s| (*s).to_string())
.collect()
}
#[cfg(test)]
mod adaptive_card_tests {
use super::*;
use crate::hold_curve::HoldCurveLoad;
use crate::DragModel;
fn test_load() -> HoldCurveLoad {
HoldCurveLoad {
velocity_mps: 800.0,
bc: 0.223,
mass_kg: 0.0109,
diameter_m: 0.00782,
drag_model: DragModel::G7,
sight_height_m: 0.045,
zero_distance_m: 100.0,
temperature_c: 15.0,
pressure_hpa: 1013.25,
humidity: 50.0,
altitude_m: 0.0,
wind_speed_mps: 3.0,
wind_direction_deg: 90.0,
}
}
fn test_curve(max_range_m: f64) -> HoldCurve {
HoldCurve::solve(&test_load(), max_range_m).expect("hold curve should solve")
}
fn plain_request(domain_m: (f64, f64), budget: f64, max_rows: usize) -> AdaptiveRequest<'static> {
AdaptiveRequest {
domain_m,
anchors_m: Vec::new(),
budget: AdaptiveBudget {
elevation: budget,
windage: budget,
},
max_rows,
click: None,
elevation_cf: 1.0,
windage_cf: 1.0,
bias_mil: 0.0,
}
}
fn independent_audited(curve: &HoldCurve, start_m: f64, end_m: f64) -> Vec<(f64, f64, f64)> {
let step = HoldCurve::SAMPLE_INTERVAL_M;
let mut ranges = vec![start_m];
let mut i = 1_i64;
loop {
let g = i as f64 * step;
if g > end_m {
break;
}
if g > start_m {
ranges.push(g);
}
i += 1;
}
if ranges.last().is_none_or(|&last| last < end_m) {
ranges.push(end_m);
}
ranges
.into_iter()
.map(|g| {
let p = curve.at_range(g).expect("audited range must be on the curve");
(g, p.drop_mil, p.wind_mil)
})
.collect()
}
fn independent_worst_error(
audited: &[(f64, f64, f64)],
row_ranges: &[f64],
row_elevation: &[f64],
row_windage: &[f64],
) -> (f64, f64) {
let mut worst = (0.0_f64, 0.0_f64);
for &(g, drop_mil, wind_mil) in audited {
let lo = row_ranges
.partition_point(|&r| r <= g)
.saturating_sub(1)
.min(row_ranges.len() - 2);
let hi = lo + 1;
let span = row_ranges[hi] - row_ranges[lo];
let t = if span > 0.0 {
(g - row_ranges[lo]) / span
} else {
0.0
};
let elevation = row_elevation[lo] + (row_elevation[hi] - row_elevation[lo]) * t;
let windage = row_windage[lo] + (row_windage[hi] - row_windage[lo]) * t;
worst.0 = worst.0.max((elevation - drop_mil).abs());
worst.1 = worst.1.max((windage - wind_mil).abs());
}
worst
}
fn smallest_uniform_card(
curve: &HoldCurve,
audited: &[(f64, f64, f64)],
start_m: f64,
end_m: f64,
budget: f64,
max_n: usize,
) -> Option<usize> {
for n in 2..=max_n {
let ranges: Vec<f64> = (0..n)
.map(|i| start_m + (end_m - start_m) * i as f64 / (n - 1) as f64)
.collect();
let points: Vec<_> = ranges
.iter()
.map(|&r| curve.at_range(r).expect("uniform row on the curve"))
.collect();
let elevation: Vec<f64> = points.iter().map(|p| p.drop_mil).collect();
let windage: Vec<f64> = points.iter().map(|p| p.wind_mil).collect();
let (worst_elevation, worst_windage) =
independent_worst_error(audited, &ranges, &elevation, &windage);
if worst_elevation <= budget && worst_windage <= budget {
return Some(n);
}
}
None
}
fn row_columns(report: &AdaptiveCardReportV1) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
(
report.rows.iter().map(|r| r.range).collect(),
report
.rows
.iter()
.map(|r| r.drop_adj.expect("adaptive rows always carry a dial value"))
.collect(),
report
.rows
.iter()
.map(|r| r.wind_adj.expect("adaptive rows always carry a dial value"))
.collect(),
)
}
#[test]
fn verification_pass_confirms_every_audited_point_within_bounds() {
let curve = test_curve(900.0);
let budget = 0.1;
let req = plain_request((200.0, 800.0), budget, 500);
let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
assert!(report.budget_met, "0.1 mil over 200-800 m should be reachable");
assert!(!report.rows_capped);
assert_eq!(report.schema_version, ADAPTIVE_CARD_SCHEMA_VERSION_V1);
assert_eq!(report.verification_grid_step_m, HoldCurve::SAMPLE_INTERVAL_M);
let (ranges, elevation, windage) = row_columns(&report);
let audited = independent_audited(&curve, 200.0, 800.0);
let (worst_elevation, worst_windage) =
independent_worst_error(&audited, &ranges, &elevation, &windage);
assert!(
worst_elevation <= budget,
"independent audit found {worst_elevation} mil of elevation error, budget {budget}"
);
assert!(
worst_windage <= budget,
"independent audit found {worst_windage} mil of windage error, budget {budget}"
);
assert!(report.worst_elevation_error >= worst_elevation - 1e-12);
assert!(report.worst_windage_error >= worst_windage - 1e-12);
}
#[test]
fn tightening_the_budget_never_decreases_row_count() {
let curve = test_curve(900.0);
let counts: Vec<usize> = [0.4, 0.2, 0.1, 0.05]
.iter()
.map(|&budget| {
let req = plain_request((200.0, 800.0), budget, 800);
let report =
adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
assert!(report.budget_met, "{budget} mil should be reachable unquantized");
report.rows.len()
})
.collect();
for pair in counts.windows(2) {
assert!(
pair[1] >= pair[0],
"row counts must not decrease as the budget tightens: {counts:?}"
);
}
assert!(
counts[3] > counts[0],
"a 8x tighter budget should cost rows: {counts:?}"
);
}
#[test]
fn adaptive_rows_concentrate_where_the_curve_bends() {
let curve = test_curve(900.0);
for budget in [0.1, 0.05, 0.02] {
let req = plain_request((200.0, 800.0), budget, 800);
let report =
adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
assert!(report.budget_met, "{budget} mil should be reachable");
assert!(report.rows.len() >= 4, "need enough rows to halve");
let gaps: Vec<f64> = report.rows.windows(2).map(|p| p[1].range - p[0].range).collect();
let half = gaps.len() / 2;
let near: f64 = gaps[..half].iter().sum::<f64>() / half as f64;
let far: f64 = gaps[gaps.len() - half..].iter().sum::<f64>() / half as f64;
assert!(
far < near,
"budget {budget}: far-half spacing {far:.1} m is not tighter than near-half \
{near:.1} m -- the card is not adapting, gaps {gaps:?}"
);
}
}
#[test]
fn fixed_step_comparison_is_measured_not_assumed() {
let curve = test_curve(900.0);
let (start_m, end_m, budget) = (200.0, 800.0, 0.1);
let req = plain_request((start_m, end_m), budget, 800);
let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
assert!(report.budget_met);
let adaptive_rows = report.rows.len();
let audited = independent_audited(&curve, start_m, end_m);
let uniform_rows = smallest_uniform_card(&curve, &audited, start_m, end_m, budget, 400)
.expect("some uniform step must meet the budget");
assert!(
adaptive_rows <= 2 * uniform_rows,
"adaptive used {adaptive_rows} rows against a {uniform_rows}-row uniform card; \
bisection granularity should never cost more than a doubling"
);
assert!(
adaptive_rows + 1 >= uniform_rows,
"adaptive ({adaptive_rows} rows) now beats uniform ({uniform_rows} rows) at the \
brief's own parameters -- the insertion rule has been improved, so the finding in \
this test's doc comment, the \"not a shorter card\" disclosure on `adaptive_card`, \
and the product guidance built on it are ALL stale and must be revisited"
);
}
#[test]
fn anchors_always_present_and_determinism() {
let curve = test_curve(900.0);
let anchors = vec![300.0, 512.5, 777.0];
let mut req = plain_request((200.0, 800.0), 0.15, 500);
req.anchors_m.clone_from(&anchors);
let first = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
let second = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
for anchor in &anchors {
assert!(
first.rows.iter().any(|row| row.range == *anchor),
"anchor {anchor} m is missing from the card"
);
}
assert_eq!(first.rows.first().map(|r| r.range), Some(200.0));
assert_eq!(first.rows.last().map(|r| r.range), Some(800.0));
assert_eq!(first.method, second.method);
assert_eq!(first.assumptions, second.assumptions);
assert_eq!(first.budget_met, second.budget_met);
assert_eq!(first.rows_capped, second.rows_capped);
assert_eq!(first.rows.len(), second.rows.len());
assert_eq!(
first.worst_elevation_error.to_bits(),
second.worst_elevation_error.to_bits()
);
assert_eq!(
first.worst_windage_error.to_bits(),
second.worst_windage_error.to_bits()
);
assert_eq!(
first.worst_error_range_m.to_bits(),
second.worst_error_range_m.to_bits()
);
for (a, b) in first.rows.iter().zip(second.rows.iter()) {
assert_eq!(a.range.to_bits(), b.range.to_bits());
assert_eq!(
a.drop_adj.map(f64::to_bits),
b.drop_adj.map(f64::to_bits)
);
assert_eq!(
a.wind_adj.map(f64::to_bits),
b.wind_adj.map(f64::to_bits)
);
}
}
#[test]
fn quantization_floor_is_honest_and_terminates() {
let curve = test_curve(900.0);
let step = HoldCurve::SAMPLE_INTERVAL_M;
let (start_m, end_m) = (330.0 * step, 350.0 * step);
let audited_points = 21usize;
let click = ClickValue {
size: 0.1,
base: ClickBase::Mil,
};
let half_click = 0.05;
let budget = 0.001;
let req = AdaptiveRequest {
domain_m: (start_m, end_m),
anchors_m: Vec::new(),
budget: AdaptiveBudget {
elevation: budget,
windage: budget,
},
max_rows: 500, click: Some((&click, &click)),
elevation_cf: 1.0,
windage_cf: 1.0,
bias_mil: 0.0,
};
let (report, trace) = adaptive_card_traced(&curve, &req, CardAdjustmentUnit::Mil)
.expect("card should build");
assert!(
trace.iterations <= audited_points + 1,
"search took {} iterations for {audited_points} audited points (cap {}) -- \
the irreducible-error stop is not firing",
trace.iterations,
trace.iteration_cap
);
assert!(
trace.iterations < trace.iteration_cap,
"the runaway backstop, not the irreducible-error stop, ended the search"
);
assert!(!report.budget_met, "0.001 mil is under the 0.05 mil floor");
assert!(
!report.rows_capped,
"the row cap was not binding; the stop must be attributed to the floor"
);
assert!(report.rows.len() <= audited_points);
let mut row_worst = (0.0_f64, 0.0_f64);
for row in &report.rows {
let point = curve.at_range(row.range).expect("row on the curve");
row_worst.0 = row_worst
.0
.max((row.drop_adj.expect("dial") - point.drop_mil).abs());
row_worst.1 = row_worst
.1
.max((row.wind_adj.expect("dial") - point.wind_mil).abs());
}
assert!(
(report.worst_elevation_error - row_worst.0).abs() < 1e-12,
"worst elevation {} vs independently measured row residue {}",
report.worst_elevation_error,
row_worst.0
);
assert!(report.worst_elevation_error > budget);
assert!(
report.worst_elevation_error <= half_click + 1e-12,
"residue {} exceeded the half-click floor",
report.worst_elevation_error
);
assert!(report.worst_windage_error <= half_click + 1e-12);
}
#[test]
fn max_rows_caps_with_capped_flag() {
let curve = test_curve(900.0);
let req = plain_request((200.0, 800.0), 0.001, 5);
let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
assert!(report.rows_capped);
assert!(!report.budget_met);
assert_eq!(report.rows.len(), 5);
assert!(report.worst_elevation_error > 0.001);
assert!(report.worst_error_range_m >= 200.0 && report.worst_error_range_m <= 800.0);
}
#[test]
fn report_carries_method_and_all_five_assumptions() {
let curve = test_curve(900.0);
let req = plain_request((200.0, 400.0), 0.2, 50);
let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
assert_eq!(
report.method,
"greedy_worst_point_insertion_on_holdcurve_grid_v1"
);
assert_eq!(report.assumptions.len(), 5);
assert_eq!(
report.assumptions[0],
"Verification is limited to the hold curve's declared sample grid (verification_grid_step_m) together with the card's own rows; no claim is made about ranges between those audited points."
);
assert_eq!(
report.assumptions[1],
"The reader of this card interpolates linearly between adjacent rows."
);
assert_eq!(
report.assumptions[2],
"Errors are measured in printed-value space -- the same zero-set bias, tracking-correction division and click quantization the printed rows carry -- so a constant zero-set bias cancels out of the interpolation error and the tracking correction factor is already inside the numbers being compared."
);
assert_eq!(
report.assumptions[3],
"Rows quantized to an optic's clicks carry an irreducible error of up to half a click at the rows themselves, which no number of added rows can remove."
);
assert_eq!(
report.assumptions[4],
"A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed."
);
}
#[test]
fn verification_grid_lands_on_the_curves_native_samples() {
let curve = test_curve(900.0);
let step = HoldCurve::SAMPLE_INTERVAL_M;
let max_m = curve.max_sampled_range_m();
let index = (max_m / step).round();
assert_eq!((index * step).to_bits(), max_m.to_bits());
assert!(curve.at_range(max_m).is_some());
assert!(curve.at_range(max_m + step).is_none());
let drop_m_at = |range_m: f64| {
let p = curve.at_range(range_m).expect("probe on the curve");
p.drop_mil / 1000.0 * range_m
};
let node = native_grid_m(step, max_m)[799];
let delta = step / 4.0;
let bend_at = |centre: f64| {
let mid = drop_m_at(centre);
let avg = 0.5 * (drop_m_at(centre - delta) + drop_m_at(centre + delta));
(mid - avg).abs()
};
let at_node = bend_at(node);
let inside_interval = bend_at(node + step / 2.0);
assert!(
inside_interval < 1e-12,
"probes inside one claimed sample interval were not collinear ({inside_interval} m) \
-- the reconstructed grid is offset from the curve's real nodes"
);
assert!(
at_node > 1e-9 && at_node > 100.0 * inside_interval.max(f64::MIN_POSITIVE),
"no interpolation kink at the claimed node ({at_node} m) -- \
the reconstructed grid is offset from the curve's real nodes"
);
let grid = native_grid_m(300.0, 300.0 + 10.0 * step);
assert!(grid.len() >= 10);
for pair in grid.windows(2) {
assert!((pair[1] - pair[0] - step).abs() < 1e-12);
}
}
#[test]
fn printed_pipeline_keeps_the_locked_bias_then_cf_then_quantize_order() {
let curve = test_curve(900.0);
let click = ClickValue {
size: 0.1,
base: ClickBase::Mil,
};
let (bias_mil, cf) = (2.0, 0.9);
let req = AdaptiveRequest {
domain_m: (300.0, 600.0),
anchors_m: Vec::new(),
budget: AdaptiveBudget {
elevation: 0.2,
windage: 0.2,
},
max_rows: 200,
click: Some((&click, &click)),
elevation_cf: cf,
windage_cf: 1.0,
bias_mil,
};
let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
let row = &report.rows[1];
let true_mil = curve.at_range(row.range).expect("row on the curve").drop_mil;
let right_order = ((true_mil + bias_mil) / cf / 0.1).round() * 0.1;
let wrong_order = (((true_mil / cf) + bias_mil) / 0.1).round() * 0.1;
let dialed = row.drop_adj.expect("dial");
assert!(
(dialed - right_order).abs() < 1e-12,
"printed {dialed} is not (true + bias) / cf quantized ({right_order})"
);
assert!(
(right_order - wrong_order).abs() > 1e-9,
"the chosen bias/CF make both orders agree; this test would not catch a swap"
);
let true_wind = curve.at_range(row.range).expect("row on the curve").wind_mil;
let expected_wind = (true_wind / 0.1).round() * 0.1;
assert!((row.wind_adj.expect("dial") - expected_wind).abs() < 1e-12);
}
#[test]
fn moa_cards_use_the_locked_3438_ratio() {
assert_eq!(CardAdjustmentUnit::Mil.from_mil_factor(), 1.0);
assert_eq!(CardAdjustmentUnit::Moa.from_mil_factor(), 3438.0 / 1000.0);
assert_ne!(
CardAdjustmentUnit::Moa.from_mil_factor(),
3437.7467 / 1000.0
);
let curve = test_curve(900.0);
let req = plain_request((300.0, 600.0), 0.5, 200);
let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Moa).expect("card should build");
let row = &report.rows[0];
let true_mil = curve.at_range(row.range).expect("row on the curve").drop_mil;
assert_eq!(
row.drop_adj.expect("dial").to_bits(),
(true_mil * (3438.0 / 1000.0)).to_bits()
);
}
#[test]
fn request_validation_reports_every_structured_error() {
let curve = test_curve(900.0);
let curve_max_m = curve.max_sampled_range_m();
let mut req = plain_request((200.0, 800.0), 0.1, 0);
assert_eq!(
adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
CardError::ZeroMaxRows
);
req = plain_request((200.0, 800.0), 0.1, 50);
req.budget.elevation = 0.0;
assert_eq!(
adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
CardError::NonPositiveBudget {
axis: "elevation",
value: 0.0
}
);
req = plain_request((200.0, 800.0), 0.1, 50);
req.budget.windage = f64::NAN;
assert!(matches!(
adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
Err(CardError::NonPositiveBudget { axis: "windage", .. })
));
for domain in [(800.0, 200.0), (0.0, 500.0), (-10.0, 500.0), (300.0, 300.0)] {
let req = plain_request(domain, 0.1, 50);
assert!(
matches!(
adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
Err(CardError::EmptyOrInvertedDomain { .. })
),
"domain {domain:?} must be rejected"
);
}
let req = plain_request((200.0, curve_max_m + 1.0), 0.1, 50);
assert_eq!(
adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
CardError::DomainOutsideCurve {
requested_m: curve_max_m + 1.0,
curve_max_m
}
);
let mut req = plain_request((200.0, 800.0), 0.1, 50);
req.anchors_m = vec![900.0];
assert_eq!(
adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
CardError::AnchorOutsideDomain {
anchor_m: 900.0,
start_m: 200.0,
end_m: 800.0
}
);
assert!(CardError::ZeroMaxRows.to_string().contains("at least one row"));
}
#[test]
fn out_of_band_elevation_tracking_cf_is_rejected() {
let curve = test_curve(900.0);
for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
let mut req = plain_request((200.0, 800.0), 0.1, 50);
req.elevation_cf = bad;
let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
.expect_err("an out-of-band elevation CF must be refused, not answered");
match err {
CardError::InvalidTrackingCf { axis, value } => {
assert_eq!(axis, "elevation");
assert_eq!(value.to_bits(), bad.to_bits(), "payload must echo the input");
}
other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
}
}
let mut req = plain_request((200.0, 800.0), 0.1, 50);
req.elevation_cf = 0.95;
assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
}
#[test]
fn out_of_band_windage_tracking_cf_is_rejected() {
let curve = test_curve(900.0);
for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
let mut req = plain_request((200.0, 800.0), 0.1, 50);
req.windage_cf = bad;
let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
.expect_err("an out-of-band windage CF must be refused, not answered");
match err {
CardError::InvalidTrackingCf { axis, value } => {
assert_eq!(axis, "windage");
assert_eq!(value.to_bits(), bad.to_bits(), "payload must echo the input");
}
other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
}
}
let mut req = plain_request((200.0, 800.0), 0.1, 50);
req.windage_cf = 1.05;
assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
let mut req = plain_request((200.0, 800.0), 0.1, 50);
req.elevation_cf = 95.0;
req.windage_cf = 95.0;
assert_eq!(
adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
CardError::InvalidTrackingCf {
axis: "elevation",
value: 95.0
}
);
let text = CardError::InvalidTrackingCf {
axis: "elevation",
value: 95.0,
}
.to_string();
assert!(text.contains("elevation") && text.contains("0.5") && text.contains("1.5"), "{text}");
}
#[test]
fn report_serializes_verbatim_with_stable_field_names() {
let curve = test_curve(900.0);
let req = plain_request((200.0, 400.0), 0.2, 50);
let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
let json = serde_json::to_value(&report).expect("report must serialize");
assert_eq!(json["schema_version"], ADAPTIVE_CARD_SCHEMA_VERSION_V1);
assert_eq!(json["method"], "greedy_worst_point_insertion_on_holdcurve_grid_v1");
assert_eq!(json["assumptions"].as_array().expect("assumptions array").len(), 5);
assert_eq!(json["budget_met"], report.budget_met);
assert_eq!(json["rows_capped"], report.rows_capped);
assert!(json.get("worst_elevation_error").is_some());
assert!(json.get("worst_windage_error").is_some());
assert!(json.get("worst_error_range_m").is_some());
assert!(json.get("verification_grid_step_m").is_some());
let rows = json["rows"].as_array().expect("rows array");
assert_eq!(rows.len(), report.rows.len());
let first = &rows[0];
assert!(first["range"].is_number());
assert!(first["drop_adj"].is_number(), "a populated Some(..) field must serialize as a number");
assert!(first["come_up"].is_null());
assert!(first["lead_adj"].is_null());
assert_eq!(first["wind_columns"], serde_json::json!([]));
}
#[test]
fn card_row_field_names_bind_to_their_sentinel_values_in_json() {
let row = CardRow {
range: 111.1,
drop_linear: Some(222.2),
drop_adj: Some(333.3),
come_up: Some(444.4),
wind_linear: Some(555.5),
wind_adj: Some(666.6),
velocity: Some(777.7),
energy: Some(888.8),
time: Some(9.99),
lead_adj: Some(101.1),
wind_columns: vec![1.0, 2.0, 3.0],
};
let json = serde_json::to_value(&row).expect("row must serialize");
assert_eq!(json["range"], 111.1);
assert_eq!(json["drop_linear"], 222.2);
assert_eq!(json["drop_adj"], 333.3);
assert_eq!(json["come_up"], 444.4);
assert_eq!(json["wind_linear"], 555.5);
assert_eq!(json["wind_adj"], 666.6);
assert_eq!(json["velocity"], 777.7);
assert_eq!(json["energy"], 888.8);
assert_eq!(json["time"], 9.99);
assert_eq!(json["lead_adj"], 101.1);
assert_eq!(json["wind_columns"], serde_json::json!([1.0, 2.0, 3.0]));
assert_eq!(
json.as_object().expect("row must serialize to a JSON object").len(),
11
);
}
}