1#[derive(Debug, Clone, Serialize)]
28pub struct CardRow {
29 pub range: f64,
30 pub drop_linear: Option<f64>,
31 pub drop_adj: Option<f64>,
32 pub come_up: Option<f64>,
33 pub wind_linear: Option<f64>,
34 pub wind_adj: Option<f64>,
35 pub velocity: Option<f64>,
36 pub energy: Option<f64>,
37 pub time: Option<f64>,
38 pub lead_adj: Option<f64>,
39 pub wind_columns: Vec<f64>,
41}
42
43use crate::adjustment::{click_size_mil, quantize_angle, ClickBase, ClickValue};
52use crate::hold_curve::HoldCurve;
53use serde::Serialize;
54use std::fmt;
55
56pub const ADAPTIVE_CARD_SCHEMA_VERSION_V1: u32 = 1;
58
59#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct AdaptiveBudget {
64 pub elevation: f64,
65 pub windage: f64,
66}
67
68#[derive(Debug, Clone)]
83pub struct AdaptiveRequest<'a> {
84 pub domain_m: (f64, f64),
86 pub anchors_m: Vec<f64>,
89 pub budget: AdaptiveBudget,
90 pub max_rows: usize,
93 pub click: Option<(&'a ClickValue, &'a ClickValue)>,
97 pub elevation_cf: f64,
98 pub windage_cf: f64,
99 pub bias_mil: f64,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum CardAdjustmentUnit {
105 Mil,
106 Moa,
107}
108
109impl CardAdjustmentUnit {
110 pub fn from_mil_factor(&self) -> f64 {
117 match self {
118 Self::Mil => 1.0,
119 Self::Moa => 3438.0 / 1000.0,
120 }
121 }
122
123 fn click_base(&self) -> ClickBase {
126 match self {
127 Self::Mil => ClickBase::Mil,
128 Self::Moa => ClickBase::Moa,
129 }
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq)]
136pub enum CardError {
137 EmptyOrInvertedDomain { start_m: f64, end_m: f64 },
140 AnchorOutsideDomain {
141 anchor_m: f64,
142 start_m: f64,
143 end_m: f64,
144 },
145 NonPositiveBudget { axis: &'static str, value: f64 },
146 ZeroMaxRows,
147 DomainOutsideCurve { requested_m: f64, curve_max_m: f64 },
150 InvalidTrackingCf { axis: &'static str, value: f64 },
161}
162
163impl fmt::Display for CardError {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 match self {
166 Self::EmptyOrInvertedDomain { start_m, end_m } => write!(
167 f,
168 "card domain {start_m} m to {end_m} m must be a forward interval with a positive start"
169 ),
170 Self::AnchorOutsideDomain {
171 anchor_m,
172 start_m,
173 end_m,
174 } => write!(
175 f,
176 "anchor {anchor_m} m lies outside the card domain {start_m} m to {end_m} m"
177 ),
178 Self::NonPositiveBudget { axis, value } => {
179 write!(f, "{axis} budget {value} must be positive and finite")
180 }
181 Self::ZeroMaxRows => write!(f, "a card needs room for at least one row"),
182 Self::DomainOutsideCurve {
183 requested_m,
184 curve_max_m,
185 } => write!(
186 f,
187 "range {requested_m} m is past the curve's last sampled point at {curve_max_m} m"
188 ),
189 Self::InvalidTrackingCf { axis, value } => write!(
190 f,
191 "{axis} tracking correction factor {value} must be finite and between 0.5 and 1.5 \
192 (it is a ratio such as 0.95, not a percentage)"
193 ),
194 }
195 }
196}
197
198impl std::error::Error for CardError {}
199
200#[derive(Debug, Clone, Serialize)]
210pub struct AdaptiveCardReportV1 {
211 pub schema_version: u32,
212 pub method: String,
214 pub assumptions: Vec<String>,
217 pub rows: Vec<CardRow>,
220 pub budget_met: bool,
221 pub rows_capped: bool,
223 pub worst_elevation_error: f64,
225 pub worst_windage_error: f64,
227 pub worst_error_range_m: f64,
231 pub verification_grid_step_m: f64,
234}
235
236#[derive(Debug, Clone, Copy)]
241struct PrintedAxis {
242 unit_factor: f64,
243 bias_mil: f64,
244 cf: f64,
245 click: Option<ClickValue>,
248}
249
250impl PrintedAxis {
251 fn new(unit: CardAdjustmentUnit, bias_mil: f64, cf: f64, click: Option<&ClickValue>) -> Self {
252 let unit_factor = unit.from_mil_factor();
253 Self {
254 unit_factor,
255 bias_mil,
256 cf,
257 click: click.map(|c| ClickValue {
261 size: click_size_mil(c) * unit_factor,
262 base: unit.click_base(),
263 }),
264 }
265 }
266
267 fn exact(&self, true_mil: f64) -> f64 {
270 let printed = true_mil * self.unit_factor;
271 let biased = if self.bias_mil != 0.0 {
274 printed + self.bias_mil * self.unit_factor
275 } else {
276 printed
277 };
278 biased / self.cf
279 }
280
281 fn printed(&self, true_mil: f64) -> f64 {
284 let exact = self.exact(true_mil);
285 match &self.click {
286 Some(c) => quantize_angle(exact, c).clicks as f64 * c.size,
287 None => exact,
288 }
289 }
290}
291
292#[derive(Debug, Clone, Copy)]
297struct AuditPoint {
298 range_m: f64,
299 exact_elevation: f64,
300 exact_windage: f64,
301 printed_elevation: f64,
302 printed_windage: f64,
303 drop_linear_m: f64,
304 wind_linear_m: f64,
305 velocity_mps: f64,
306 energy_j: f64,
307 time_s: f64,
308}
309
310type AxisErrors = (f64, f64);
312
313fn sorted_dedup(mut values: Vec<f64>) -> Vec<f64> {
316 values.sort_by(f64::total_cmp);
317 values.dedup();
318 values
319}
320
321fn native_grid_m(start_m: f64, end_m: f64) -> Vec<f64> {
328 let step = HoldCurve::SAMPLE_INTERVAL_M;
329 let first = ((start_m / step).ceil() as i64).max(1);
331 let last = (end_m / step).floor() as i64;
332 let mut grid = Vec::new();
333 for i in first..=last {
334 let g = i as f64 * step;
335 if g >= start_m && g <= end_m {
337 grid.push(g);
338 }
339 }
340 grid
341}
342
343fn sweep(audit: &[AuditPoint], rows: &[usize]) -> Vec<AxisErrors> {
349 let mut errors = vec![(0.0, 0.0); audit.len()];
350 for &r in rows {
351 let p = &audit[r];
352 errors[r] = (
353 (p.printed_elevation - p.exact_elevation).abs(),
354 (p.printed_windage - p.exact_windage).abs(),
355 );
356 }
357 for pair in rows.windows(2) {
358 let (lo, hi) = (pair[0], pair[1]);
359 let (a, b) = (&audit[lo], &audit[hi]);
360 let span = b.range_m - a.range_m;
361 for (k, point) in audit.iter().enumerate().take(hi).skip(lo + 1) {
362 let t = if span > 0.0 {
363 (point.range_m - a.range_m) / span
364 } else {
365 0.0
366 };
367 let elevation = a.printed_elevation + (b.printed_elevation - a.printed_elevation) * t;
368 let windage = a.printed_windage + (b.printed_windage - a.printed_windage) * t;
369 errors[k] = (
370 (elevation - point.exact_elevation).abs(),
371 (windage - point.exact_windage).abs(),
372 );
373 }
374 }
375 errors
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383struct LoopTrace {
384 iterations: usize,
385 iteration_cap: usize,
386}
387
388pub fn adaptive_card(
416 curve: &HoldCurve,
417 req: &AdaptiveRequest,
418 unit: CardAdjustmentUnit,
419) -> Result<AdaptiveCardReportV1, CardError> {
420 adaptive_card_traced(curve, req, unit).map(|(report, _)| report)
421}
422
423fn adaptive_card_traced(
425 curve: &HoldCurve,
426 req: &AdaptiveRequest,
427 unit: CardAdjustmentUnit,
428) -> Result<(AdaptiveCardReportV1, LoopTrace), CardError> {
429 let (start_m, end_m) = req.domain_m;
430
431 if req.max_rows == 0 {
432 return Err(CardError::ZeroMaxRows);
433 }
434 for (axis, value) in [
435 ("elevation", req.budget.elevation),
436 ("windage", req.budget.windage),
437 ] {
438 if !value.is_finite() || value <= 0.0 {
439 return Err(CardError::NonPositiveBudget { axis, value });
440 }
441 }
442 for (axis, value) in [
447 ("elevation", req.elevation_cf),
448 ("windage", req.windage_cf),
449 ] {
450 if !crate::adjustment::tracking_cf_in_range(value) {
451 return Err(CardError::InvalidTrackingCf { axis, value });
452 }
453 }
454 if !start_m.is_finite() || !end_m.is_finite() || start_m <= 0.0 || end_m <= start_m {
455 return Err(CardError::EmptyOrInvertedDomain { start_m, end_m });
456 }
457 let curve_max_m = curve.max_sampled_range_m();
458 if end_m > curve_max_m {
459 return Err(CardError::DomainOutsideCurve {
460 requested_m: end_m,
461 curve_max_m,
462 });
463 }
464 for &anchor_m in &req.anchors_m {
465 if !anchor_m.is_finite() || anchor_m < start_m || anchor_m > end_m {
466 return Err(CardError::AnchorOutsideDomain {
467 anchor_m,
468 start_m,
469 end_m,
470 });
471 }
472 }
473 debug_assert!(req.bias_mil.is_finite(), "zero-set bias must be finite");
474
475 let (elevation_click, windage_click) = match req.click {
476 Some((e, w)) => (Some(e), Some(w)),
477 None => (None, None),
478 };
479 let elevation = PrintedAxis::new(unit, req.bias_mil, req.elevation_cf, elevation_click);
480 let windage = PrintedAxis::new(unit, 0.0, req.windage_cf, windage_click);
483
484 let mut seeds = vec![start_m, end_m];
490 seeds.extend_from_slice(&req.anchors_m);
491 let mut ranges = native_grid_m(start_m, end_m);
492 ranges.extend_from_slice(&seeds);
493 let ranges = sorted_dedup(ranges);
494
495 let mut audit = Vec::with_capacity(ranges.len());
496 for range_m in ranges {
497 let point = curve
498 .at_range(range_m)
499 .ok_or(CardError::DomainOutsideCurve {
500 requested_m: range_m,
501 curve_max_m,
502 })?;
503 audit.push(AuditPoint {
504 range_m,
505 exact_elevation: elevation.exact(point.drop_mil),
506 exact_windage: windage.exact(point.wind_mil),
507 printed_elevation: elevation.printed(point.drop_mil),
508 printed_windage: windage.printed(point.wind_mil),
509 drop_linear_m: point.drop_mil / 1000.0 * range_m,
510 wind_linear_m: point.wind_mil / 1000.0 * range_m,
511 velocity_mps: point.velocity_mps,
512 energy_j: point.energy_j,
513 time_s: point.time_s,
514 });
515 }
516
517 let mut rows: Vec<usize> = sorted_dedup(seeds)
520 .iter()
521 .map(|target| {
522 audit
523 .partition_point(|p| p.range_m < *target)
524 .min(audit.len() - 1)
525 })
526 .collect();
527 rows.dedup();
528 let mut is_row = vec![false; audit.len()];
529 for &r in &rows {
530 is_row[r] = true;
531 }
532 debug_assert_eq!(rows.first(), Some(&0), "the domain start must seed row 0");
533 debug_assert_eq!(
534 rows.last(),
535 Some(&(audit.len() - 1)),
536 "the domain end must seed the last row"
537 );
538
539 let iteration_cap = 2 * audit.len() + 8;
544 let mut iterations = 0usize;
545 let mut rows_capped = false;
546
547 while iterations < iteration_cap {
548 iterations += 1;
549 let errors = sweep(&audit, &rows);
550
551 let mut any_violation = false;
552 let mut worst: Option<(f64, usize)> = None;
553 for (k, &(elevation_error, windage_error)) in errors.iter().enumerate() {
554 if elevation_error <= req.budget.elevation && windage_error <= req.budget.windage {
555 continue;
556 }
557 any_violation = true;
558 if is_row[k] {
559 continue;
560 }
561 let excess =
565 (elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
566 if worst.is_none_or(|(best, _)| excess > best) {
567 worst = Some((excess, k));
568 }
569 }
570
571 if !any_violation {
572 break;
573 }
574 let Some((_, insert_at)) = worst else {
581 break;
582 };
583 if rows.len() >= req.max_rows {
584 rows_capped = true;
585 break;
586 }
587 rows.insert(rows.partition_point(|&r| r < insert_at), insert_at);
588 is_row[insert_at] = true;
589 }
590
591 let final_errors = sweep(&audit, &rows);
596 let mut worst_elevation_error = 0.0_f64;
597 let mut worst_windage_error = 0.0_f64;
598 let mut worst_excess = f64::NEG_INFINITY;
599 let mut worst_error_range_m = audit[0].range_m;
600 for (k, &(elevation_error, windage_error)) in final_errors.iter().enumerate() {
601 worst_elevation_error = worst_elevation_error.max(elevation_error);
602 worst_windage_error = worst_windage_error.max(windage_error);
603 let excess =
604 (elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
605 if excess > worst_excess {
606 worst_excess = excess;
607 worst_error_range_m = audit[k].range_m;
608 }
609 }
610 let budget_met =
611 worst_elevation_error <= req.budget.elevation && worst_windage_error <= req.budget.windage;
612
613 let card_rows = rows
625 .iter()
626 .map(|&r| {
627 let p = &audit[r];
628 CardRow {
629 range: p.range_m,
630 drop_linear: Some(p.drop_linear_m),
631 drop_adj: Some(p.printed_elevation),
632 come_up: None,
633 wind_linear: Some(p.wind_linear_m),
634 wind_adj: Some(p.printed_windage),
635 velocity: Some(p.velocity_mps),
636 energy: Some(p.energy_j),
637 time: Some(p.time_s),
638 lead_adj: None,
639 wind_columns: Vec::new(),
640 }
641 })
642 .collect();
643
644 let report = AdaptiveCardReportV1 {
645 schema_version: ADAPTIVE_CARD_SCHEMA_VERSION_V1,
646 method: "greedy_worst_point_insertion_on_holdcurve_grid_v1".to_string(),
647 assumptions: adaptive_card_assumptions(),
648 rows: card_rows,
649 budget_met,
650 rows_capped,
651 worst_elevation_error,
652 worst_windage_error,
653 worst_error_range_m,
654 verification_grid_step_m: HoldCurve::SAMPLE_INTERVAL_M,
655 };
656 Ok((
657 report,
658 LoopTrace {
659 iterations,
660 iteration_cap,
661 },
662 ))
663}
664
665fn adaptive_card_assumptions() -> Vec<String> {
668 [
669 "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.",
670 "The reader of this card interpolates linearly between adjacent rows.",
671 "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.",
672 "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.",
673 "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed.",
674 ]
675 .iter()
676 .map(|s| (*s).to_string())
677 .collect()
678}
679
680#[cfg(test)]
681mod adaptive_card_tests {
682 use super::*;
683 use crate::hold_curve::HoldCurveLoad;
684 use crate::DragModel;
685
686 fn test_load() -> HoldCurveLoad {
689 HoldCurveLoad {
690 velocity_mps: 800.0,
691 bc: 0.223,
692 mass_kg: 0.0109,
693 diameter_m: 0.00782,
694 drag_model: DragModel::G7,
695 sight_height_m: 0.045,
696 zero_distance_m: 100.0,
697 temperature_c: 15.0,
698 pressure_hpa: 1013.25,
699 humidity: 50.0,
700 altitude_m: 0.0,
701 wind_speed_mps: 3.0,
702 wind_direction_deg: 90.0,
703 }
704 }
705
706 fn test_curve(max_range_m: f64) -> HoldCurve {
707 HoldCurve::solve(&test_load(), max_range_m).expect("hold curve should solve")
708 }
709
710 fn plain_request(domain_m: (f64, f64), budget: f64, max_rows: usize) -> AdaptiveRequest<'static> {
714 AdaptiveRequest {
715 domain_m,
716 anchors_m: Vec::new(),
717 budget: AdaptiveBudget {
718 elevation: budget,
719 windage: budget,
720 },
721 max_rows,
722 click: None,
723 elevation_cf: 1.0,
724 windage_cf: 1.0,
725 bias_mil: 0.0,
726 }
727 }
728
729 fn independent_audited(curve: &HoldCurve, start_m: f64, end_m: f64) -> Vec<(f64, f64, f64)> {
734 let step = HoldCurve::SAMPLE_INTERVAL_M;
735 let mut ranges = vec![start_m];
736 let mut i = 1_i64;
737 loop {
738 let g = i as f64 * step;
739 if g > end_m {
740 break;
741 }
742 if g > start_m {
743 ranges.push(g);
744 }
745 i += 1;
746 }
747 if ranges.last().is_none_or(|&last| last < end_m) {
748 ranges.push(end_m);
749 }
750 ranges
751 .into_iter()
752 .map(|g| {
753 let p = curve.at_range(g).expect("audited range must be on the curve");
754 (g, p.drop_mil, p.wind_mil)
755 })
756 .collect()
757 }
758
759 fn independent_worst_error(
763 audited: &[(f64, f64, f64)],
764 row_ranges: &[f64],
765 row_elevation: &[f64],
766 row_windage: &[f64],
767 ) -> (f64, f64) {
768 let mut worst = (0.0_f64, 0.0_f64);
769 for &(g, drop_mil, wind_mil) in audited {
770 let lo = row_ranges
772 .partition_point(|&r| r <= g)
773 .saturating_sub(1)
774 .min(row_ranges.len() - 2);
775 let hi = lo + 1;
776 let span = row_ranges[hi] - row_ranges[lo];
777 let t = if span > 0.0 {
778 (g - row_ranges[lo]) / span
779 } else {
780 0.0
781 };
782 let elevation = row_elevation[lo] + (row_elevation[hi] - row_elevation[lo]) * t;
783 let windage = row_windage[lo] + (row_windage[hi] - row_windage[lo]) * t;
784 worst.0 = worst.0.max((elevation - drop_mil).abs());
785 worst.1 = worst.1.max((windage - wind_mil).abs());
786 }
787 worst
788 }
789
790 fn smallest_uniform_card(
793 curve: &HoldCurve,
794 audited: &[(f64, f64, f64)],
795 start_m: f64,
796 end_m: f64,
797 budget: f64,
798 max_n: usize,
799 ) -> Option<usize> {
800 for n in 2..=max_n {
801 let ranges: Vec<f64> = (0..n)
802 .map(|i| start_m + (end_m - start_m) * i as f64 / (n - 1) as f64)
803 .collect();
804 let points: Vec<_> = ranges
805 .iter()
806 .map(|&r| curve.at_range(r).expect("uniform row on the curve"))
807 .collect();
808 let elevation: Vec<f64> = points.iter().map(|p| p.drop_mil).collect();
809 let windage: Vec<f64> = points.iter().map(|p| p.wind_mil).collect();
810 let (worst_elevation, worst_windage) =
811 independent_worst_error(audited, &ranges, &elevation, &windage);
812 if worst_elevation <= budget && worst_windage <= budget {
813 return Some(n);
814 }
815 }
816 None
817 }
818
819 fn row_columns(report: &AdaptiveCardReportV1) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
820 (
821 report.rows.iter().map(|r| r.range).collect(),
822 report
823 .rows
824 .iter()
825 .map(|r| r.drop_adj.expect("adaptive rows always carry a dial value"))
826 .collect(),
827 report
828 .rows
829 .iter()
830 .map(|r| r.wind_adj.expect("adaptive rows always carry a dial value"))
831 .collect(),
832 )
833 }
834
835 #[test]
839 fn verification_pass_confirms_every_audited_point_within_bounds() {
840 let curve = test_curve(900.0);
841 let budget = 0.1;
842 let req = plain_request((200.0, 800.0), budget, 500);
843 let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
844
845 assert!(report.budget_met, "0.1 mil over 200-800 m should be reachable");
846 assert!(!report.rows_capped);
847 assert_eq!(report.schema_version, ADAPTIVE_CARD_SCHEMA_VERSION_V1);
848 assert_eq!(report.verification_grid_step_m, HoldCurve::SAMPLE_INTERVAL_M);
849
850 let (ranges, elevation, windage) = row_columns(&report);
851 let audited = independent_audited(&curve, 200.0, 800.0);
852 let (worst_elevation, worst_windage) =
853 independent_worst_error(&audited, &ranges, &elevation, &windage);
854
855 assert!(
856 worst_elevation <= budget,
857 "independent audit found {worst_elevation} mil of elevation error, budget {budget}"
858 );
859 assert!(
860 worst_windage <= budget,
861 "independent audit found {worst_windage} mil of windage error, budget {budget}"
862 );
863 assert!(report.worst_elevation_error >= worst_elevation - 1e-12);
865 assert!(report.worst_windage_error >= worst_windage - 1e-12);
866 }
867
868 #[test]
871 fn tightening_the_budget_never_decreases_row_count() {
872 let curve = test_curve(900.0);
873 let counts: Vec<usize> = [0.4, 0.2, 0.1, 0.05]
874 .iter()
875 .map(|&budget| {
876 let req = plain_request((200.0, 800.0), budget, 800);
877 let report =
878 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
879 assert!(report.budget_met, "{budget} mil should be reachable unquantized");
880 report.rows.len()
881 })
882 .collect();
883
884 for pair in counts.windows(2) {
885 assert!(
886 pair[1] >= pair[0],
887 "row counts must not decrease as the budget tightens: {counts:?}"
888 );
889 }
890 assert!(
891 counts[3] > counts[0],
892 "a 8x tighter budget should cost rows: {counts:?}"
893 );
894 }
895
896 #[test]
900 fn adaptive_rows_concentrate_where_the_curve_bends() {
901 let curve = test_curve(900.0);
902 for budget in [0.1, 0.05, 0.02] {
903 let req = plain_request((200.0, 800.0), budget, 800);
904 let report =
905 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
906 assert!(report.budget_met, "{budget} mil should be reachable");
907 assert!(report.rows.len() >= 4, "need enough rows to halve");
908
909 let gaps: Vec<f64> = report.rows.windows(2).map(|p| p[1].range - p[0].range).collect();
910 let half = gaps.len() / 2;
911 let near: f64 = gaps[..half].iter().sum::<f64>() / half as f64;
912 let far: f64 = gaps[gaps.len() - half..].iter().sum::<f64>() / half as f64;
913 assert!(
914 far < near,
915 "budget {budget}: far-half spacing {far:.1} m is not tighter than near-half \
916 {near:.1} m -- the card is not adapting, gaps {gaps:?}"
917 );
918 }
919 }
920
921 #[test]
948 fn fixed_step_comparison_is_measured_not_assumed() {
949 let curve = test_curve(900.0);
950 let (start_m, end_m, budget) = (200.0, 800.0, 0.1);
951
952 let req = plain_request((start_m, end_m), budget, 800);
953 let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
954 assert!(report.budget_met);
955 let adaptive_rows = report.rows.len();
956
957 let audited = independent_audited(&curve, start_m, end_m);
960 let uniform_rows = smallest_uniform_card(&curve, &audited, start_m, end_m, budget, 400)
961 .expect("some uniform step must meet the budget");
962
963 assert!(
964 adaptive_rows <= 2 * uniform_rows,
965 "adaptive used {adaptive_rows} rows against a {uniform_rows}-row uniform card; \
966 bisection granularity should never cost more than a doubling"
967 );
968 assert!(
969 adaptive_rows + 1 >= uniform_rows,
970 "adaptive ({adaptive_rows} rows) now beats uniform ({uniform_rows} rows) at the \
971 brief's own parameters -- the insertion rule has been improved, so the finding in \
972 this test's doc comment, the \"not a shorter card\" disclosure on `adaptive_card`, \
973 and the product guidance built on it are ALL stale and must be revisited"
974 );
975 }
976
977 #[test]
980 fn anchors_always_present_and_determinism() {
981 let curve = test_curve(900.0);
982 let anchors = vec![300.0, 512.5, 777.0];
983 let mut req = plain_request((200.0, 800.0), 0.15, 500);
984 req.anchors_m.clone_from(&anchors);
985
986 let first = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
987 let second = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
988
989 for anchor in &anchors {
990 assert!(
991 first.rows.iter().any(|row| row.range == *anchor),
992 "anchor {anchor} m is missing from the card"
993 );
994 }
995 assert_eq!(first.rows.first().map(|r| r.range), Some(200.0));
997 assert_eq!(first.rows.last().map(|r| r.range), Some(800.0));
998
999 assert_eq!(first.method, second.method);
1000 assert_eq!(first.assumptions, second.assumptions);
1001 assert_eq!(first.budget_met, second.budget_met);
1002 assert_eq!(first.rows_capped, second.rows_capped);
1003 assert_eq!(first.rows.len(), second.rows.len());
1004 assert_eq!(
1006 first.worst_elevation_error.to_bits(),
1007 second.worst_elevation_error.to_bits()
1008 );
1009 assert_eq!(
1010 first.worst_windage_error.to_bits(),
1011 second.worst_windage_error.to_bits()
1012 );
1013 assert_eq!(
1014 first.worst_error_range_m.to_bits(),
1015 second.worst_error_range_m.to_bits()
1016 );
1017 for (a, b) in first.rows.iter().zip(second.rows.iter()) {
1018 assert_eq!(a.range.to_bits(), b.range.to_bits());
1019 assert_eq!(
1020 a.drop_adj.map(f64::to_bits),
1021 b.drop_adj.map(f64::to_bits)
1022 );
1023 assert_eq!(
1024 a.wind_adj.map(f64::to_bits),
1025 b.wind_adj.map(f64::to_bits)
1026 );
1027 }
1028 }
1029
1030 #[test]
1035 fn quantization_floor_is_honest_and_terminates() {
1036 let curve = test_curve(900.0);
1037 let step = HoldCurve::SAMPLE_INTERVAL_M;
1038 let (start_m, end_m) = (330.0 * step, 350.0 * step);
1041 let audited_points = 21usize;
1042
1043 let click = ClickValue {
1044 size: 0.1,
1045 base: ClickBase::Mil,
1046 };
1047 let half_click = 0.05;
1048 let budget = 0.001;
1049 let req = AdaptiveRequest {
1050 domain_m: (start_m, end_m),
1051 anchors_m: Vec::new(),
1052 budget: AdaptiveBudget {
1053 elevation: budget,
1054 windage: budget,
1055 },
1056 max_rows: 500, click: Some((&click, &click)),
1058 elevation_cf: 1.0,
1059 windage_cf: 1.0,
1060 bias_mil: 0.0,
1061 };
1062
1063 let (report, trace) = adaptive_card_traced(&curve, &req, CardAdjustmentUnit::Mil)
1064 .expect("card should build");
1065
1066 assert!(
1070 trace.iterations <= audited_points + 1,
1071 "search took {} iterations for {audited_points} audited points (cap {}) -- \
1072 the irreducible-error stop is not firing",
1073 trace.iterations,
1074 trace.iteration_cap
1075 );
1076 assert!(
1077 trace.iterations < trace.iteration_cap,
1078 "the runaway backstop, not the irreducible-error stop, ended the search"
1079 );
1080
1081 assert!(!report.budget_met, "0.001 mil is under the 0.05 mil floor");
1082 assert!(
1083 !report.rows_capped,
1084 "the row cap was not binding; the stop must be attributed to the floor"
1085 );
1086 assert!(report.rows.len() <= audited_points);
1087
1088 let mut row_worst = (0.0_f64, 0.0_f64);
1091 for row in &report.rows {
1092 let point = curve.at_range(row.range).expect("row on the curve");
1093 row_worst.0 = row_worst
1094 .0
1095 .max((row.drop_adj.expect("dial") - point.drop_mil).abs());
1096 row_worst.1 = row_worst
1097 .1
1098 .max((row.wind_adj.expect("dial") - point.wind_mil).abs());
1099 }
1100 assert!(
1101 (report.worst_elevation_error - row_worst.0).abs() < 1e-12,
1102 "worst elevation {} vs independently measured row residue {}",
1103 report.worst_elevation_error,
1104 row_worst.0
1105 );
1106 assert!(report.worst_elevation_error > budget);
1107 assert!(
1108 report.worst_elevation_error <= half_click + 1e-12,
1109 "residue {} exceeded the half-click floor",
1110 report.worst_elevation_error
1111 );
1112 assert!(report.worst_windage_error <= half_click + 1e-12);
1113 }
1114
1115 #[test]
1117 fn max_rows_caps_with_capped_flag() {
1118 let curve = test_curve(900.0);
1119 let req = plain_request((200.0, 800.0), 0.001, 5);
1120 let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1121
1122 assert!(report.rows_capped);
1123 assert!(!report.budget_met);
1124 assert_eq!(report.rows.len(), 5);
1125 assert!(report.worst_elevation_error > 0.001);
1126 assert!(report.worst_error_range_m >= 200.0 && report.worst_error_range_m <= 800.0);
1127 }
1128
1129 #[test]
1133 fn report_carries_method_and_all_five_assumptions() {
1134 let curve = test_curve(900.0);
1135 let req = plain_request((200.0, 400.0), 0.2, 50);
1136 let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1137
1138 assert_eq!(
1139 report.method,
1140 "greedy_worst_point_insertion_on_holdcurve_grid_v1"
1141 );
1142 assert_eq!(report.assumptions.len(), 5);
1143 assert_eq!(
1144 report.assumptions[0],
1145 "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."
1146 );
1147 assert_eq!(
1148 report.assumptions[1],
1149 "The reader of this card interpolates linearly between adjacent rows."
1150 );
1151 assert_eq!(
1152 report.assumptions[2],
1153 "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."
1154 );
1155 assert_eq!(
1156 report.assumptions[3],
1157 "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."
1158 );
1159 assert_eq!(
1160 report.assumptions[4],
1161 "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed."
1162 );
1163 }
1164
1165 #[test]
1175 fn verification_grid_lands_on_the_curves_native_samples() {
1176 let curve = test_curve(900.0);
1177 let step = HoldCurve::SAMPLE_INTERVAL_M;
1178 let max_m = curve.max_sampled_range_m();
1179
1180 let index = (max_m / step).round();
1182 assert_eq!((index * step).to_bits(), max_m.to_bits());
1183 assert!(curve.at_range(max_m).is_some());
1184 assert!(curve.at_range(max_m + step).is_none());
1185
1186 let drop_m_at = |range_m: f64| {
1188 let p = curve.at_range(range_m).expect("probe on the curve");
1189 p.drop_mil / 1000.0 * range_m
1190 };
1191 let node = native_grid_m(step, max_m)[799];
1196 let delta = step / 4.0;
1197
1198 let bend_at = |centre: f64| {
1199 let mid = drop_m_at(centre);
1200 let avg = 0.5 * (drop_m_at(centre - delta) + drop_m_at(centre + delta));
1201 (mid - avg).abs()
1202 };
1203 let at_node = bend_at(node);
1204 let inside_interval = bend_at(node + step / 2.0);
1205
1206 assert!(
1207 inside_interval < 1e-12,
1208 "probes inside one claimed sample interval were not collinear ({inside_interval} m) \
1209 -- the reconstructed grid is offset from the curve's real nodes"
1210 );
1211 assert!(
1212 at_node > 1e-9 && at_node > 100.0 * inside_interval.max(f64::MIN_POSITIVE),
1213 "no interpolation kink at the claimed node ({at_node} m) -- \
1214 the reconstructed grid is offset from the curve's real nodes"
1215 );
1216
1217 let grid = native_grid_m(300.0, 300.0 + 10.0 * step);
1219 assert!(grid.len() >= 10);
1220 for pair in grid.windows(2) {
1221 assert!((pair[1] - pair[0] - step).abs() < 1e-12);
1222 }
1223 }
1224
1225 #[test]
1230 fn printed_pipeline_keeps_the_locked_bias_then_cf_then_quantize_order() {
1231 let curve = test_curve(900.0);
1232 let click = ClickValue {
1233 size: 0.1,
1234 base: ClickBase::Mil,
1235 };
1236 let (bias_mil, cf) = (2.0, 0.9);
1240 let req = AdaptiveRequest {
1241 domain_m: (300.0, 600.0),
1242 anchors_m: Vec::new(),
1243 budget: AdaptiveBudget {
1244 elevation: 0.2,
1245 windage: 0.2,
1246 },
1247 max_rows: 200,
1248 click: Some((&click, &click)),
1249 elevation_cf: cf,
1250 windage_cf: 1.0,
1251 bias_mil,
1252 };
1253 let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1254
1255 let row = &report.rows[1];
1256 let true_mil = curve.at_range(row.range).expect("row on the curve").drop_mil;
1257
1258 let right_order = ((true_mil + bias_mil) / cf / 0.1).round() * 0.1;
1259 let wrong_order = (((true_mil / cf) + bias_mil) / 0.1).round() * 0.1;
1260 let dialed = row.drop_adj.expect("dial");
1261
1262 assert!(
1263 (dialed - right_order).abs() < 1e-12,
1264 "printed {dialed} is not (true + bias) / cf quantized ({right_order})"
1265 );
1266 assert!(
1267 (right_order - wrong_order).abs() > 1e-9,
1268 "the chosen bias/CF make both orders agree; this test would not catch a swap"
1269 );
1270
1271 let true_wind = curve.at_range(row.range).expect("row on the curve").wind_mil;
1273 let expected_wind = (true_wind / 0.1).round() * 0.1;
1274 assert!((row.wind_adj.expect("dial") - expected_wind).abs() < 1e-12);
1275 }
1276
1277 #[test]
1280 fn moa_cards_use_the_locked_3438_ratio() {
1281 assert_eq!(CardAdjustmentUnit::Mil.from_mil_factor(), 1.0);
1282 assert_eq!(CardAdjustmentUnit::Moa.from_mil_factor(), 3438.0 / 1000.0);
1283 assert_ne!(
1284 CardAdjustmentUnit::Moa.from_mil_factor(),
1285 3437.7467 / 1000.0
1286 );
1287
1288 let curve = test_curve(900.0);
1289 let req = plain_request((300.0, 600.0), 0.5, 200);
1290 let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Moa).expect("card should build");
1291 let row = &report.rows[0];
1292 let true_mil = curve.at_range(row.range).expect("row on the curve").drop_mil;
1293 assert_eq!(
1294 row.drop_adj.expect("dial").to_bits(),
1295 (true_mil * (3438.0 / 1000.0)).to_bits()
1296 );
1297 }
1298
1299 #[test]
1302 fn request_validation_reports_every_structured_error() {
1303 let curve = test_curve(900.0);
1304 let curve_max_m = curve.max_sampled_range_m();
1305
1306 let mut req = plain_request((200.0, 800.0), 0.1, 0);
1307 assert_eq!(
1308 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1309 CardError::ZeroMaxRows
1310 );
1311
1312 req = plain_request((200.0, 800.0), 0.1, 50);
1313 req.budget.elevation = 0.0;
1314 assert_eq!(
1315 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1316 CardError::NonPositiveBudget {
1317 axis: "elevation",
1318 value: 0.0
1319 }
1320 );
1321 req = plain_request((200.0, 800.0), 0.1, 50);
1322 req.budget.windage = f64::NAN;
1323 assert!(matches!(
1324 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1325 Err(CardError::NonPositiveBudget { axis: "windage", .. })
1326 ));
1327
1328 for domain in [(800.0, 200.0), (0.0, 500.0), (-10.0, 500.0), (300.0, 300.0)] {
1329 let req = plain_request(domain, 0.1, 50);
1330 assert!(
1331 matches!(
1332 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1333 Err(CardError::EmptyOrInvertedDomain { .. })
1334 ),
1335 "domain {domain:?} must be rejected"
1336 );
1337 }
1338
1339 let req = plain_request((200.0, curve_max_m + 1.0), 0.1, 50);
1340 assert_eq!(
1341 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1342 CardError::DomainOutsideCurve {
1343 requested_m: curve_max_m + 1.0,
1344 curve_max_m
1345 }
1346 );
1347
1348 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1349 req.anchors_m = vec![900.0];
1350 assert_eq!(
1351 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1352 CardError::AnchorOutsideDomain {
1353 anchor_m: 900.0,
1354 start_m: 200.0,
1355 end_m: 800.0
1356 }
1357 );
1358
1359 assert!(CardError::ZeroMaxRows.to_string().contains("at least one row"));
1361 }
1362
1363 #[test]
1371 fn out_of_band_elevation_tracking_cf_is_rejected() {
1372 let curve = test_curve(900.0);
1373 for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1374 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1375 req.elevation_cf = bad;
1376 let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1377 .expect_err("an out-of-band elevation CF must be refused, not answered");
1378 match err {
1379 CardError::InvalidTrackingCf { axis, value } => {
1380 assert_eq!(axis, "elevation");
1381 assert_eq!(value.to_bits(), bad.to_bits(), "payload must echo the input");
1384 }
1385 other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1386 }
1387 }
1388 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1390 req.elevation_cf = 0.95;
1391 assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1392 }
1393
1394 #[test]
1397 fn out_of_band_windage_tracking_cf_is_rejected() {
1398 let curve = test_curve(900.0);
1399 for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1400 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1401 req.windage_cf = bad;
1402 let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1403 .expect_err("an out-of-band windage CF must be refused, not answered");
1404 match err {
1405 CardError::InvalidTrackingCf { axis, value } => {
1406 assert_eq!(axis, "windage");
1407 assert_eq!(value.to_bits(), bad.to_bits(), "payload must echo the input");
1408 }
1409 other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1410 }
1411 }
1412 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1413 req.windage_cf = 1.05;
1414 assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1415
1416 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1419 req.elevation_cf = 95.0;
1420 req.windage_cf = 95.0;
1421 assert_eq!(
1422 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1423 CardError::InvalidTrackingCf {
1424 axis: "elevation",
1425 value: 95.0
1426 }
1427 );
1428 let text = CardError::InvalidTrackingCf {
1430 axis: "elevation",
1431 value: 95.0,
1432 }
1433 .to_string();
1434 assert!(text.contains("elevation") && text.contains("0.5") && text.contains("1.5"), "{text}");
1435 }
1436
1437 #[test]
1445 fn report_serializes_verbatim_with_stable_field_names() {
1446 let curve = test_curve(900.0);
1447 let req = plain_request((200.0, 400.0), 0.2, 50);
1448 let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1449
1450 let json = serde_json::to_value(&report).expect("report must serialize");
1451 assert_eq!(json["schema_version"], ADAPTIVE_CARD_SCHEMA_VERSION_V1);
1452 assert_eq!(json["method"], "greedy_worst_point_insertion_on_holdcurve_grid_v1");
1453 assert_eq!(json["assumptions"].as_array().expect("assumptions array").len(), 5);
1454 assert_eq!(json["budget_met"], report.budget_met);
1455 assert_eq!(json["rows_capped"], report.rows_capped);
1456 assert!(json.get("worst_elevation_error").is_some());
1457 assert!(json.get("worst_windage_error").is_some());
1458 assert!(json.get("worst_error_range_m").is_some());
1459 assert!(json.get("verification_grid_step_m").is_some());
1460
1461 let rows = json["rows"].as_array().expect("rows array");
1462 assert_eq!(rows.len(), report.rows.len());
1463 let first = &rows[0];
1464 assert!(first["range"].is_number());
1465 assert!(first["drop_adj"].is_number(), "a populated Some(..) field must serialize as a number");
1466 assert!(first["come_up"].is_null());
1470 assert!(first["lead_adj"].is_null());
1471 assert_eq!(first["wind_columns"], serde_json::json!([]));
1472 }
1473
1474 #[test]
1488 fn card_row_field_names_bind_to_their_sentinel_values_in_json() {
1489 let row = CardRow {
1490 range: 111.1,
1491 drop_linear: Some(222.2),
1492 drop_adj: Some(333.3),
1493 come_up: Some(444.4),
1494 wind_linear: Some(555.5),
1495 wind_adj: Some(666.6),
1496 velocity: Some(777.7),
1497 energy: Some(888.8),
1498 time: Some(9.99),
1499 lead_adj: Some(101.1),
1500 wind_columns: vec![1.0, 2.0, 3.0],
1501 };
1502 let json = serde_json::to_value(&row).expect("row must serialize");
1503
1504 assert_eq!(json["range"], 111.1);
1505 assert_eq!(json["drop_linear"], 222.2);
1506 assert_eq!(json["drop_adj"], 333.3);
1507 assert_eq!(json["come_up"], 444.4);
1508 assert_eq!(json["wind_linear"], 555.5);
1509 assert_eq!(json["wind_adj"], 666.6);
1510 assert_eq!(json["velocity"], 777.7);
1511 assert_eq!(json["energy"], 888.8);
1512 assert_eq!(json["time"], 9.99);
1513 assert_eq!(json["lead_adj"], 101.1);
1514 assert_eq!(json["wind_columns"], serde_json::json!([1.0, 2.0, 3.0]));
1515
1516 assert_eq!(
1518 json.as_object().expect("row must serialize to a JSON object").len(),
1519 11
1520 );
1521 }
1522}