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 station_presence: crate::atmosphere::StationAtmospherePresence::NEITHER,
713 }
714 }
715
716 fn test_curve(max_range_m: f64) -> HoldCurve {
717 HoldCurve::solve(&test_load(), max_range_m).expect("hold curve should solve")
718 }
719
720 fn plain_request(
724 domain_m: (f64, f64),
725 budget: f64,
726 max_rows: usize,
727 ) -> AdaptiveRequest<'static> {
728 AdaptiveRequest {
729 domain_m,
730 anchors_m: Vec::new(),
731 budget: AdaptiveBudget {
732 elevation: budget,
733 windage: budget,
734 },
735 max_rows,
736 click: None,
737 elevation_cf: 1.0,
738 windage_cf: 1.0,
739 bias_mil: 0.0,
740 }
741 }
742
743 fn independent_audited(curve: &HoldCurve, start_m: f64, end_m: f64) -> Vec<(f64, f64, f64)> {
748 let step = HoldCurve::SAMPLE_INTERVAL_M;
749 let mut ranges = vec![start_m];
750 let mut i = 1_i64;
751 loop {
752 let g = i as f64 * step;
753 if g > end_m {
754 break;
755 }
756 if g > start_m {
757 ranges.push(g);
758 }
759 i += 1;
760 }
761 if ranges.last().is_none_or(|&last| last < end_m) {
762 ranges.push(end_m);
763 }
764 ranges
765 .into_iter()
766 .map(|g| {
767 let p = curve
768 .at_range(g)
769 .expect("audited range must be on the curve");
770 (g, p.drop_mil, p.wind_mil)
771 })
772 .collect()
773 }
774
775 fn independent_worst_error(
779 audited: &[(f64, f64, f64)],
780 row_ranges: &[f64],
781 row_elevation: &[f64],
782 row_windage: &[f64],
783 ) -> (f64, f64) {
784 let mut worst = (0.0_f64, 0.0_f64);
785 for &(g, drop_mil, wind_mil) in audited {
786 let lo = row_ranges
788 .partition_point(|&r| r <= g)
789 .saturating_sub(1)
790 .min(row_ranges.len() - 2);
791 let hi = lo + 1;
792 let span = row_ranges[hi] - row_ranges[lo];
793 let t = if span > 0.0 {
794 (g - row_ranges[lo]) / span
795 } else {
796 0.0
797 };
798 let elevation = row_elevation[lo] + (row_elevation[hi] - row_elevation[lo]) * t;
799 let windage = row_windage[lo] + (row_windage[hi] - row_windage[lo]) * t;
800 worst.0 = worst.0.max((elevation - drop_mil).abs());
801 worst.1 = worst.1.max((windage - wind_mil).abs());
802 }
803 worst
804 }
805
806 fn smallest_uniform_card(
809 curve: &HoldCurve,
810 audited: &[(f64, f64, f64)],
811 start_m: f64,
812 end_m: f64,
813 budget: f64,
814 max_n: usize,
815 ) -> Option<usize> {
816 for n in 2..=max_n {
817 let ranges: Vec<f64> = (0..n)
818 .map(|i| start_m + (end_m - start_m) * i as f64 / (n - 1) as f64)
819 .collect();
820 let points: Vec<_> = ranges
821 .iter()
822 .map(|&r| curve.at_range(r).expect("uniform row on the curve"))
823 .collect();
824 let elevation: Vec<f64> = points.iter().map(|p| p.drop_mil).collect();
825 let windage: Vec<f64> = points.iter().map(|p| p.wind_mil).collect();
826 let (worst_elevation, worst_windage) =
827 independent_worst_error(audited, &ranges, &elevation, &windage);
828 if worst_elevation <= budget && worst_windage <= budget {
829 return Some(n);
830 }
831 }
832 None
833 }
834
835 fn row_columns(report: &AdaptiveCardReportV1) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
836 (
837 report.rows.iter().map(|r| r.range).collect(),
838 report
839 .rows
840 .iter()
841 .map(|r| r.drop_adj.expect("adaptive rows always carry a dial value"))
842 .collect(),
843 report
844 .rows
845 .iter()
846 .map(|r| r.wind_adj.expect("adaptive rows always carry a dial value"))
847 .collect(),
848 )
849 }
850
851 #[test]
855 fn verification_pass_confirms_every_audited_point_within_bounds() {
856 let curve = test_curve(900.0);
857 let budget = 0.1;
858 let req = plain_request((200.0, 800.0), budget, 500);
859 let report =
860 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
861
862 assert!(
863 report.budget_met,
864 "0.1 mil over 200-800 m should be reachable"
865 );
866 assert!(!report.rows_capped);
867 assert_eq!(report.schema_version, ADAPTIVE_CARD_SCHEMA_VERSION_V1);
868 assert_eq!(
869 report.verification_grid_step_m,
870 HoldCurve::SAMPLE_INTERVAL_M
871 );
872
873 let (ranges, elevation, windage) = row_columns(&report);
874 let audited = independent_audited(&curve, 200.0, 800.0);
875 let (worst_elevation, worst_windage) =
876 independent_worst_error(&audited, &ranges, &elevation, &windage);
877
878 assert!(
879 worst_elevation <= budget,
880 "independent audit found {worst_elevation} mil of elevation error, budget {budget}"
881 );
882 assert!(
883 worst_windage <= budget,
884 "independent audit found {worst_windage} mil of windage error, budget {budget}"
885 );
886 assert!(report.worst_elevation_error >= worst_elevation - 1e-12);
888 assert!(report.worst_windage_error >= worst_windage - 1e-12);
889 }
890
891 #[test]
894 fn tightening_the_budget_never_decreases_row_count() {
895 let curve = test_curve(900.0);
896 let counts: Vec<usize> = [0.4, 0.2, 0.1, 0.05]
897 .iter()
898 .map(|&budget| {
899 let req = plain_request((200.0, 800.0), budget, 800);
900 let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
901 .expect("card should build");
902 assert!(
903 report.budget_met,
904 "{budget} mil should be reachable unquantized"
905 );
906 report.rows.len()
907 })
908 .collect();
909
910 for pair in counts.windows(2) {
911 assert!(
912 pair[1] >= pair[0],
913 "row counts must not decrease as the budget tightens: {counts:?}"
914 );
915 }
916 assert!(
917 counts[3] > counts[0],
918 "a 8x tighter budget should cost rows: {counts:?}"
919 );
920 }
921
922 #[test]
926 fn adaptive_rows_concentrate_where_the_curve_bends() {
927 let curve = test_curve(900.0);
928 for budget in [0.1, 0.05, 0.02] {
929 let req = plain_request((200.0, 800.0), budget, 800);
930 let report =
931 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
932 assert!(report.budget_met, "{budget} mil should be reachable");
933 assert!(report.rows.len() >= 4, "need enough rows to halve");
934
935 let gaps: Vec<f64> = report
936 .rows
937 .windows(2)
938 .map(|p| p[1].range - p[0].range)
939 .collect();
940 let half = gaps.len() / 2;
941 let near: f64 = gaps[..half].iter().sum::<f64>() / half as f64;
942 let far: f64 = gaps[gaps.len() - half..].iter().sum::<f64>() / half as f64;
943 assert!(
944 far < near,
945 "budget {budget}: far-half spacing {far:.1} m is not tighter than near-half \
946 {near:.1} m -- the card is not adapting, gaps {gaps:?}"
947 );
948 }
949 }
950
951 #[test]
978 fn fixed_step_comparison_is_measured_not_assumed() {
979 let curve = test_curve(900.0);
980 let (start_m, end_m, budget) = (200.0, 800.0, 0.1);
981
982 let req = plain_request((start_m, end_m), budget, 800);
983 let report =
984 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
985 assert!(report.budget_met);
986 let adaptive_rows = report.rows.len();
987
988 let audited = independent_audited(&curve, start_m, end_m);
991 let uniform_rows = smallest_uniform_card(&curve, &audited, start_m, end_m, budget, 400)
992 .expect("some uniform step must meet the budget");
993
994 assert!(
995 adaptive_rows <= 2 * uniform_rows,
996 "adaptive used {adaptive_rows} rows against a {uniform_rows}-row uniform card; \
997 bisection granularity should never cost more than a doubling"
998 );
999 assert!(
1000 adaptive_rows + 1 >= uniform_rows,
1001 "adaptive ({adaptive_rows} rows) now beats uniform ({uniform_rows} rows) at the \
1002 brief's own parameters -- the insertion rule has been improved, so the finding in \
1003 this test's doc comment, the \"not a shorter card\" disclosure on `adaptive_card`, \
1004 and the product guidance built on it are ALL stale and must be revisited"
1005 );
1006 }
1007
1008 #[test]
1011 fn anchors_always_present_and_determinism() {
1012 let curve = test_curve(900.0);
1013 let anchors = vec![300.0, 512.5, 777.0];
1014 let mut req = plain_request((200.0, 800.0), 0.15, 500);
1015 req.anchors_m.clone_from(&anchors);
1016
1017 let first =
1018 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1019 let second =
1020 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1021
1022 for anchor in &anchors {
1023 assert!(
1024 first.rows.iter().any(|row| row.range == *anchor),
1025 "anchor {anchor} m is missing from the card"
1026 );
1027 }
1028 assert_eq!(first.rows.first().map(|r| r.range), Some(200.0));
1030 assert_eq!(first.rows.last().map(|r| r.range), Some(800.0));
1031
1032 assert_eq!(first.method, second.method);
1033 assert_eq!(first.assumptions, second.assumptions);
1034 assert_eq!(first.budget_met, second.budget_met);
1035 assert_eq!(first.rows_capped, second.rows_capped);
1036 assert_eq!(first.rows.len(), second.rows.len());
1037 assert_eq!(
1039 first.worst_elevation_error.to_bits(),
1040 second.worst_elevation_error.to_bits()
1041 );
1042 assert_eq!(
1043 first.worst_windage_error.to_bits(),
1044 second.worst_windage_error.to_bits()
1045 );
1046 assert_eq!(
1047 first.worst_error_range_m.to_bits(),
1048 second.worst_error_range_m.to_bits()
1049 );
1050 for (a, b) in first.rows.iter().zip(second.rows.iter()) {
1051 assert_eq!(a.range.to_bits(), b.range.to_bits());
1052 assert_eq!(a.drop_adj.map(f64::to_bits), b.drop_adj.map(f64::to_bits));
1053 assert_eq!(a.wind_adj.map(f64::to_bits), b.wind_adj.map(f64::to_bits));
1054 }
1055 }
1056
1057 #[test]
1062 fn quantization_floor_is_honest_and_terminates() {
1063 let curve = test_curve(900.0);
1064 let step = HoldCurve::SAMPLE_INTERVAL_M;
1065 let (start_m, end_m) = (330.0 * step, 350.0 * step);
1068 let audited_points = 21usize;
1069
1070 let click = ClickValue {
1071 size: 0.1,
1072 base: ClickBase::Mil,
1073 };
1074 let half_click = 0.05;
1075 let budget = 0.001;
1076 let req = AdaptiveRequest {
1077 domain_m: (start_m, end_m),
1078 anchors_m: Vec::new(),
1079 budget: AdaptiveBudget {
1080 elevation: budget,
1081 windage: budget,
1082 },
1083 max_rows: 500, click: Some((&click, &click)),
1085 elevation_cf: 1.0,
1086 windage_cf: 1.0,
1087 bias_mil: 0.0,
1088 };
1089
1090 let (report, trace) =
1091 adaptive_card_traced(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1092
1093 assert!(
1097 trace.iterations <= audited_points + 1,
1098 "search took {} iterations for {audited_points} audited points (cap {}) -- \
1099 the irreducible-error stop is not firing",
1100 trace.iterations,
1101 trace.iteration_cap
1102 );
1103 assert!(
1104 trace.iterations < trace.iteration_cap,
1105 "the runaway backstop, not the irreducible-error stop, ended the search"
1106 );
1107
1108 assert!(!report.budget_met, "0.001 mil is under the 0.05 mil floor");
1109 assert!(
1110 !report.rows_capped,
1111 "the row cap was not binding; the stop must be attributed to the floor"
1112 );
1113 assert!(report.rows.len() <= audited_points);
1114
1115 let mut row_worst = (0.0_f64, 0.0_f64);
1118 for row in &report.rows {
1119 let point = curve.at_range(row.range).expect("row on the curve");
1120 row_worst.0 = row_worst
1121 .0
1122 .max((row.drop_adj.expect("dial") - point.drop_mil).abs());
1123 row_worst.1 = row_worst
1124 .1
1125 .max((row.wind_adj.expect("dial") - point.wind_mil).abs());
1126 }
1127 assert!(
1128 (report.worst_elevation_error - row_worst.0).abs() < 1e-12,
1129 "worst elevation {} vs independently measured row residue {}",
1130 report.worst_elevation_error,
1131 row_worst.0
1132 );
1133 assert!(report.worst_elevation_error > budget);
1134 assert!(
1135 report.worst_elevation_error <= half_click + 1e-12,
1136 "residue {} exceeded the half-click floor",
1137 report.worst_elevation_error
1138 );
1139 assert!(report.worst_windage_error <= half_click + 1e-12);
1140 }
1141
1142 #[test]
1144 fn max_rows_caps_with_capped_flag() {
1145 let curve = test_curve(900.0);
1146 let req = plain_request((200.0, 800.0), 0.001, 5);
1147 let report =
1148 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1149
1150 assert!(report.rows_capped);
1151 assert!(!report.budget_met);
1152 assert_eq!(report.rows.len(), 5);
1153 assert!(report.worst_elevation_error > 0.001);
1154 assert!(report.worst_error_range_m >= 200.0 && report.worst_error_range_m <= 800.0);
1155 }
1156
1157 #[test]
1161 fn report_carries_method_and_all_five_assumptions() {
1162 let curve = test_curve(900.0);
1163 let req = plain_request((200.0, 400.0), 0.2, 50);
1164 let report =
1165 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1166
1167 assert_eq!(
1168 report.method,
1169 "greedy_worst_point_insertion_on_holdcurve_grid_v1"
1170 );
1171 assert_eq!(report.assumptions.len(), 5);
1172 assert_eq!(
1173 report.assumptions[0],
1174 "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."
1175 );
1176 assert_eq!(
1177 report.assumptions[1],
1178 "The reader of this card interpolates linearly between adjacent rows."
1179 );
1180 assert_eq!(
1181 report.assumptions[2],
1182 "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."
1183 );
1184 assert_eq!(
1185 report.assumptions[3],
1186 "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."
1187 );
1188 assert_eq!(
1189 report.assumptions[4],
1190 "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed."
1191 );
1192 }
1193
1194 #[test]
1204 fn verification_grid_lands_on_the_curves_native_samples() {
1205 let curve = test_curve(900.0);
1206 let step = HoldCurve::SAMPLE_INTERVAL_M;
1207 let max_m = curve.max_sampled_range_m();
1208
1209 let index = (max_m / step).round();
1211 assert_eq!((index * step).to_bits(), max_m.to_bits());
1212 assert!(curve.at_range(max_m).is_some());
1213 assert!(curve.at_range(max_m + step).is_none());
1214
1215 let drop_m_at = |range_m: f64| {
1217 let p = curve.at_range(range_m).expect("probe on the curve");
1218 p.drop_mil / 1000.0 * range_m
1219 };
1220 let node = native_grid_m(step, max_m)[799];
1225 let delta = step / 4.0;
1226
1227 let bend_at = |centre: f64| {
1228 let mid = drop_m_at(centre);
1229 let avg = 0.5 * (drop_m_at(centre - delta) + drop_m_at(centre + delta));
1230 (mid - avg).abs()
1231 };
1232 let at_node = bend_at(node);
1233 let inside_interval = bend_at(node + step / 2.0);
1234
1235 assert!(
1236 inside_interval < 1e-12,
1237 "probes inside one claimed sample interval were not collinear ({inside_interval} m) \
1238 -- the reconstructed grid is offset from the curve's real nodes"
1239 );
1240 assert!(
1241 at_node > 1e-9 && at_node > 100.0 * inside_interval.max(f64::MIN_POSITIVE),
1242 "no interpolation kink at the claimed node ({at_node} m) -- \
1243 the reconstructed grid is offset from the curve's real nodes"
1244 );
1245
1246 let grid = native_grid_m(300.0, 300.0 + 10.0 * step);
1248 assert!(grid.len() >= 10);
1249 for pair in grid.windows(2) {
1250 assert!((pair[1] - pair[0] - step).abs() < 1e-12);
1251 }
1252 }
1253
1254 #[test]
1259 fn printed_pipeline_keeps_the_locked_bias_then_cf_then_quantize_order() {
1260 let curve = test_curve(900.0);
1261 let click = ClickValue {
1262 size: 0.1,
1263 base: ClickBase::Mil,
1264 };
1265 let (bias_mil, cf) = (2.0, 0.9);
1269 let req = AdaptiveRequest {
1270 domain_m: (300.0, 600.0),
1271 anchors_m: Vec::new(),
1272 budget: AdaptiveBudget {
1273 elevation: 0.2,
1274 windage: 0.2,
1275 },
1276 max_rows: 200,
1277 click: Some((&click, &click)),
1278 elevation_cf: cf,
1279 windage_cf: 1.0,
1280 bias_mil,
1281 };
1282 let report =
1283 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1284
1285 let row = &report.rows[1];
1286 let true_mil = curve
1287 .at_range(row.range)
1288 .expect("row on the curve")
1289 .drop_mil;
1290
1291 let right_order = ((true_mil + bias_mil) / cf / 0.1).round() * 0.1;
1292 let wrong_order = (((true_mil / cf) + bias_mil) / 0.1).round() * 0.1;
1293 let dialed = row.drop_adj.expect("dial");
1294
1295 assert!(
1296 (dialed - right_order).abs() < 1e-12,
1297 "printed {dialed} is not (true + bias) / cf quantized ({right_order})"
1298 );
1299 assert!(
1300 (right_order - wrong_order).abs() > 1e-9,
1301 "the chosen bias/CF make both orders agree; this test would not catch a swap"
1302 );
1303
1304 let true_wind = curve
1306 .at_range(row.range)
1307 .expect("row on the curve")
1308 .wind_mil;
1309 let expected_wind = (true_wind / 0.1).round() * 0.1;
1310 assert!((row.wind_adj.expect("dial") - expected_wind).abs() < 1e-12);
1311 }
1312
1313 #[test]
1316 fn moa_cards_use_the_locked_3438_ratio() {
1317 assert_eq!(CardAdjustmentUnit::Mil.from_mil_factor(), 1.0);
1318 assert_eq!(CardAdjustmentUnit::Moa.from_mil_factor(), 3438.0 / 1000.0);
1319 assert_ne!(
1320 CardAdjustmentUnit::Moa.from_mil_factor(),
1321 3437.7467 / 1000.0
1322 );
1323
1324 let curve = test_curve(900.0);
1325 let req = plain_request((300.0, 600.0), 0.5, 200);
1326 let report =
1327 adaptive_card(&curve, &req, CardAdjustmentUnit::Moa).expect("card should build");
1328 let row = &report.rows[0];
1329 let true_mil = curve
1330 .at_range(row.range)
1331 .expect("row on the curve")
1332 .drop_mil;
1333 assert_eq!(
1334 row.drop_adj.expect("dial").to_bits(),
1335 (true_mil * (3438.0 / 1000.0)).to_bits()
1336 );
1337 }
1338
1339 #[test]
1342 fn request_validation_reports_every_structured_error() {
1343 let curve = test_curve(900.0);
1344 let curve_max_m = curve.max_sampled_range_m();
1345
1346 let mut req = plain_request((200.0, 800.0), 0.1, 0);
1347 assert_eq!(
1348 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1349 CardError::ZeroMaxRows
1350 );
1351
1352 req = plain_request((200.0, 800.0), 0.1, 50);
1353 req.budget.elevation = 0.0;
1354 assert_eq!(
1355 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1356 CardError::NonPositiveBudget {
1357 axis: "elevation",
1358 value: 0.0
1359 }
1360 );
1361 req = plain_request((200.0, 800.0), 0.1, 50);
1362 req.budget.windage = f64::NAN;
1363 assert!(matches!(
1364 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1365 Err(CardError::NonPositiveBudget {
1366 axis: "windage",
1367 ..
1368 })
1369 ));
1370
1371 for domain in [(800.0, 200.0), (0.0, 500.0), (-10.0, 500.0), (300.0, 300.0)] {
1372 let req = plain_request(domain, 0.1, 50);
1373 assert!(
1374 matches!(
1375 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
1376 Err(CardError::EmptyOrInvertedDomain { .. })
1377 ),
1378 "domain {domain:?} must be rejected"
1379 );
1380 }
1381
1382 let req = plain_request((200.0, curve_max_m + 1.0), 0.1, 50);
1383 assert_eq!(
1384 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1385 CardError::DomainOutsideCurve {
1386 requested_m: curve_max_m + 1.0,
1387 curve_max_m
1388 }
1389 );
1390
1391 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1392 req.anchors_m = vec![900.0];
1393 assert_eq!(
1394 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1395 CardError::AnchorOutsideDomain {
1396 anchor_m: 900.0,
1397 start_m: 200.0,
1398 end_m: 800.0
1399 }
1400 );
1401
1402 assert!(CardError::ZeroMaxRows
1404 .to_string()
1405 .contains("at least one row"));
1406 }
1407
1408 #[test]
1416 fn out_of_band_elevation_tracking_cf_is_rejected() {
1417 let curve = test_curve(900.0);
1418 for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1419 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1420 req.elevation_cf = bad;
1421 let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1422 .expect_err("an out-of-band elevation CF must be refused, not answered");
1423 match err {
1424 CardError::InvalidTrackingCf { axis, value } => {
1425 assert_eq!(axis, "elevation");
1426 assert_eq!(
1429 value.to_bits(),
1430 bad.to_bits(),
1431 "payload must echo the input"
1432 );
1433 }
1434 other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1435 }
1436 }
1437 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1439 req.elevation_cf = 0.95;
1440 assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1441 }
1442
1443 #[test]
1446 fn out_of_band_windage_tracking_cf_is_rejected() {
1447 let curve = test_curve(900.0);
1448 for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
1449 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1450 req.windage_cf = bad;
1451 let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
1452 .expect_err("an out-of-band windage CF must be refused, not answered");
1453 match err {
1454 CardError::InvalidTrackingCf { axis, value } => {
1455 assert_eq!(axis, "windage");
1456 assert_eq!(
1457 value.to_bits(),
1458 bad.to_bits(),
1459 "payload must echo the input"
1460 );
1461 }
1462 other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
1463 }
1464 }
1465 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1466 req.windage_cf = 1.05;
1467 assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
1468
1469 let mut req = plain_request((200.0, 800.0), 0.1, 50);
1472 req.elevation_cf = 95.0;
1473 req.windage_cf = 95.0;
1474 assert_eq!(
1475 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
1476 CardError::InvalidTrackingCf {
1477 axis: "elevation",
1478 value: 95.0
1479 }
1480 );
1481 let text = CardError::InvalidTrackingCf {
1483 axis: "elevation",
1484 value: 95.0,
1485 }
1486 .to_string();
1487 assert!(
1488 text.contains("elevation") && text.contains("0.5") && text.contains("1.5"),
1489 "{text}"
1490 );
1491 }
1492
1493 #[test]
1501 fn report_serializes_verbatim_with_stable_field_names() {
1502 let curve = test_curve(900.0);
1503 let req = plain_request((200.0, 400.0), 0.2, 50);
1504 let report =
1505 adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
1506
1507 let json = serde_json::to_value(&report).expect("report must serialize");
1508 assert_eq!(json["schema_version"], ADAPTIVE_CARD_SCHEMA_VERSION_V1);
1509 assert_eq!(
1510 json["method"],
1511 "greedy_worst_point_insertion_on_holdcurve_grid_v1"
1512 );
1513 assert_eq!(
1514 json["assumptions"]
1515 .as_array()
1516 .expect("assumptions array")
1517 .len(),
1518 5
1519 );
1520 assert_eq!(json["budget_met"], report.budget_met);
1521 assert_eq!(json["rows_capped"], report.rows_capped);
1522 assert!(json.get("worst_elevation_error").is_some());
1523 assert!(json.get("worst_windage_error").is_some());
1524 assert!(json.get("worst_error_range_m").is_some());
1525 assert!(json.get("verification_grid_step_m").is_some());
1526
1527 let rows = json["rows"].as_array().expect("rows array");
1528 assert_eq!(rows.len(), report.rows.len());
1529 let first = &rows[0];
1530 assert!(first["range"].is_number());
1531 assert!(
1532 first["drop_adj"].is_number(),
1533 "a populated Some(..) field must serialize as a number"
1534 );
1535 assert!(first["come_up"].is_null());
1539 assert!(first["lead_adj"].is_null());
1540 assert_eq!(first["wind_columns"], serde_json::json!([]));
1541 }
1542
1543 #[test]
1557 fn card_row_field_names_bind_to_their_sentinel_values_in_json() {
1558 let row = CardRow {
1559 range: 111.1,
1560 drop_linear: Some(222.2),
1561 drop_adj: Some(333.3),
1562 come_up: Some(444.4),
1563 wind_linear: Some(555.5),
1564 wind_adj: Some(666.6),
1565 velocity: Some(777.7),
1566 energy: Some(888.8),
1567 time: Some(9.99),
1568 lead_adj: Some(101.1),
1569 wind_columns: vec![1.0, 2.0, 3.0],
1570 };
1571 let json = serde_json::to_value(&row).expect("row must serialize");
1572
1573 assert_eq!(json["range"], 111.1);
1574 assert_eq!(json["drop_linear"], 222.2);
1575 assert_eq!(json["drop_adj"], 333.3);
1576 assert_eq!(json["come_up"], 444.4);
1577 assert_eq!(json["wind_linear"], 555.5);
1578 assert_eq!(json["wind_adj"], 666.6);
1579 assert_eq!(json["velocity"], 777.7);
1580 assert_eq!(json["energy"], 888.8);
1581 assert_eq!(json["time"], 9.99);
1582 assert_eq!(json["lead_adj"], 101.1);
1583 assert_eq!(json["wind_columns"], serde_json::json!([1.0, 2.0, 3.0]));
1584
1585 assert_eq!(
1587 json.as_object()
1588 .expect("row must serialize to a JSON object")
1589 .len(),
1590 11
1591 );
1592 }
1593}