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 {
140 start_m: f64,
141 end_m: f64,
142 },
143 AnchorOutsideDomain {
144 anchor_m: f64,
145 start_m: f64,
146 end_m: f64,
147 },
148 NonPositiveBudget {
149 axis: &'static str,
150 value: f64,
151 },
152 ZeroMaxRows,
153 DomainOutsideCurve {
156 requested_m: f64,
157 curve_max_m: f64,
158 },
159 InvalidTrackingCf {
170 axis: &'static str,
171 value: f64,
172 },
173}
174
175impl fmt::Display for CardError {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 match self {
178 Self::EmptyOrInvertedDomain { start_m, end_m } => write!(
179 f,
180 "card domain {start_m} m to {end_m} m must be a forward interval with a positive start"
181 ),
182 Self::AnchorOutsideDomain {
183 anchor_m,
184 start_m,
185 end_m,
186 } => write!(
187 f,
188 "anchor {anchor_m} m lies outside the card domain {start_m} m to {end_m} m"
189 ),
190 Self::NonPositiveBudget { axis, value } => {
191 write!(f, "{axis} budget {value} must be positive and finite")
192 }
193 Self::ZeroMaxRows => write!(f, "a card needs room for at least one row"),
194 Self::DomainOutsideCurve {
195 requested_m,
196 curve_max_m,
197 } => write!(
198 f,
199 "range {requested_m} m is past the curve's last sampled point at {curve_max_m} m"
200 ),
201 Self::InvalidTrackingCf { axis, value } => write!(
202 f,
203 "{axis} tracking correction factor {value} must be finite and between 0.5 and 1.5 \
204 (it is a ratio such as 0.95, not a percentage)"
205 ),
206 }
207 }
208}
209
210impl std::error::Error for CardError {}
211
212#[derive(Debug, Clone, Serialize)]
222pub struct AdaptiveCardReportV1 {
223 pub schema_version: u32,
224 pub method: String,
226 pub assumptions: Vec<String>,
229 pub rows: Vec<CardRow>,
232 pub budget_met: bool,
233 pub rows_capped: bool,
235 pub worst_elevation_error: f64,
237 pub worst_windage_error: f64,
239 pub worst_error_range_m: f64,
243 pub verification_grid_step_m: f64,
246}
247
248#[derive(Debug, Clone, Copy)]
253struct PrintedAxis {
254 unit_factor: f64,
255 bias_mil: f64,
256 cf: f64,
257 click: Option<ClickValue>,
260}
261
262impl PrintedAxis {
263 fn new(unit: CardAdjustmentUnit, bias_mil: f64, cf: f64, click: Option<&ClickValue>) -> Self {
264 let unit_factor = unit.from_mil_factor();
265 Self {
266 unit_factor,
267 bias_mil,
268 cf,
269 click: click.map(|c| ClickValue {
273 size: click_size_mil(c) * unit_factor,
274 base: unit.click_base(),
275 }),
276 }
277 }
278
279 fn exact(&self, true_mil: f64) -> f64 {
282 let printed = true_mil * self.unit_factor;
283 let biased = if self.bias_mil != 0.0 {
286 printed + self.bias_mil * self.unit_factor
287 } else {
288 printed
289 };
290 biased / self.cf
291 }
292
293 fn printed(&self, true_mil: f64) -> f64 {
296 let exact = self.exact(true_mil);
297 match &self.click {
298 Some(c) => quantize_angle(exact, c).clicks as f64 * c.size,
299 None => exact,
300 }
301 }
302}
303
304#[derive(Debug, Clone, Copy)]
309struct AuditPoint {
310 range_m: f64,
311 exact_elevation: f64,
312 exact_windage: f64,
313 printed_elevation: f64,
314 printed_windage: f64,
315 drop_linear_m: f64,
316 wind_linear_m: f64,
317 velocity_mps: f64,
318 energy_j: f64,
319 time_s: f64,
320}
321
322type AxisErrors = (f64, f64);
324
325fn sorted_dedup(mut values: Vec<f64>) -> Vec<f64> {
328 values.sort_by(f64::total_cmp);
329 values.dedup();
330 values
331}
332
333fn native_grid_m(start_m: f64, end_m: f64) -> Vec<f64> {
340 let step = HoldCurve::SAMPLE_INTERVAL_M;
341 let first = ((start_m / step).ceil() as i64).max(1);
343 let last = (end_m / step).floor() as i64;
344 let mut grid = Vec::new();
345 for i in first..=last {
346 let g = i as f64 * step;
347 if g >= start_m && g <= end_m {
349 grid.push(g);
350 }
351 }
352 grid
353}
354
355fn sweep(audit: &[AuditPoint], rows: &[usize]) -> Vec<AxisErrors> {
361 let mut errors = vec![(0.0, 0.0); audit.len()];
362 for &r in rows {
363 let p = &audit[r];
364 errors[r] = (
365 (p.printed_elevation - p.exact_elevation).abs(),
366 (p.printed_windage - p.exact_windage).abs(),
367 );
368 }
369 for pair in rows.windows(2) {
370 let (lo, hi) = (pair[0], pair[1]);
371 let (a, b) = (&audit[lo], &audit[hi]);
372 let span = b.range_m - a.range_m;
373 for (k, point) in audit.iter().enumerate().take(hi).skip(lo + 1) {
374 let t = if span > 0.0 {
375 (point.range_m - a.range_m) / span
376 } else {
377 0.0
378 };
379 let elevation = a.printed_elevation + (b.printed_elevation - a.printed_elevation) * t;
380 let windage = a.printed_windage + (b.printed_windage - a.printed_windage) * t;
381 errors[k] = (
382 (elevation - point.exact_elevation).abs(),
383 (windage - point.exact_windage).abs(),
384 );
385 }
386 }
387 errors
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395struct LoopTrace {
396 iterations: usize,
397 iteration_cap: usize,
398}
399
400pub fn adaptive_card(
428 curve: &HoldCurve,
429 req: &AdaptiveRequest,
430 unit: CardAdjustmentUnit,
431) -> Result<AdaptiveCardReportV1, CardError> {
432 adaptive_card_traced(curve, req, unit).map(|(report, _)| report)
433}
434
435fn adaptive_card_traced(
437 curve: &HoldCurve,
438 req: &AdaptiveRequest,
439 unit: CardAdjustmentUnit,
440) -> Result<(AdaptiveCardReportV1, LoopTrace), CardError> {
441 let (start_m, end_m) = req.domain_m;
442
443 if req.max_rows == 0 {
444 return Err(CardError::ZeroMaxRows);
445 }
446 for (axis, value) in [
447 ("elevation", req.budget.elevation),
448 ("windage", req.budget.windage),
449 ] {
450 if !value.is_finite() || value <= 0.0 {
451 return Err(CardError::NonPositiveBudget { axis, value });
452 }
453 }
454 for (axis, value) in [("elevation", req.elevation_cf), ("windage", req.windage_cf)] {
459 if !crate::adjustment::tracking_cf_in_range(value) {
460 return Err(CardError::InvalidTrackingCf { axis, value });
461 }
462 }
463 if !start_m.is_finite() || !end_m.is_finite() || start_m <= 0.0 || end_m <= start_m {
464 return Err(CardError::EmptyOrInvertedDomain { start_m, end_m });
465 }
466 let curve_max_m = curve.max_sampled_range_m();
467 if end_m > curve_max_m {
468 return Err(CardError::DomainOutsideCurve {
469 requested_m: end_m,
470 curve_max_m,
471 });
472 }
473 for &anchor_m in &req.anchors_m {
474 if !anchor_m.is_finite() || anchor_m < start_m || anchor_m > end_m {
475 return Err(CardError::AnchorOutsideDomain {
476 anchor_m,
477 start_m,
478 end_m,
479 });
480 }
481 }
482 debug_assert!(req.bias_mil.is_finite(), "zero-set bias must be finite");
483
484 let (elevation_click, windage_click) = match req.click {
485 Some((e, w)) => (Some(e), Some(w)),
486 None => (None, None),
487 };
488 let elevation = PrintedAxis::new(unit, req.bias_mil, req.elevation_cf, elevation_click);
489 let windage = PrintedAxis::new(unit, 0.0, req.windage_cf, windage_click);
492
493 let mut seeds = vec![start_m, end_m];
499 seeds.extend_from_slice(&req.anchors_m);
500 let mut ranges = native_grid_m(start_m, end_m);
501 ranges.extend_from_slice(&seeds);
502 let ranges = sorted_dedup(ranges);
503
504 let mut audit = Vec::with_capacity(ranges.len());
505 for range_m in ranges {
506 let point = curve
507 .at_range(range_m)
508 .ok_or(CardError::DomainOutsideCurve {
509 requested_m: range_m,
510 curve_max_m,
511 })?;
512 audit.push(AuditPoint {
513 range_m,
514 exact_elevation: elevation.exact(point.drop_mil),
515 exact_windage: windage.exact(point.wind_mil),
516 printed_elevation: elevation.printed(point.drop_mil),
517 printed_windage: windage.printed(point.wind_mil),
518 drop_linear_m: point.drop_mil / 1000.0 * range_m,
519 wind_linear_m: point.wind_mil / 1000.0 * range_m,
520 velocity_mps: point.velocity_mps,
521 energy_j: point.energy_j,
522 time_s: point.time_s,
523 });
524 }
525
526 let mut rows: Vec<usize> = sorted_dedup(seeds)
529 .iter()
530 .map(|target| {
531 audit
532 .partition_point(|p| p.range_m < *target)
533 .min(audit.len() - 1)
534 })
535 .collect();
536 rows.dedup();
537 let mut is_row = vec![false; audit.len()];
538 for &r in &rows {
539 is_row[r] = true;
540 }
541 debug_assert_eq!(rows.first(), Some(&0), "the domain start must seed row 0");
542 debug_assert_eq!(
543 rows.last(),
544 Some(&(audit.len() - 1)),
545 "the domain end must seed the last row"
546 );
547
548 let iteration_cap = 2 * audit.len() + 8;
553 let mut iterations = 0usize;
554 let mut rows_capped = false;
555
556 while iterations < iteration_cap {
557 iterations += 1;
558 let errors = sweep(&audit, &rows);
559
560 let mut any_violation = false;
561 let mut worst: Option<(f64, usize)> = None;
562 for (k, &(elevation_error, windage_error)) in errors.iter().enumerate() {
563 if elevation_error <= req.budget.elevation && windage_error <= req.budget.windage {
564 continue;
565 }
566 any_violation = true;
567 if is_row[k] {
568 continue;
569 }
570 let excess =
574 (elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
575 if worst.is_none_or(|(best, _)| excess > best) {
576 worst = Some((excess, k));
577 }
578 }
579
580 if !any_violation {
581 break;
582 }
583 let Some((_, insert_at)) = worst else {
590 break;
591 };
592 if rows.len() >= req.max_rows {
593 rows_capped = true;
594 break;
595 }
596 rows.insert(rows.partition_point(|&r| r < insert_at), insert_at);
597 is_row[insert_at] = true;
598 }
599
600 let final_errors = sweep(&audit, &rows);
605 let mut worst_elevation_error = 0.0_f64;
606 let mut worst_windage_error = 0.0_f64;
607 let mut worst_excess = f64::NEG_INFINITY;
608 let mut worst_error_range_m = audit[0].range_m;
609 for (k, &(elevation_error, windage_error)) in final_errors.iter().enumerate() {
610 worst_elevation_error = worst_elevation_error.max(elevation_error);
611 worst_windage_error = worst_windage_error.max(windage_error);
612 let excess =
613 (elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
614 if excess > worst_excess {
615 worst_excess = excess;
616 worst_error_range_m = audit[k].range_m;
617 }
618 }
619 let budget_met =
620 worst_elevation_error <= req.budget.elevation && worst_windage_error <= req.budget.windage;
621
622 let card_rows = rows
634 .iter()
635 .map(|&r| {
636 let p = &audit[r];
637 CardRow {
638 range: p.range_m,
639 drop_linear: Some(p.drop_linear_m),
640 drop_adj: Some(p.printed_elevation),
641 come_up: None,
642 wind_linear: Some(p.wind_linear_m),
643 wind_adj: Some(p.printed_windage),
644 velocity: Some(p.velocity_mps),
645 energy: Some(p.energy_j),
646 time: Some(p.time_s),
647 lead_adj: None,
648 wind_columns: Vec::new(),
649 }
650 })
651 .collect();
652
653 let report = AdaptiveCardReportV1 {
654 schema_version: ADAPTIVE_CARD_SCHEMA_VERSION_V1,
655 method: "greedy_worst_point_insertion_on_holdcurve_grid_v1".to_string(),
656 assumptions: adaptive_card_assumptions(),
657 rows: card_rows,
658 budget_met,
659 rows_capped,
660 worst_elevation_error,
661 worst_windage_error,
662 worst_error_range_m,
663 verification_grid_step_m: HoldCurve::SAMPLE_INTERVAL_M,
664 };
665 Ok((
666 report,
667 LoopTrace {
668 iterations,
669 iteration_cap,
670 },
671 ))
672}
673
674fn adaptive_card_assumptions() -> Vec<String> {
677 [
678 "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.",
679 "The reader of this card interpolates linearly between adjacent rows.",
680 "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.",
681 "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.",
682 "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed.",
683 ]
684 .iter()
685 .map(|s| (*s).to_string())
686 .collect()
687}
688
689#[cfg(test)]
690mod adaptive_card_tests {
691 use super::*;
692 use crate::hold_curve::HoldCurveLoad;
693 use crate::DragModel;
694
695 fn test_load() -> HoldCurveLoad {
698 HoldCurveLoad {
699 velocity_mps: 800.0,
700 bc: 0.223,
701 mass_kg: 0.0109,
702 diameter_m: 0.00782,
703 drag_model: DragModel::G7,
704 sight_height_m: 0.045,
705 zero_distance_m: 100.0,
706 temperature_c: 15.0,
707 pressure_hpa: 1013.25,
708 humidity: 50.0,
709 altitude_m: 0.0,
710 wind_speed_mps: 3.0,
711 wind_direction_deg: 90.0,
712 }
713 }
714
715 fn test_curve(max_range_m: f64) -> HoldCurve {
716 HoldCurve::solve(&test_load(), max_range_m).expect("hold curve should solve")
717 }
718
719 fn plain_request(
723 domain_m: (f64, f64),
724 budget: f64,
725 max_rows: usize,
726 ) -> AdaptiveRequest<'static> {
727 AdaptiveRequest {
728 domain_m,
729 anchors_m: Vec::new(),
730 budget: AdaptiveBudget {
731 elevation: budget,
732 windage: budget,
733 },
734 max_rows,
735 click: None,
736 elevation_cf: 1.0,
737 windage_cf: 1.0,
738 bias_mil: 0.0,
739 }
740 }
741
742 fn independent_audited(curve: &HoldCurve, start_m: f64, end_m: f64) -> Vec<(f64, f64, f64)> {
747 let step = HoldCurve::SAMPLE_INTERVAL_M;
748 let mut ranges = vec![start_m];
749 let mut i = 1_i64;
750 loop {
751 let g = i as f64 * step;
752 if g > end_m {
753 break;
754 }
755 if g > start_m {
756 ranges.push(g);
757 }
758 i += 1;
759 }
760 if ranges.last().is_none_or(|&last| last < end_m) {
761 ranges.push(end_m);
762 }
763 ranges
764 .into_iter()
765 .map(|g| {
766 let p = curve
767 .at_range(g)
768 .expect("audited range must be on the curve");
769 (g, p.drop_mil, p.wind_mil)
770 })
771 .collect()
772 }
773
774 fn independent_worst_error(
778 audited: &[(f64, f64, f64)],
779 row_ranges: &[f64],
780 row_elevation: &[f64],
781 row_windage: &[f64],
782 ) -> (f64, f64) {
783 let mut worst = (0.0_f64, 0.0_f64);
784 for &(g, drop_mil, wind_mil) in audited {
785 let lo = row_ranges
787 .partition_point(|&r| r <= g)
788 .saturating_sub(1)
789 .min(row_ranges.len() - 2);
790 let hi = lo + 1;
791 let span = row_ranges[hi] - row_ranges[lo];
792 let t = if span > 0.0 {
793 (g - row_ranges[lo]) / span
794 } else {
795 0.0
796 };
797 let elevation = row_elevation[lo] + (row_elevation[hi] - row_elevation[lo]) * t;
798 let windage = row_windage[lo] + (row_windage[hi] - row_windage[lo]) * t;
799 worst.0 = worst.0.max((elevation - drop_mil).abs());
800 worst.1 = worst.1.max((windage - wind_mil).abs());
801 }
802 worst
803 }
804
805 fn smallest_uniform_card(
808 curve: &HoldCurve,
809 audited: &[(f64, f64, f64)],
810 start_m: f64,
811 end_m: f64,
812 budget: f64,
813 max_n: usize,
814 ) -> Option<usize> {
815 for n in 2..=max_n {
816 let ranges: Vec<f64> = (0..n)
817 .map(|i| start_m + (end_m - start_m) * i as f64 / (n - 1) as f64)
818 .collect();
819 let points: Vec<_> = ranges
820 .iter()
821 .map(|&r| curve.at_range(r).expect("uniform row on the curve"))
822 .collect();
823 let elevation: Vec<f64> = points.iter().map(|p| p.drop_mil).collect();
824 let windage: Vec<f64> = points.iter().map(|p| p.wind_mil).collect();
825 let (worst_elevation, worst_windage) =
826 independent_worst_error(audited, &ranges, &elevation, &windage);
827 if worst_elevation <= budget && worst_windage <= budget {
828 return Some(n);
829 }
830 }
831 None
832 }
833
834 fn row_columns(report: &AdaptiveCardReportV1) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
835 (
836 report.rows.iter().map(|r| r.range).collect(),
837 report
838 .rows
839 .iter()
840 .map(|r| r.drop_adj.expect("adaptive rows always carry a dial value"))
841 .collect(),
842 report
843 .rows
844 .iter()
845 .map(|r| r.wind_adj.expect("adaptive rows always carry a dial value"))
846 .collect(),
847 )
848 }
849
850 #[test]
854 fn verification_pass_confirms_every_audited_point_within_bounds() {
855 let curve = test_curve(900.0);
856 let budget = 0.1;
857 let req = plain_request((200.0, 800.0), budget, 500);
858 let report =
859 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
860
861 assert!(
862 report.budget_met,
863 "0.1 mil over 200-800 m should be reachable"
864 );
865 assert!(!report.rows_capped);
866 assert_eq!(report.schema_version, ADAPTIVE_CARD_SCHEMA_VERSION_V1);
867 assert_eq!(
868 report.verification_grid_step_m,
869 HoldCurve::SAMPLE_INTERVAL_M
870 );
871
872 let (ranges, elevation, windage) = row_columns(&report);
873 let audited = independent_audited(&curve, 200.0, 800.0);
874 let (worst_elevation, worst_windage) =
875 independent_worst_error(&audited, &ranges, &elevation, &windage);
876
877 assert!(
878 worst_elevation <= budget,
879 "independent audit found {worst_elevation} mil of elevation error, budget {budget}"
880 );
881 assert!(
882 worst_windage <= budget,
883 "independent audit found {worst_windage} mil of windage error, budget {budget}"
884 );
885 assert!(report.worst_elevation_error >= worst_elevation - 1e-12);
887 assert!(report.worst_windage_error >= worst_windage - 1e-12);
888 }
889
890 #[test]
893 fn tightening_the_budget_never_decreases_row_count() {
894 let curve = test_curve(900.0);
895 let counts: Vec<usize> = [0.4, 0.2, 0.1, 0.05]
896 .iter()
897 .map(|&budget| {
898 let req = plain_request((200.0, 800.0), budget, 800);
899 let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
900 .expect("card should build");
901 assert!(
902 report.budget_met,
903 "{budget} mil should be reachable unquantized"
904 );
905 report.rows.len()
906 })
907 .collect();
908
909 for pair in counts.windows(2) {
910 assert!(
911 pair[1] >= pair[0],
912 "row counts must not decrease as the budget tightens: {counts:?}"
913 );
914 }
915 assert!(
916 counts[3] > counts[0],
917 "a 8x tighter budget should cost rows: {counts:?}"
918 );
919 }
920
921 #[test]
925 fn adaptive_rows_concentrate_where_the_curve_bends() {
926 let curve = test_curve(900.0);
927 for budget in [0.1, 0.05, 0.02] {
928 let req = plain_request((200.0, 800.0), budget, 800);
929 let report =
930 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
931 assert!(report.budget_met, "{budget} mil should be reachable");
932 assert!(report.rows.len() >= 4, "need enough rows to halve");
933
934 let gaps: Vec<f64> = report
935 .rows
936 .windows(2)
937 .map(|p| p[1].range - p[0].range)
938 .collect();
939 let half = gaps.len() / 2;
940 let near: f64 = gaps[..half].iter().sum::<f64>() / half as f64;
941 let far: f64 = gaps[gaps.len() - half..].iter().sum::<f64>() / half as f64;
942 assert!(
943 far < near,
944 "budget {budget}: far-half spacing {far:.1} m is not tighter than near-half \
945 {near:.1} m -- the card is not adapting, gaps {gaps:?}"
946 );
947 }
948 }
949
950 #[test]
977 fn fixed_step_comparison_is_measured_not_assumed() {
978 let curve = test_curve(900.0);
979 let (start_m, end_m, budget) = (200.0, 800.0, 0.1);
980
981 let req = plain_request((start_m, end_m), budget, 800);
982 let report =
983 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
984 assert!(report.budget_met);
985 let adaptive_rows = report.rows.len();
986
987 let audited = independent_audited(&curve, start_m, end_m);
990 let uniform_rows = smallest_uniform_card(&curve, &audited, start_m, end_m, budget, 400)
991 .expect("some uniform step must meet the budget");
992
993 assert!(
994 adaptive_rows <= 2 * uniform_rows,
995 "adaptive used {adaptive_rows} rows against a {uniform_rows}-row uniform card; \
996 bisection granularity should never cost more than a doubling"
997 );
998 assert!(
999 adaptive_rows + 1 >= uniform_rows,
1000 "adaptive ({adaptive_rows} rows) now beats uniform ({uniform_rows} rows) at the \
1001 brief's own parameters -- the insertion rule has been improved, so the finding in \
1002 this test's doc comment, the \"not a shorter card\" disclosure on `adaptive_card`, \
1003 and the product guidance built on it are ALL stale and must be revisited"
1004 );
1005 }
1006
1007 #[test]
1010 fn anchors_always_present_and_determinism() {
1011 let curve = test_curve(900.0);
1012 let anchors = vec![300.0, 512.5, 777.0];
1013 let mut req = plain_request((200.0, 800.0), 0.15, 500);
1014 req.anchors_m.clone_from(&anchors);
1015
1016 let first =
1017 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1018 let second =
1019 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1020
1021 for anchor in &anchors {
1022 assert!(
1023 first.rows.iter().any(|row| row.range == *anchor),
1024 "anchor {anchor} m is missing from the card"
1025 );
1026 }
1027 assert_eq!(first.rows.first().map(|r| r.range), Some(200.0));
1029 assert_eq!(first.rows.last().map(|r| r.range), Some(800.0));
1030
1031 assert_eq!(first.method, second.method);
1032 assert_eq!(first.assumptions, second.assumptions);
1033 assert_eq!(first.budget_met, second.budget_met);
1034 assert_eq!(first.rows_capped, second.rows_capped);
1035 assert_eq!(first.rows.len(), second.rows.len());
1036 assert_eq!(
1038 first.worst_elevation_error.to_bits(),
1039 second.worst_elevation_error.to_bits()
1040 );
1041 assert_eq!(
1042 first.worst_windage_error.to_bits(),
1043 second.worst_windage_error.to_bits()
1044 );
1045 assert_eq!(
1046 first.worst_error_range_m.to_bits(),
1047 second.worst_error_range_m.to_bits()
1048 );
1049 for (a, b) in first.rows.iter().zip(second.rows.iter()) {
1050 assert_eq!(a.range.to_bits(), b.range.to_bits());
1051 assert_eq!(a.drop_adj.map(f64::to_bits), b.drop_adj.map(f64::to_bits));
1052 assert_eq!(a.wind_adj.map(f64::to_bits), b.wind_adj.map(f64::to_bits));
1053 }
1054 }
1055
1056 #[test]
1061 fn quantization_floor_is_honest_and_terminates() {
1062 let curve = test_curve(900.0);
1063 let step = HoldCurve::SAMPLE_INTERVAL_M;
1064 let (start_m, end_m) = (330.0 * step, 350.0 * step);
1067 let audited_points = 21usize;
1068
1069 let click = ClickValue {
1070 size: 0.1,
1071 base: ClickBase::Mil,
1072 };
1073 let half_click = 0.05;
1074 let budget = 0.001;
1075 let req = AdaptiveRequest {
1076 domain_m: (start_m, end_m),
1077 anchors_m: Vec::new(),
1078 budget: AdaptiveBudget {
1079 elevation: budget,
1080 windage: budget,
1081 },
1082 max_rows: 500, click: Some((&click, &click)),
1084 elevation_cf: 1.0,
1085 windage_cf: 1.0,
1086 bias_mil: 0.0,
1087 };
1088
1089 let (report, trace) =
1090 adaptive_card_traced(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1091
1092 assert!(
1096 trace.iterations <= audited_points + 1,
1097 "search took {} iterations for {audited_points} audited points (cap {}) -- \
1098 the irreducible-error stop is not firing",
1099 trace.iterations,
1100 trace.iteration_cap
1101 );
1102 assert!(
1103 trace.iterations < trace.iteration_cap,
1104 "the runaway backstop, not the irreducible-error stop, ended the search"
1105 );
1106
1107 assert!(!report.budget_met, "0.001 mil is under the 0.05 mil floor");
1108 assert!(
1109 !report.rows_capped,
1110 "the row cap was not binding; the stop must be attributed to the floor"
1111 );
1112 assert!(report.rows.len() <= audited_points);
1113
1114 let mut row_worst = (0.0_f64, 0.0_f64);
1117 for row in &report.rows {
1118 let point = curve.at_range(row.range).expect("row on the curve");
1119 row_worst.0 = row_worst
1120 .0
1121 .max((row.drop_adj.expect("dial") - point.drop_mil).abs());
1122 row_worst.1 = row_worst
1123 .1
1124 .max((row.wind_adj.expect("dial") - point.wind_mil).abs());
1125 }
1126 assert!(
1127 (report.worst_elevation_error - row_worst.0).abs() < 1e-12,
1128 "worst elevation {} vs independently measured row residue {}",
1129 report.worst_elevation_error,
1130 row_worst.0
1131 );
1132 assert!(report.worst_elevation_error > budget);
1133 assert!(
1134 report.worst_elevation_error <= half_click + 1e-12,
1135 "residue {} exceeded the half-click floor",
1136 report.worst_elevation_error
1137 );
1138 assert!(report.worst_windage_error <= half_click + 1e-12);
1139 }
1140
1141 #[test]
1143 fn max_rows_caps_with_capped_flag() {
1144 let curve = test_curve(900.0);
1145 let req = plain_request((200.0, 800.0), 0.001, 5);
1146 let report =
1147 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1148
1149 assert!(report.rows_capped);
1150 assert!(!report.budget_met);
1151 assert_eq!(report.rows.len(), 5);
1152 assert!(report.worst_elevation_error > 0.001);
1153 assert!(report.worst_error_range_m >= 200.0 && report.worst_error_range_m <= 800.0);
1154 }
1155
1156 #[test]
1160 fn report_carries_method_and_all_five_assumptions() {
1161 let curve = test_curve(900.0);
1162 let req = plain_request((200.0, 400.0), 0.2, 50);
1163 let report =
1164 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1165
1166 assert_eq!(
1167 report.method,
1168 "greedy_worst_point_insertion_on_holdcurve_grid_v1"
1169 );
1170 assert_eq!(report.assumptions.len(), 5);
1171 assert_eq!(
1172 report.assumptions[0],
1173 "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."
1174 );
1175 assert_eq!(
1176 report.assumptions[1],
1177 "The reader of this card interpolates linearly between adjacent rows."
1178 );
1179 assert_eq!(
1180 report.assumptions[2],
1181 "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."
1182 );
1183 assert_eq!(
1184 report.assumptions[3],
1185 "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."
1186 );
1187 assert_eq!(
1188 report.assumptions[4],
1189 "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed."
1190 );
1191 }
1192
1193 #[test]
1203 fn verification_grid_lands_on_the_curves_native_samples() {
1204 let curve = test_curve(900.0);
1205 let step = HoldCurve::SAMPLE_INTERVAL_M;
1206 let max_m = curve.max_sampled_range_m();
1207
1208 let index = (max_m / step).round();
1210 assert_eq!((index * step).to_bits(), max_m.to_bits());
1211 assert!(curve.at_range(max_m).is_some());
1212 assert!(curve.at_range(max_m + step).is_none());
1213
1214 let drop_m_at = |range_m: f64| {
1216 let p = curve.at_range(range_m).expect("probe on the curve");
1217 p.drop_mil / 1000.0 * range_m
1218 };
1219 let node = native_grid_m(step, max_m)[799];
1224 let delta = step / 4.0;
1225
1226 let bend_at = |centre: f64| {
1227 let mid = drop_m_at(centre);
1228 let avg = 0.5 * (drop_m_at(centre - delta) + drop_m_at(centre + delta));
1229 (mid - avg).abs()
1230 };
1231 let at_node = bend_at(node);
1232 let inside_interval = bend_at(node + step / 2.0);
1233
1234 assert!(
1235 inside_interval < 1e-12,
1236 "probes inside one claimed sample interval were not collinear ({inside_interval} m) \
1237 -- the reconstructed grid is offset from the curve's real nodes"
1238 );
1239 assert!(
1240 at_node > 1e-9 && at_node > 100.0 * inside_interval.max(f64::MIN_POSITIVE),
1241 "no interpolation kink at the claimed node ({at_node} m) -- \
1242 the reconstructed grid is offset from the curve's real nodes"
1243 );
1244
1245 let grid = native_grid_m(300.0, 300.0 + 10.0 * step);
1247 assert!(grid.len() >= 10);
1248 for pair in grid.windows(2) {
1249 assert!((pair[1] - pair[0] - step).abs() < 1e-12);
1250 }
1251 }
1252
1253 #[test]
1258 fn printed_pipeline_keeps_the_locked_bias_then_cf_then_quantize_order() {
1259 let curve = test_curve(900.0);
1260 let click = ClickValue {
1261 size: 0.1,
1262 base: ClickBase::Mil,
1263 };
1264 let (bias_mil, cf) = (2.0, 0.9);
1268 let req = AdaptiveRequest {
1269 domain_m: (300.0, 600.0),
1270 anchors_m: Vec::new(),
1271 budget: AdaptiveBudget {
1272 elevation: 0.2,
1273 windage: 0.2,
1274 },
1275 max_rows: 200,
1276 click: Some((&click, &click)),
1277 elevation_cf: cf,
1278 windage_cf: 1.0,
1279 bias_mil,
1280 };
1281 let report =
1282 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1283
1284 let row = &report.rows[1];
1285 let true_mil = curve
1286 .at_range(row.range)
1287 .expect("row on the curve")
1288 .drop_mil;
1289
1290 let right_order = ((true_mil + bias_mil) / cf / 0.1).round() * 0.1;
1291 let wrong_order = (((true_mil / cf) + bias_mil) / 0.1).round() * 0.1;
1292 let dialed = row.drop_adj.expect("dial");
1293
1294 assert!(
1295 (dialed - right_order).abs() < 1e-12,
1296 "printed {dialed} is not (true + bias) / cf quantized ({right_order})"
1297 );
1298 assert!(
1299 (right_order - wrong_order).abs() > 1e-9,
1300 "the chosen bias/CF make both orders agree; this test would not catch a swap"
1301 );
1302
1303 let true_wind = curve
1305 .at_range(row.range)
1306 .expect("row on the curve")
1307 .wind_mil;
1308 let expected_wind = (true_wind / 0.1).round() * 0.1;
1309 assert!((row.wind_adj.expect("dial") - expected_wind).abs() < 1e-12);
1310 }
1311
1312 #[test]
1315 fn moa_cards_use_the_locked_3438_ratio() {
1316 assert_eq!(CardAdjustmentUnit::Mil.from_mil_factor(), 1.0);
1317 assert_eq!(CardAdjustmentUnit::Moa.from_mil_factor(), 3438.0 / 1000.0);
1318 assert_ne!(
1319 CardAdjustmentUnit::Moa.from_mil_factor(),
1320 3437.7467 / 1000.0
1321 );
1322
1323 let curve = test_curve(900.0);
1324 let req = plain_request((300.0, 600.0), 0.5, 200);
1325 let report =
1326 adaptive_card(&curve, &req, CardAdjustmentUnit::Moa).expect("card should build");
1327 let row = &report.rows[0];
1328 let true_mil = curve
1329 .at_range(row.range)
1330 .expect("row on the curve")
1331 .drop_mil;
1332 assert_eq!(
1333 row.drop_adj.expect("dial").to_bits(),
1334 (true_mil * (3438.0 / 1000.0)).to_bits()
1335 );
1336 }
1337
1338 #[test]
1341 fn request_validation_reports_every_structured_error() {
1342 let curve = test_curve(900.0);
1343 let curve_max_m = curve.max_sampled_range_m();
1344
1345 let mut req = plain_request((200.0, 800.0), 0.1, 0);
1346 assert_eq!(
1347 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1348 CardError::ZeroMaxRows
1349 );
1350
1351 req = plain_request((200.0, 800.0), 0.1, 50);
1352 req.budget.elevation = 0.0;
1353 assert_eq!(
1354 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1355 CardError::NonPositiveBudget {
1356 axis: "elevation",
1357 value: 0.0
1358 }
1359 );
1360 req = plain_request((200.0, 800.0), 0.1, 50);
1361 req.budget.windage = f64::NAN;
1362 assert!(matches!(
1363 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1364 Err(CardError::NonPositiveBudget {
1365 axis: "windage",
1366 ..
1367 })
1368 ));
1369
1370 for domain in [(800.0, 200.0), (0.0, 500.0), (-10.0, 500.0), (300.0, 300.0)] {
1371 let req = plain_request(domain, 0.1, 50);
1372 assert!(
1373 matches!(
1374 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1375 Err(CardError::EmptyOrInvertedDomain { .. })
1376 ),
1377 "domain {domain:?} must be rejected"
1378 );
1379 }
1380
1381 let req = plain_request((200.0, curve_max_m + 1.0), 0.1, 50);
1382 assert_eq!(
1383 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1384 CardError::DomainOutsideCurve {
1385 requested_m: curve_max_m + 1.0,
1386 curve_max_m
1387 }
1388 );
1389
1390 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1391 req.anchors_m = vec![900.0];
1392 assert_eq!(
1393 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1394 CardError::AnchorOutsideDomain {
1395 anchor_m: 900.0,
1396 start_m: 200.0,
1397 end_m: 800.0
1398 }
1399 );
1400
1401 assert!(CardError::ZeroMaxRows
1403 .to_string()
1404 .contains("at least one row"));
1405 }
1406
1407 #[test]
1415 fn out_of_band_elevation_tracking_cf_is_rejected() {
1416 let curve = test_curve(900.0);
1417 for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1418 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1419 req.elevation_cf = bad;
1420 let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1421 .expect_err("an out-of-band elevation CF must be refused, not answered");
1422 match err {
1423 CardError::InvalidTrackingCf { axis, value } => {
1424 assert_eq!(axis, "elevation");
1425 assert_eq!(
1428 value.to_bits(),
1429 bad.to_bits(),
1430 "payload must echo the input"
1431 );
1432 }
1433 other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1434 }
1435 }
1436 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1438 req.elevation_cf = 0.95;
1439 assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1440 }
1441
1442 #[test]
1445 fn out_of_band_windage_tracking_cf_is_rejected() {
1446 let curve = test_curve(900.0);
1447 for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1448 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1449 req.windage_cf = bad;
1450 let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1451 .expect_err("an out-of-band windage CF must be refused, not answered");
1452 match err {
1453 CardError::InvalidTrackingCf { axis, value } => {
1454 assert_eq!(axis, "windage");
1455 assert_eq!(
1456 value.to_bits(),
1457 bad.to_bits(),
1458 "payload must echo the input"
1459 );
1460 }
1461 other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1462 }
1463 }
1464 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1465 req.windage_cf = 1.05;
1466 assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1467
1468 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1471 req.elevation_cf = 95.0;
1472 req.windage_cf = 95.0;
1473 assert_eq!(
1474 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1475 CardError::InvalidTrackingCf {
1476 axis: "elevation",
1477 value: 95.0
1478 }
1479 );
1480 let text = CardError::InvalidTrackingCf {
1482 axis: "elevation",
1483 value: 95.0,
1484 }
1485 .to_string();
1486 assert!(
1487 text.contains("elevation") && text.contains("0.5") && text.contains("1.5"),
1488 "{text}"
1489 );
1490 }
1491
1492 #[test]
1500 fn report_serializes_verbatim_with_stable_field_names() {
1501 let curve = test_curve(900.0);
1502 let req = plain_request((200.0, 400.0), 0.2, 50);
1503 let report =
1504 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1505
1506 let json = serde_json::to_value(&report).expect("report must serialize");
1507 assert_eq!(json["schema_version"], ADAPTIVE_CARD_SCHEMA_VERSION_V1);
1508 assert_eq!(
1509 json["method"],
1510 "greedy_worst_point_insertion_on_holdcurve_grid_v1"
1511 );
1512 assert_eq!(
1513 json["assumptions"]
1514 .as_array()
1515 .expect("assumptions array")
1516 .len(),
1517 5
1518 );
1519 assert_eq!(json["budget_met"], report.budget_met);
1520 assert_eq!(json["rows_capped"], report.rows_capped);
1521 assert!(json.get("worst_elevation_error").is_some());
1522 assert!(json.get("worst_windage_error").is_some());
1523 assert!(json.get("worst_error_range_m").is_some());
1524 assert!(json.get("verification_grid_step_m").is_some());
1525
1526 let rows = json["rows"].as_array().expect("rows array");
1527 assert_eq!(rows.len(), report.rows.len());
1528 let first = &rows[0];
1529 assert!(first["range"].is_number());
1530 assert!(
1531 first["drop_adj"].is_number(),
1532 "a populated Some(..) field must serialize as a number"
1533 );
1534 assert!(first["come_up"].is_null());
1538 assert!(first["lead_adj"].is_null());
1539 assert_eq!(first["wind_columns"], serde_json::json!([]));
1540 }
1541
1542 #[test]
1556 fn card_row_field_names_bind_to_their_sentinel_values_in_json() {
1557 let row = CardRow {
1558 range: 111.1,
1559 drop_linear: Some(222.2),
1560 drop_adj: Some(333.3),
1561 come_up: Some(444.4),
1562 wind_linear: Some(555.5),
1563 wind_adj: Some(666.6),
1564 velocity: Some(777.7),
1565 energy: Some(888.8),
1566 time: Some(9.99),
1567 lead_adj: Some(101.1),
1568 wind_columns: vec![1.0, 2.0, 3.0],
1569 };
1570 let json = serde_json::to_value(&row).expect("row must serialize");
1571
1572 assert_eq!(json["range"], 111.1);
1573 assert_eq!(json["drop_linear"], 222.2);
1574 assert_eq!(json["drop_adj"], 333.3);
1575 assert_eq!(json["come_up"], 444.4);
1576 assert_eq!(json["wind_linear"], 555.5);
1577 assert_eq!(json["wind_adj"], 666.6);
1578 assert_eq!(json["velocity"], 777.7);
1579 assert_eq!(json["energy"], 888.8);
1580 assert_eq!(json["time"], 9.99);
1581 assert_eq!(json["lead_adj"], 101.1);
1582 assert_eq!(json["wind_columns"], serde_json::json!([1.0, 2.0, 3.0]));
1583
1584 assert_eq!(
1586 json.as_object()
1587 .expect("row must serialize to a JSON object")
1588 .len(),
1589 11
1590 );
1591 }
1592}