ballistics-engine 0.33.0

High-performance ballistics trajectory engine with professional physics
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
//! Shared display-ready row type behind the CLI's card-shaped table/CSV/JSON surfaces
//! (0.33.0 decision-support Plan B Task 9).
//!
//! `come-ups`, `range-table`, `wind-card` and `compare` each grew their own
//! function-local row struct (`ComeUpRow`/`RangeRow`/`WindRow`/`LoadRow`) that said the
//! same handful of things -- range, drop, wind, velocity, energy, time -- a different way,
//! which blocked any shared card machinery between them. `CardRow` is the display-ready
//! superset: every surface populates only the fields it has ever had and leaves the rest
//! `None` / empty, so each surface's existing table/CSV/JSON writer keeps reading the
//! identical numbers it always did (pinned byte-identical by `tests/card_golden_cli.rs`).
//!
//! No feature gate: this module must compile for `wasm32-unknown-unknown` with no default
//! features (pure data, no `fs`, no `clap`, no `pdf`). Task 10 rewrites the PDF dope card on
//! `&[CardRow]`; Task 11 grows an adaptive-card engine in this module.

/// One card row. Display-ready values in the surface's chosen units (exactly what the
/// legacy per-surface structs stored), so rendering is unchanged; range is f64 metres-
/// or-display per the surface's existing convention — DO NOT re-convert anything.
///
/// `Serialize` (0.33.0 decision-support Plan B Task 12) so `adaptive-card -o json` can
/// pretty-print [`AdaptiveCardReportV1`] verbatim rather than hand-rebuilding a `json!`
/// object the way the four Task 9 surfaces do -- purely additive: none of those four
/// surfaces serializes a `CardRow` directly (each still builds its own `json!({..})` from
/// named fields), so this creates no new wire surface for them. Deliberately NOT
/// `Deserialize` -- nothing reads a `CardRow` back yet, and adding it speculatively is not
/// this task's job.
#[derive(Debug, Clone, Serialize)]
pub struct CardRow {
    pub range: f64,
    pub drop_linear: Option<f64>,
    pub drop_adj: Option<f64>,
    pub come_up: Option<f64>,
    pub wind_linear: Option<f64>,
    pub wind_adj: Option<f64>,
    pub velocity: Option<f64>,
    pub energy: Option<f64>,
    pub time: Option<f64>,
    pub lead_adj: Option<f64>,
    /// wind-card's per-speed drift columns; empty elsewhere.
    pub wind_columns: Vec<f64>,
}

// ---------------------------------------------------------------------------
// Adaptive range-card engine (0.33.0 decision-support Plan B Task 11, MBA-1351).
//
// `use` items sit here rather than at the top of the file so this task's addition is a
// pure append -- Task 9's `CardRow` block above is untouched, and a parallel lane editing
// it merges cleanly. Rust does not care where module-level items appear.
// ---------------------------------------------------------------------------

use crate::adjustment::{click_size_mil, quantize_angle, ClickBase, ClickValue};
use crate::hold_curve::HoldCurve;
use serde::Serialize;
use std::fmt;

/// Schema version of [`AdaptiveCardReportV1`]. Bump only for a breaking shape change.
pub const ADAPTIVE_CARD_SCHEMA_VERSION_V1: u32 = 1;

/// The reconstruction error a shooter is willing to accept, per axis, **in the card's
/// printed adjustment unit** (mil for [`CardAdjustmentUnit::Mil`], the locked-3438 MOA for
/// [`CardAdjustmentUnit::Moa`]). The caller converts; this engine never guesses a unit.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AdaptiveBudget {
    pub elevation: f64,
    pub windage: f64,
}

/// Everything one adaptive card needs beyond the solved curve itself.
///
/// `elevation_cf` / `windage_cf` are the scope's tracking correction factors (MBA-1358).
/// Both are VALIDATED here against [`crate::adjustment::tracking_cf_in_range`]'s locked
/// `(0.5, 1.5)` band and rejected with [`CardError::InvalidTrackingCf`] -- this is a public
/// library API that language bindings call without the CLI's own validation, and an
/// out-of-band CF fails silently rather than loudly. Enforced as a hard bound here, unlike
/// [`crate::optic::OpticError::NonPositiveTrackingFactor`]'s advisory-only band (see its own
/// doc comment): the card engine enforces it because a large finite CF here would otherwise
/// produce a confident `budget_met: true` on a card that does not actually meet its stated
/// error budget, while the planner does not, because its residual stays honest under a wild
/// CF. `bias_mil` is the selected zero set's elevation dial
/// correction (MBA-1360) in true angular mil; it applies to the ELEVATION axis only, which
/// is what "zero-set bias as a drop-equivalent" means.
#[derive(Debug, Clone)]
pub struct AdaptiveRequest<'a> {
    /// `(start, end)` in meters. Both must be finite, positive, and increasing.
    pub domain_m: (f64, f64),
    /// Ranges that must appear as rows whatever the error says (a known dope point, a
    /// target distance). Validated into the domain, never silently dropped.
    pub anchors_m: Vec<f64>,
    pub budget: AdaptiveBudget,
    /// Upper bound on printed rows. The mandatory seed (both domain ends plus every anchor)
    /// is never truncated to honour it -- see [`AdaptiveCardReportV1::rows_capped`].
    pub max_rows: usize,
    /// `(elevation, windage)` turret graduations. `Some` snaps every printed row onto the
    /// detent lattice, which is what a shooter can actually dial; `None` prints the
    /// unrounded angle.
    pub click: Option<(&'a ClickValue, &'a ClickValue)>,
    pub elevation_cf: f64,
    pub windage_cf: f64,
    pub bias_mil: f64,
}

/// The printed adjustment unit of a card's dial columns.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardAdjustmentUnit {
    Mil,
    Moa,
}

impl CardAdjustmentUnit {
    /// Multiplier from the curve's true angular mil into this printed unit.
    ///
    /// `Moa` uses this crate's LOCKED printed-table constant `3438` (MBA-724), deliberately
    /// not the exact-angle 3437.7467 -- every printed MOA column in the crate is drawn on
    /// that ratio, and an adaptive card that measured its error on a different one would be
    /// auditing numbers nobody prints.
    pub fn from_mil_factor(&self) -> f64 {
        match self {
            Self::Mil => 1.0,
            Self::Moa => 3438.0 / 1000.0,
        }
    }

    /// The [`ClickBase`] this printed unit quantizes on, so the synthetic printed-space
    /// graduation handed to [`quantize_angle`] is self-consistent rather than mislabelled.
    fn click_base(&self) -> ClickBase {
        match self {
            Self::Mil => ClickBase::Mil,
            Self::Moa => ClickBase::Moa,
        }
    }
}

/// Every way an [`adaptive_card`] request can be rejected. All six are structured: a range
/// a shooter asked for and did not get back is information, never a silent drop.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CardError {
    /// The domain is not a forward, positive, finite interval. (Angular drop divides by the
    /// range, so a start at or below zero has no angular value at all.)
    EmptyOrInvertedDomain { start_m: f64, end_m: f64 },
    AnchorOutsideDomain {
        anchor_m: f64,
        start_m: f64,
        end_m: f64,
    },
    NonPositiveBudget { axis: &'static str, value: f64 },
    ZeroMaxRows,
    /// The domain runs past the last sampled point of the curve, where there is no ground
    /// truth to verify against.
    DomainOutsideCurve { requested_m: f64, curve_max_m: f64 },
    /// A tracking correction factor outside [`crate::adjustment::tracking_cf_in_range`]'s
    /// locked `(0.5, 1.5)` band (MBA-1358).
    ///
    /// Checked rather than assumed because the failure is SILENT AND CONFIDENT, not loud: a
    /// finite but out-of-band CF -- the realistic slip of passing the percentage `95` where
    /// the ratio `0.95` belongs, or an `INFINITY` -- divides every printed value to ~0, so
    /// every measured error is ~0 and the engine would hand back a two-row card of near-zero
    /// dial values reporting `budget_met: true`. That is a wrong answer on the one field
    /// this whole module exists to make trustworthy, and it is worse than the NaN a zero CF
    /// produces, which at least reports `budget_met: false`.
    InvalidTrackingCf { axis: &'static str, value: f64 },
}

impl fmt::Display for CardError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyOrInvertedDomain { start_m, end_m } => write!(
                f,
                "card domain {start_m} m to {end_m} m must be a forward interval with a positive start"
            ),
            Self::AnchorOutsideDomain {
                anchor_m,
                start_m,
                end_m,
            } => write!(
                f,
                "anchor {anchor_m} m lies outside the card domain {start_m} m to {end_m} m"
            ),
            Self::NonPositiveBudget { axis, value } => {
                write!(f, "{axis} budget {value} must be positive and finite")
            }
            Self::ZeroMaxRows => write!(f, "a card needs room for at least one row"),
            Self::DomainOutsideCurve {
                requested_m,
                curve_max_m,
            } => write!(
                f,
                "range {requested_m} m is past the curve's last sampled point at {curve_max_m} m"
            ),
            Self::InvalidTrackingCf { axis, value } => write!(
                f,
                "{axis} tracking correction factor {value} must be finite and between 0.5 and 1.5 \
                 (it is a ratio such as 0.95, not a percentage)"
            ),
        }
    }
}

impl std::error::Error for CardError {}

/// What an adaptive card is, plus what its own numbers were measured to be worth.
///
/// The error fields are MEASURED by a dense verification pass over the declared grid after
/// the rows are final -- they are not the loop's running estimate, and `budget_met` is
/// recomputed from that pass rather than inherited from the insertion loop.
///
/// `Serialize` (Task 12): `adaptive-card -o json` prints this struct pretty-printed
/// verbatim, field names unchanged -- unlike the four Task 9 surfaces, there is no legacy
/// hand-built `json!` shape to preserve here, so a plain derive IS the wire format.
#[derive(Debug, Clone, Serialize)]
pub struct AdaptiveCardReportV1 {
    pub schema_version: u32,
    /// Stable identifier for how these rows were chosen.
    pub method: String,
    /// Exactly five entries; see [`adaptive_card`]. Index-stable: a consumer may quote
    /// `assumptions[3]` and mean the half-click floor.
    pub assumptions: Vec<String>,
    /// Printed rows, ascending by range (meters). Dial values are quantized when clicks
    /// were supplied.
    pub rows: Vec<CardRow>,
    pub budget_met: bool,
    /// The row cap stopped refinement while violations remained.
    pub rows_capped: bool,
    /// Worst measured elevation error over the audited points, in the card's printed unit.
    pub worst_elevation_error: f64,
    /// Worst measured windage error over the audited points, in the card's printed unit.
    pub worst_windage_error: f64,
    /// Range of the single worst point by budget-normalized excess (ties to the lowest
    /// range). The two per-axis maxima above may each occur elsewhere, which is exactly why
    /// they are reported as their own scalars.
    pub worst_error_range_m: f64,
    /// Spacing of the declared verification grid, meters. The honesty claim extends to this
    /// grid and no further.
    pub verification_grid_step_m: f64,
}

/// One axis's printed-value pipeline, in the LOCKED composition order the CLI's
/// `adjustment_display` boundary already uses (MBA-1360 x MBA-1358): the zero-set bias joins
/// the TRUE angular need first, the tracking correction divides second, click quantization
/// happens last on the corrected value. The order is load-bearing and must not move.
#[derive(Debug, Clone, Copy)]
struct PrintedAxis {
    unit_factor: f64,
    bias_mil: f64,
    cf: f64,
    /// The detent graduation expressed in the PRINTED unit, so quantization and the error
    /// metric live in the same space. `None` prints the unrounded angle.
    click: Option<ClickValue>,
}

impl PrintedAxis {
    fn new(unit: CardAdjustmentUnit, bias_mil: f64, cf: f64, click: Option<&ClickValue>) -> Self {
        let unit_factor = unit.from_mil_factor();
        Self {
            unit_factor,
            bias_mil,
            cf,
            // click_size_mil is the crate's one click -> mil converter (locked 3438 for MOA);
            // scaling its result by the printed unit factor lands the graduation in printed
            // space without a second, divergent conversion table.
            click: click.map(|c| ClickValue {
                size: click_size_mil(c) * unit_factor,
                base: unit.click_base(),
            }),
        }
    }

    /// The unquantized printed value -- what the card WOULD say with infinite dial
    /// resolution. This is the ground truth every error below is measured against.
    fn exact(&self, true_mil: f64) -> f64 {
        let printed = true_mil * self.unit_factor;
        // Skipping a zero bias is bit-exact on purpose: an unconditional `+ 0.0` flips a
        // -0.0 to +0.0 and changes rendered bytes (the same rule `adjustment_display` keeps).
        let biased = if self.bias_mil != 0.0 {
            printed + self.bias_mil * self.unit_factor
        } else {
            printed
        };
        biased / self.cf
    }

    /// The value actually PRINTED on the row: [`Self::exact`] snapped to the detent lattice
    /// when the optic's clicks are known.
    fn printed(&self, true_mil: f64) -> f64 {
        let exact = self.exact(true_mil);
        match &self.click {
            Some(c) => quantize_angle(exact, c).clicks as f64 * c.size,
            None => exact,
        }
    }
}

/// One audited range: the exact printed values, the values the card prints there, and the
/// display extras a [`CardRow`] wants. Precomputed once -- the curve is never re-queried
/// inside the insertion loop, which keeps each pass O(points) and makes the loop's result
/// independent of how many passes it takes.
#[derive(Debug, Clone, Copy)]
struct AuditPoint {
    range_m: f64,
    exact_elevation: f64,
    exact_windage: f64,
    printed_elevation: f64,
    printed_windage: f64,
    drop_linear_m: f64,
    wind_linear_m: f64,
    velocity_mps: f64,
    energy_j: f64,
    time_s: f64,
}

/// Per-axis absolute reconstruction error at one audited point.
type AxisErrors = (f64, f64);

/// Sort ascending and drop exact duplicates. `total_cmp` gives a total order with no
/// comparator-contract hazard; NaN cannot reach here (validation rejects it).
fn sorted_dedup(mut values: Vec<f64>) -> Vec<f64> {
    values.sort_by(f64::total_cmp);
    values.dedup();
    values
}

/// The hold curve's own sample points inside `[start_m, end_m]`.
///
/// [`HoldCurve`] samples at exact multiples of [`HoldCurve::SAMPLE_INTERVAL_M`] (the sampler
/// builds its distance list as `i as f64 * step`), so recomputing that arithmetic sequence
/// reproduces the native grid bit-for-bit without needing access to the curve's private
/// sample vector -- pinned by `verification_grid_lands_on_the_curves_native_samples`.
fn native_grid_m(start_m: f64, end_m: f64) -> Vec<f64> {
    let step = HoldCurve::SAMPLE_INTERVAL_M;
    // Index 0 is the muzzle, where an angular hold is undefined; start at 1.
    let first = ((start_m / step).ceil() as i64).max(1);
    let last = (end_m / step).floor() as i64;
    let mut grid = Vec::new();
    for i in first..=last {
        let g = i as f64 * step;
        // Re-check the bounds rather than trusting ceil/floor at the endpoints.
        if g >= start_m && g <= end_m {
            grid.push(g);
        }
    }
    grid
}

/// Reconstruct the printed card at every audited point and measure the per-axis error.
///
/// At a row the reconstruction IS the row's printed value, so the error there is the
/// quantization residual -- not zero. That is deliberate: hiding it would hide the
/// half-click floor, the one error extra rows cannot fix.
fn sweep(audit: &[AuditPoint], rows: &[usize]) -> Vec<AxisErrors> {
    let mut errors = vec![(0.0, 0.0); audit.len()];
    for &r in rows {
        let p = &audit[r];
        errors[r] = (
            (p.printed_elevation - p.exact_elevation).abs(),
            (p.printed_windage - p.exact_windage).abs(),
        );
    }
    for pair in rows.windows(2) {
        let (lo, hi) = (pair[0], pair[1]);
        let (a, b) = (&audit[lo], &audit[hi]);
        let span = b.range_m - a.range_m;
        for (k, point) in audit.iter().enumerate().take(hi).skip(lo + 1) {
            let t = if span > 0.0 {
                (point.range_m - a.range_m) / span
            } else {
                0.0
            };
            let elevation = a.printed_elevation + (b.printed_elevation - a.printed_elevation) * t;
            let windage = a.printed_windage + (b.printed_windage - a.printed_windage) * t;
            errors[k] = (
                (elevation - point.exact_elevation).abs(),
                (windage - point.exact_windage).abs(),
            );
        }
    }
    errors
}

/// Loop instrumentation, for the termination tests only. Not part of the public report:
/// how many passes the search took is an implementation detail, but it is the ONE
/// observable that distinguishes "stopped because the error is irreducible" from "spun
/// until the runaway backstop caught it".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LoopTrace {
    iterations: usize,
    iteration_cap: usize,
}

/// Build an adaptive range card and measure what it is worth.
///
/// Greedy worst-point insertion: start from the domain ends plus every anchor, then
/// repeatedly add the single audited point whose linearly-interpolated printed value is
/// furthest outside budget, until nothing violates, the row cap binds, or the remaining
/// error is irreducible. A SEPARATE dense pass over the declared grid then measures the
/// finished card, and `budget_met` comes from that measurement.
///
/// Everything is measured in printed-value space -- the same zero-set bias, tracking
/// correction and click quantization the rows carry -- so the reported error is the error a
/// shooter interpolating the printed card actually makes.
///
/// # What this does and does not buy you
///
/// It buys a MEASURED error bound, guaranteed anchors, and no step to guess. It does not
/// reliably buy a shorter card than a well-chosen fixed step: a single insertion can at
/// best quarter an interval's error (a bisection), so on a trajectory whose curvature
/// barely varies the whole card doubles at once while a uniform card may pick any row
/// count. See `fixed_step_comparison_is_measured_not_assumed` for the measurements.
///
/// # Errors
///
/// Returns [`CardError`] for an inverted or non-positive domain, an anchor outside it, a
/// non-positive budget, a zero row cap, a domain running past the curve's last sample, or a
/// tracking correction factor outside the locked `(0.5, 1.5)` band. Every one of these is
/// checked before any work is done, on every build profile -- a caller cannot reach the
/// solver with a request that would produce a confidently wrong card.
pub fn adaptive_card(
    curve: &HoldCurve,
    req: &AdaptiveRequest,
    unit: CardAdjustmentUnit,
) -> Result<AdaptiveCardReportV1, CardError> {
    adaptive_card_traced(curve, req, unit).map(|(report, _)| report)
}

/// [`adaptive_card`] plus its loop trace. Private: see [`LoopTrace`].
fn adaptive_card_traced(
    curve: &HoldCurve,
    req: &AdaptiveRequest,
    unit: CardAdjustmentUnit,
) -> Result<(AdaptiveCardReportV1, LoopTrace), CardError> {
    let (start_m, end_m) = req.domain_m;

    if req.max_rows == 0 {
        return Err(CardError::ZeroMaxRows);
    }
    for (axis, value) in [
        ("elevation", req.budget.elevation),
        ("windage", req.budget.windage),
    ] {
        if !value.is_finite() || value <= 0.0 {
            return Err(CardError::NonPositiveBudget { axis, value });
        }
    }
    // Enforced, not assumed: an out-of-band CF silently produces a confident, wrong card
    // (see `CardError::InvalidTrackingCf`), and a `debug_assert` is compiled out of exactly
    // the builds that ship. `tracking_cf_in_range` is the crate's ONE locked band (MBA-1358),
    // shared with the CLI and the WASM terminal -- reused here, never restated as a literal.
    for (axis, value) in [
        ("elevation", req.elevation_cf),
        ("windage", req.windage_cf),
    ] {
        if !crate::adjustment::tracking_cf_in_range(value) {
            return Err(CardError::InvalidTrackingCf { axis, value });
        }
    }
    if !start_m.is_finite() || !end_m.is_finite() || start_m <= 0.0 || end_m <= start_m {
        return Err(CardError::EmptyOrInvertedDomain { start_m, end_m });
    }
    let curve_max_m = curve.max_sampled_range_m();
    if end_m > curve_max_m {
        return Err(CardError::DomainOutsideCurve {
            requested_m: end_m,
            curve_max_m,
        });
    }
    for &anchor_m in &req.anchors_m {
        if !anchor_m.is_finite() || anchor_m < start_m || anchor_m > end_m {
            return Err(CardError::AnchorOutsideDomain {
                anchor_m,
                start_m,
                end_m,
            });
        }
    }
    debug_assert!(req.bias_mil.is_finite(), "zero-set bias must be finite");

    let (elevation_click, windage_click) = match req.click {
        Some((e, w)) => (Some(e), Some(w)),
        None => (None, None),
    };
    let elevation = PrintedAxis::new(unit, req.bias_mil, req.elevation_cf, elevation_click);
    // The zero-set bias is an elevation-only dial correction; windage carries its own
    // tracking correction but no bias on this interface.
    let windage = PrintedAxis::new(unit, 0.0, req.windage_cf, windage_click);

    // Audited points = the curve's native grid inside the domain, UNION the mandatory rows.
    // The union matters: a domain end or an anchor need not land on the grid, and a
    // quantized row carries error at its own range, so leaving one unaudited would under-
    // report the very floor `assumptions[3]` warns about. Every point the loop may insert
    // therefore already lives in this fixed, precomputed set.
    let mut seeds = vec![start_m, end_m];
    seeds.extend_from_slice(&req.anchors_m);
    let mut ranges = native_grid_m(start_m, end_m);
    ranges.extend_from_slice(&seeds);
    let ranges = sorted_dedup(ranges);

    let mut audit = Vec::with_capacity(ranges.len());
    for range_m in ranges {
        let point = curve
            .at_range(range_m)
            .ok_or(CardError::DomainOutsideCurve {
                requested_m: range_m,
                curve_max_m,
            })?;
        audit.push(AuditPoint {
            range_m,
            exact_elevation: elevation.exact(point.drop_mil),
            exact_windage: windage.exact(point.wind_mil),
            printed_elevation: elevation.printed(point.drop_mil),
            printed_windage: windage.printed(point.wind_mil),
            drop_linear_m: point.drop_mil / 1000.0 * range_m,
            wind_linear_m: point.wind_mil / 1000.0 * range_m,
            velocity_mps: point.velocity_mps,
            energy_j: point.energy_j,
            time_s: point.time_s,
        });
    }

    // Seed rows: both domain ends plus every anchor. `audit` is sorted and contains each of
    // them, so a partition point gives the seed indices in ascending order.
    let mut rows: Vec<usize> = sorted_dedup(seeds)
        .iter()
        .map(|target| {
            audit
                .partition_point(|p| p.range_m < *target)
                .min(audit.len() - 1)
        })
        .collect();
    rows.dedup();
    let mut is_row = vec![false; audit.len()];
    for &r in &rows {
        is_row[r] = true;
    }
    debug_assert_eq!(rows.first(), Some(&0), "the domain start must seed row 0");
    debug_assert_eq!(
        rows.last(),
        Some(&(audit.len() - 1)),
        "the domain end must seed the last row"
    );

    // Runaway backstop, NOT the termination argument. Every iteration either stops or
    // inserts an audited point that was not already a row, so at most `audit.len()`
    // insertions are possible and the irreducible-error stop below must fire first; this
    // cap only bounds the damage if that reasoning is ever broken by a later edit.
    let iteration_cap = 2 * audit.len() + 8;
    let mut iterations = 0usize;
    let mut rows_capped = false;

    while iterations < iteration_cap {
        iterations += 1;
        let errors = sweep(&audit, &rows);

        let mut any_violation = false;
        let mut worst: Option<(f64, usize)> = None;
        for (k, &(elevation_error, windage_error)) in errors.iter().enumerate() {
            if elevation_error <= req.budget.elevation && windage_error <= req.budget.windage {
                continue;
            }
            any_violation = true;
            if is_row[k] {
                continue;
            }
            // Two axes with different budgets reduce to one comparable number by
            // budget-normalized excess. `audit` is ascending and the comparison is strict,
            // so ties keep the lowest range.
            let excess =
                (elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
            if worst.is_none_or(|(best, _)| excess > best) {
                worst = Some((excess, k));
            }
        }

        if !any_violation {
            break;
        }
        // IRREDUCIBLE-ERROR STOP. Violations remain, but every one of them is AT a row, so
        // there is nothing left to insert -- the residue is the quantization floor, not a
        // shortage of rows. Without this arm the loop re-measures the same state forever;
        // deleting it makes `quantization_floor_is_honest_and_terminates` run to the runaway
        // backstop (50 iterations for 21 audited points) and fail, which is how that test
        // earns its keep.
        let Some((_, insert_at)) = worst else {
            break;
        };
        if rows.len() >= req.max_rows {
            rows_capped = true;
            break;
        }
        rows.insert(rows.partition_point(|&r| r < insert_at), insert_at);
        is_row[insert_at] = true;
    }

    // Dense verification pass -- deliberately SEPARATE from the loop above. It re-measures
    // the finished card from scratch; nothing about `budget_met` is inherited from the
    // search, which is what stops a loop that ended early (capped or irreducible) from
    // claiming a tolerance it never reached.
    let final_errors = sweep(&audit, &rows);
    let mut worst_elevation_error = 0.0_f64;
    let mut worst_windage_error = 0.0_f64;
    let mut worst_excess = f64::NEG_INFINITY;
    let mut worst_error_range_m = audit[0].range_m;
    for (k, &(elevation_error, windage_error)) in final_errors.iter().enumerate() {
        worst_elevation_error = worst_elevation_error.max(elevation_error);
        worst_windage_error = worst_windage_error.max(windage_error);
        let excess =
            (elevation_error / req.budget.elevation).max(windage_error / req.budget.windage);
        if excess > worst_excess {
            worst_excess = excess;
            worst_error_range_m = audit[k].range_m;
        }
    }
    let budget_met =
        worst_elevation_error <= req.budget.elevation && worst_windage_error <= req.budget.windage;

    // (T11 review fix: this used to carry a `debug_assert!(half_click_floor() >= 0.0, ...)`,
    // where `half_click_floor` was `PrintedAxis::half_click_floor(&self) -> f64`, `self.click
    // .map_or(0.0, |c| c.size / 2.0)`. That was checking a property already guaranteed by
    // construction wherever a `ClickValue` reaches this code path -- both CLI parsing and
    // profile loading reject a non-positive click size before it gets this far -- not the
    // `assumptions[3]` floor invariant the comment claimed to be checking. Removed (along with
    // the now-unused `half_click_floor` method) rather than replaced with an assertion this
    // function cannot actually verify: whether a given run's worst-case error DOES reach the
    // floor depends on where the mandatory rows happen to fall in the click phase, not on
    // anything checkable here.)

    let card_rows = rows
        .iter()
        .map(|&r| {
            let p = &audit[r];
            CardRow {
                range: p.range_m,
                drop_linear: Some(p.drop_linear_m),
                drop_adj: Some(p.printed_elevation),
                come_up: None,
                wind_linear: Some(p.wind_linear_m),
                wind_adj: Some(p.printed_windage),
                velocity: Some(p.velocity_mps),
                energy: Some(p.energy_j),
                time: Some(p.time_s),
                lead_adj: None,
                wind_columns: Vec::new(),
            }
        })
        .collect();

    let report = AdaptiveCardReportV1 {
        schema_version: ADAPTIVE_CARD_SCHEMA_VERSION_V1,
        method: "greedy_worst_point_insertion_on_holdcurve_grid_v1".to_string(),
        assumptions: adaptive_card_assumptions(),
        rows: card_rows,
        budget_met,
        rows_capped,
        worst_elevation_error,
        worst_windage_error,
        worst_error_range_m,
        verification_grid_step_m: HoldCurve::SAMPLE_INTERVAL_M,
    };
    Ok((
        report,
        LoopTrace {
            iterations,
            iteration_cap,
        },
    ))
}

/// The five index-stable claims [`AdaptiveCardReportV1`] ships with. Written once, here, so
/// the report and its pinning test cannot drift apart by editing only one of them.
fn adaptive_card_assumptions() -> Vec<String> {
    [
        "Verification is limited to the hold curve's declared sample grid (verification_grid_step_m) together with the card's own rows; no claim is made about ranges between those audited points.",
        "The reader of this card interpolates linearly between adjacent rows.",
        "Errors are measured in printed-value space -- the same zero-set bias, tracking-correction division and click quantization the printed rows carry -- so a constant zero-set bias cancels out of the interpolation error and the tracking correction factor is already inside the numbers being compared.",
        "Rows quantized to an optic's clicks carry an irreducible error of up to half a click at the rows themselves, which no number of added rows can remove.",
        "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed.",
    ]
    .iter()
    .map(|s| (*s).to_string())
    .collect()
}

#[cfg(test)]
mod adaptive_card_tests {
    use super::*;
    use crate::hold_curve::HoldCurveLoad;
    use crate::DragModel;

    /// The same representative .308-class load `hold_curve`'s own tests use, so a curve
    /// difference between the two modules cannot masquerade as a card-engine difference.
    fn test_load() -> HoldCurveLoad {
        HoldCurveLoad {
            velocity_mps: 800.0,
            bc: 0.223,
            mass_kg: 0.0109,
            diameter_m: 0.00782,
            drag_model: DragModel::G7,
            sight_height_m: 0.045,
            zero_distance_m: 100.0,
            temperature_c: 15.0,
            pressure_hpa: 1013.25,
            humidity: 50.0,
            altitude_m: 0.0,
            wind_speed_mps: 3.0,
            wind_direction_deg: 90.0,
        }
    }

    fn test_curve(max_range_m: f64) -> HoldCurve {
        HoldCurve::solve(&test_load(), max_range_m).expect("hold curve should solve")
    }

    /// An unbiased, uncorrected, unquantized request: in `Mil` the printed value is then
    /// exactly the curve's `drop_mil` / `wind_mil`, which keeps the independent checks below
    /// free of any conversion the engine could also get wrong.
    fn plain_request(domain_m: (f64, f64), budget: f64, max_rows: usize) -> AdaptiveRequest<'static> {
        AdaptiveRequest {
            domain_m,
            anchors_m: Vec::new(),
            budget: AdaptiveBudget {
                elevation: budget,
                windage: budget,
            },
            max_rows,
            click: None,
            elevation_cf: 1.0,
            windage_cf: 1.0,
            bias_mil: 0.0,
        }
    }

    /// The TEST's own audited set -- `(range_m, drop_mil, wind_mil)` at the curve's native
    /// grid inside the domain plus the two domain ends. Written out longhand rather than
    /// calling `native_grid_m`, so a bug in the engine's grid construction cannot hide
    /// behind the check that uses it.
    fn independent_audited(curve: &HoldCurve, start_m: f64, end_m: f64) -> Vec<(f64, f64, f64)> {
        let step = HoldCurve::SAMPLE_INTERVAL_M;
        let mut ranges = vec![start_m];
        let mut i = 1_i64;
        loop {
            let g = i as f64 * step;
            if g > end_m {
                break;
            }
            if g > start_m {
                ranges.push(g);
            }
            i += 1;
        }
        if ranges.last().is_none_or(|&last| last < end_m) {
            ranges.push(end_m);
        }
        ranges
            .into_iter()
            .map(|g| {
                let p = curve.at_range(g).expect("audited range must be on the curve");
                (g, p.drop_mil, p.wind_mil)
            })
            .collect()
    }

    /// The TEST's own reconstruction check: its own bracket search and hand-written lerp,
    /// deliberately NOT the engine's `sweep`. Returns the worst (elevation, windage)
    /// absolute error over `audited`.
    fn independent_worst_error(
        audited: &[(f64, f64, f64)],
        row_ranges: &[f64],
        row_elevation: &[f64],
        row_windage: &[f64],
    ) -> (f64, f64) {
        let mut worst = (0.0_f64, 0.0_f64);
        for &(g, drop_mil, wind_mil) in audited {
            // The last row at or below `g`, clamped so the final row still brackets.
            let lo = row_ranges
                .partition_point(|&r| r <= g)
                .saturating_sub(1)
                .min(row_ranges.len() - 2);
            let hi = lo + 1;
            let span = row_ranges[hi] - row_ranges[lo];
            let t = if span > 0.0 {
                (g - row_ranges[lo]) / span
            } else {
                0.0
            };
            let elevation = row_elevation[lo] + (row_elevation[hi] - row_elevation[lo]) * t;
            let windage = row_windage[lo] + (row_windage[hi] - row_windage[lo]) * t;
            worst.0 = worst.0.max((elevation - drop_mil).abs());
            worst.1 = worst.1.max((windage - wind_mil).abs());
        }
        worst
    }

    /// Smallest evenly-spaced card meeting `budget` on the same audited points, found by
    /// trial. `None` if no step up to `max_n` rows manages it.
    fn smallest_uniform_card(
        curve: &HoldCurve,
        audited: &[(f64, f64, f64)],
        start_m: f64,
        end_m: f64,
        budget: f64,
        max_n: usize,
    ) -> Option<usize> {
        for n in 2..=max_n {
            let ranges: Vec<f64> = (0..n)
                .map(|i| start_m + (end_m - start_m) * i as f64 / (n - 1) as f64)
                .collect();
            let points: Vec<_> = ranges
                .iter()
                .map(|&r| curve.at_range(r).expect("uniform row on the curve"))
                .collect();
            let elevation: Vec<f64> = points.iter().map(|p| p.drop_mil).collect();
            let windage: Vec<f64> = points.iter().map(|p| p.wind_mil).collect();
            let (worst_elevation, worst_windage) =
                independent_worst_error(audited, &ranges, &elevation, &windage);
            if worst_elevation <= budget && worst_windage <= budget {
                return Some(n);
            }
        }
        None
    }

    fn row_columns(report: &AdaptiveCardReportV1) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
        (
            report.rows.iter().map(|r| r.range).collect(),
            report
                .rows
                .iter()
                .map(|r| r.drop_adj.expect("adaptive rows always carry a dial value"))
                .collect(),
            report
                .rows
                .iter()
                .map(|r| r.wind_adj.expect("adaptive rows always carry a dial value"))
                .collect(),
        )
    }

    /// Spec 8.2 acceptance: a card that claims a met budget must survive an audit written
    /// by someone other than the engine. Every audited point is re-measured here with the
    /// test's own grid, bracket search and lerp.
    #[test]
    fn verification_pass_confirms_every_audited_point_within_bounds() {
        let curve = test_curve(900.0);
        let budget = 0.1;
        let req = plain_request((200.0, 800.0), budget, 500);
        let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");

        assert!(report.budget_met, "0.1 mil over 200-800 m should be reachable");
        assert!(!report.rows_capped);
        assert_eq!(report.schema_version, ADAPTIVE_CARD_SCHEMA_VERSION_V1);
        assert_eq!(report.verification_grid_step_m, HoldCurve::SAMPLE_INTERVAL_M);

        let (ranges, elevation, windage) = row_columns(&report);
        let audited = independent_audited(&curve, 200.0, 800.0);
        let (worst_elevation, worst_windage) =
            independent_worst_error(&audited, &ranges, &elevation, &windage);

        assert!(
            worst_elevation <= budget,
            "independent audit found {worst_elevation} mil of elevation error, budget {budget}"
        );
        assert!(
            worst_windage <= budget,
            "independent audit found {worst_windage} mil of windage error, budget {budget}"
        );
        // The engine's own measurement must not be optimistic relative to the independent one.
        assert!(report.worst_elevation_error >= worst_elevation - 1e-12);
        assert!(report.worst_windage_error >= worst_windage - 1e-12);
    }

    /// A tighter tolerance can never buy a shorter card. Swept across four budgets rather
    /// than compared at two points, so a non-monotone middle cannot slip through.
    #[test]
    fn tightening_the_budget_never_decreases_row_count() {
        let curve = test_curve(900.0);
        let counts: Vec<usize> = [0.4, 0.2, 0.1, 0.05]
            .iter()
            .map(|&budget| {
                let req = plain_request((200.0, 800.0), budget, 800);
                let report =
                    adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
                assert!(report.budget_met, "{budget} mil should be reachable unquantized");
                report.rows.len()
            })
            .collect();

        for pair in counts.windows(2) {
            assert!(
                pair[1] >= pair[0],
                "row counts must not decrease as the budget tightens: {counts:?}"
            );
        }
        assert!(
            counts[3] > counts[0],
            "a 8x tighter budget should cost rows: {counts:?}"
        );
    }

    /// The search really does adapt: rows bunch up where the curve bends. Measured as the
    /// mean row spacing over the far half of the card against the near half, which is the
    /// property that survives whatever the total row count turns out to be.
    #[test]
    fn adaptive_rows_concentrate_where_the_curve_bends() {
        let curve = test_curve(900.0);
        for budget in [0.1, 0.05, 0.02] {
            let req = plain_request((200.0, 800.0), budget, 800);
            let report =
                adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
            assert!(report.budget_met, "{budget} mil should be reachable");
            assert!(report.rows.len() >= 4, "need enough rows to halve");

            let gaps: Vec<f64> = report.rows.windows(2).map(|p| p[1].range - p[0].range).collect();
            let half = gaps.len() / 2;
            let near: f64 = gaps[..half].iter().sum::<f64>() / half as f64;
            let far: f64 = gaps[gaps.len() - half..].iter().sum::<f64>() / half as f64;
            assert!(
                far < near,
                "budget {budget}: far-half spacing {far:.1} m is not tighter than near-half \
                 {near:.1} m -- the card is not adapting, gaps {gaps:?}"
            );
        }
    }

    /// The fixed-step comparison, MEASURED rather than assumed -- and it does not come out
    /// the way the task brief predicted.
    ///
    /// The brief specified this as `smooth_trajectory_beats_fixed_step`: adaptive at 0.1 mil
    /// on 200-800 m should need fewer rows than the smallest uniform card meeting the same
    /// budget. It does not. Measured over five domains x four budgets, greedy worst-point
    /// insertion lost 10, tied 5 and won 5, and its row counts cluster on 5 / 9 / 17.
    ///
    /// That is a property of the pinned algorithm, not a defect in it. One insertion splits
    /// an interval into parts of length `l*h` and `(1-l)*h` carrying `l^2` and `(1-l)^2` of
    /// the old error, so the best any single insertion can do to an interval's error is
    /// divide it by four -- a bisection. When a trajectory's curvature barely varies (over
    /// 200-800 m this load's does so by under 2x, which is exactly what "smooth" means),
    /// every interval needs refining at once and the card doubles, while a uniform card is
    /// free to pick any row count at all. Adaptive placement wins where curvature varies
    /// sharply; on a smooth mid-range trajectory its value is the MEASURED error bound, the
    /// anchors and not having to guess a step -- not a shorter card.
    ///
    /// Two assertions, bounding the finding from BOTH sides, because a one-sided bound is
    /// not a pin. The upper bound is a regression backstop (bisection granularity must never
    /// cost more than a doubling). The directional one is the finding itself: adaptive does
    /// not meaningfully beat uniform here. Neither pins exact arithmetic a physics change
    /// would move -- the `+ 1` slack keeps a ULP-level shift at the current 5-vs-5 tie from
    /// firing spuriously -- but if adaptive ever genuinely wins, the directional assertion
    /// fails, and that is the signal that this comment, the public `adaptive_card` docs and
    /// the product guidance built on them have all gone stale.
    #[test]
    fn fixed_step_comparison_is_measured_not_assumed() {
        let curve = test_curve(900.0);
        let (start_m, end_m, budget) = (200.0, 800.0, 0.1);

        let req = plain_request((start_m, end_m), budget, 800);
        let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
        assert!(report.budget_met);
        let adaptive_rows = report.rows.len();

        // Smallest uniform card meeting the same budget on the same audited points, found
        // by trial in the test -- the most generous possible fixed-step baseline.
        let audited = independent_audited(&curve, start_m, end_m);
        let uniform_rows = smallest_uniform_card(&curve, &audited, start_m, end_m, budget, 400)
            .expect("some uniform step must meet the budget");

        assert!(
            adaptive_rows <= 2 * uniform_rows,
            "adaptive used {adaptive_rows} rows against a {uniform_rows}-row uniform card; \
             bisection granularity should never cost more than a doubling"
        );
        assert!(
            adaptive_rows + 1 >= uniform_rows,
            "adaptive ({adaptive_rows} rows) now beats uniform ({uniform_rows} rows) at the \
             brief's own parameters -- the insertion rule has been improved, so the finding in \
             this test's doc comment, the \"not a shorter card\" disclosure on `adaptive_card`, \
             and the product guidance built on it are ALL stale and must be revisited"
        );
    }

    /// Anchors are promises, not suggestions -- and the same request must produce the same
    /// card every time (no RNG, no hash-order dependence anywhere in the search).
    #[test]
    fn anchors_always_present_and_determinism() {
        let curve = test_curve(900.0);
        let anchors = vec![300.0, 512.5, 777.0];
        let mut req = plain_request((200.0, 800.0), 0.15, 500);
        req.anchors_m.clone_from(&anchors);

        let first = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");
        let second = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");

        for anchor in &anchors {
            assert!(
                first.rows.iter().any(|row| row.range == *anchor),
                "anchor {anchor} m is missing from the card"
            );
        }
        // Both domain ends are rows too.
        assert_eq!(first.rows.first().map(|r| r.range), Some(200.0));
        assert_eq!(first.rows.last().map(|r| r.range), Some(800.0));

        assert_eq!(first.method, second.method);
        assert_eq!(first.assumptions, second.assumptions);
        assert_eq!(first.budget_met, second.budget_met);
        assert_eq!(first.rows_capped, second.rows_capped);
        assert_eq!(first.rows.len(), second.rows.len());
        // Bit-for-bit, not approximately: determinism means identical, not close.
        assert_eq!(
            first.worst_elevation_error.to_bits(),
            second.worst_elevation_error.to_bits()
        );
        assert_eq!(
            first.worst_windage_error.to_bits(),
            second.worst_windage_error.to_bits()
        );
        assert_eq!(
            first.worst_error_range_m.to_bits(),
            second.worst_error_range_m.to_bits()
        );
        for (a, b) in first.rows.iter().zip(second.rows.iter()) {
            assert_eq!(a.range.to_bits(), b.range.to_bits());
            assert_eq!(
                a.drop_adj.map(f64::to_bits),
                b.drop_adj.map(f64::to_bits)
            );
            assert_eq!(
                a.wind_adj.map(f64::to_bits),
                b.wind_adj.map(f64::to_bits)
            );
        }
    }

    /// A budget below the half-click floor cannot be met by adding rows, and the search
    /// must SAY so rather than grind. The iteration assertion is the fault-injection probe:
    /// with the irreducible-error stop removed the loop stops making progress and runs to
    /// the runaway backstop, blowing this bound.
    #[test]
    fn quantization_floor_is_honest_and_terminates() {
        let curve = test_curve(900.0);
        let step = HoldCurve::SAMPLE_INTERVAL_M;
        // Domain ends chosen ON the native grid so the audited set is exactly 21 points,
        // whichever way the ceil/floor rounds at the endpoints.
        let (start_m, end_m) = (330.0 * step, 350.0 * step);
        let audited_points = 21usize;

        let click = ClickValue {
            size: 0.1,
            base: ClickBase::Mil,
        };
        let half_click = 0.05;
        let budget = 0.001;
        let req = AdaptiveRequest {
            domain_m: (start_m, end_m),
            anchors_m: Vec::new(),
            budget: AdaptiveBudget {
                elevation: budget,
                windage: budget,
            },
            max_rows: 500, // deliberately not binding: the stop must come from the floor
            click: Some((&click, &click)),
            elevation_cf: 1.0,
            windage_cf: 1.0,
            bias_mil: 0.0,
        };

        let (report, trace) = adaptive_card_traced(&curve, &req, CardAdjustmentUnit::Mil)
            .expect("card should build");

        // Termination, stated as a bound rather than demonstrated by hanging: every
        // iteration inserts a distinct audited point or stops, so one pass per point plus
        // the final deciding pass is the most the search can legitimately take.
        assert!(
            trace.iterations <= audited_points + 1,
            "search took {} iterations for {audited_points} audited points (cap {}) -- \
             the irreducible-error stop is not firing",
            trace.iterations,
            trace.iteration_cap
        );
        assert!(
            trace.iterations < trace.iteration_cap,
            "the runaway backstop, not the irreducible-error stop, ended the search"
        );

        assert!(!report.budget_met, "0.001 mil is under the 0.05 mil floor");
        assert!(
            !report.rows_capped,
            "the row cap was not binding; the stop must be attributed to the floor"
        );
        assert!(report.rows.len() <= audited_points);

        // The reported worst error IS the measured half-click residue: recomputed here from
        // the printed rows alone, with no help from the engine.
        let mut row_worst = (0.0_f64, 0.0_f64);
        for row in &report.rows {
            let point = curve.at_range(row.range).expect("row on the curve");
            row_worst.0 = row_worst
                .0
                .max((row.drop_adj.expect("dial") - point.drop_mil).abs());
            row_worst.1 = row_worst
                .1
                .max((row.wind_adj.expect("dial") - point.wind_mil).abs());
        }
        assert!(
            (report.worst_elevation_error - row_worst.0).abs() < 1e-12,
            "worst elevation {} vs independently measured row residue {}",
            report.worst_elevation_error,
            row_worst.0
        );
        assert!(report.worst_elevation_error > budget);
        assert!(
            report.worst_elevation_error <= half_click + 1e-12,
            "residue {} exceeded the half-click floor",
            report.worst_elevation_error
        );
        assert!(report.worst_windage_error <= half_click + 1e-12);
    }

    /// The row cap is honoured and admitted to, never papered over with a met budget.
    #[test]
    fn max_rows_caps_with_capped_flag() {
        let curve = test_curve(900.0);
        let req = plain_request((200.0, 800.0), 0.001, 5);
        let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");

        assert!(report.rows_capped);
        assert!(!report.budget_met);
        assert_eq!(report.rows.len(), 5);
        assert!(report.worst_elevation_error > 0.001);
        assert!(report.worst_error_range_m >= 200.0 && report.worst_error_range_m <= 800.0);
    }

    /// The method string and all five assumptions, pinned by length AND by exact content at
    /// every index -- a consumer that quotes `assumptions[3]` must keep getting the
    /// half-click floor and not whatever a later edit shuffled into that slot.
    #[test]
    fn report_carries_method_and_all_five_assumptions() {
        let curve = test_curve(900.0);
        let req = plain_request((200.0, 400.0), 0.2, 50);
        let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");

        assert_eq!(
            report.method,
            "greedy_worst_point_insertion_on_holdcurve_grid_v1"
        );
        assert_eq!(report.assumptions.len(), 5);
        assert_eq!(
            report.assumptions[0],
            "Verification is limited to the hold curve's declared sample grid (verification_grid_step_m) together with the card's own rows; no claim is made about ranges between those audited points."
        );
        assert_eq!(
            report.assumptions[1],
            "The reader of this card interpolates linearly between adjacent rows."
        );
        assert_eq!(
            report.assumptions[2],
            "Errors are measured in printed-value space -- the same zero-set bias, tracking-correction division and click quantization the printed rows carry -- so a constant zero-set bias cancels out of the interpolation error and the tracking correction factor is already inside the numbers being compared."
        );
        assert_eq!(
            report.assumptions[3],
            "Rows quantized to an optic's clicks carry an irreducible error of up to half a click at the rows themselves, which no number of added rows can remove."
        );
        assert_eq!(
            report.assumptions[4],
            "A budget tighter than that half-click floor is reported as budget_met: false; the requested tolerance is never silently relaxed."
        );
    }

    /// The verification grid's one load-bearing assumption: the curve's native samples sit
    /// at exact multiples of `SAMPLE_INTERVAL_M`, so `native_grid_m` reproduces them without
    /// reaching into the curve's private sample vector.
    ///
    /// Discriminating, not merely consistent: the interpolated drop is piecewise linear with
    /// its kinks AT the sample nodes, so three probes straddling a claimed node are
    /// measurably non-collinear while three probes inside one claimed interval are collinear
    /// to floating-point noise. A grid that was offset from the real nodes would swap those
    /// two outcomes.
    #[test]
    fn verification_grid_lands_on_the_curves_native_samples() {
        let curve = test_curve(900.0);
        let step = HoldCurve::SAMPLE_INTERVAL_M;
        let max_m = curve.max_sampled_range_m();

        // The last sample is an exact multiple of the step, bit-for-bit.
        let index = (max_m / step).round();
        assert_eq!((index * step).to_bits(), max_m.to_bits());
        assert!(curve.at_range(max_m).is_some());
        assert!(curve.at_range(max_m + step).is_none());

        // Linear drop at a range, reconstructed from the angular reading.
        let drop_m_at = |range_m: f64| {
            let p = curve.at_range(range_m).expect("probe on the curve");
            p.drop_mil / 1000.0 * range_m
        };
        // A claimed node, out where the curve bends hardest -- drawn from `native_grid_m`
        // itself (not independently reconstructed as `800.0 * step`) so a phase bug in the
        // function under test would surface in this kink probe too, not only in the separate
        // ascending/step-spaced check below.
        let node = native_grid_m(step, max_m)[799];
        let delta = step / 4.0;

        let bend_at = |centre: f64| {
            let mid = drop_m_at(centre);
            let avg = 0.5 * (drop_m_at(centre - delta) + drop_m_at(centre + delta));
            (mid - avg).abs()
        };
        let at_node = bend_at(node);
        let inside_interval = bend_at(node + step / 2.0);

        assert!(
            inside_interval < 1e-12,
            "probes inside one claimed sample interval were not collinear ({inside_interval} m) \
             -- the reconstructed grid is offset from the curve's real nodes"
        );
        assert!(
            at_node > 1e-9 && at_node > 100.0 * inside_interval.max(f64::MIN_POSITIVE),
            "no interpolation kink at the claimed node ({at_node} m) -- \
             the reconstructed grid is offset from the curve's real nodes"
        );

        // And the reconstructed grid is inside the domain, ascending, and step-spaced.
        let grid = native_grid_m(300.0, 300.0 + 10.0 * step);
        assert!(grid.len() >= 10);
        for pair in grid.windows(2) {
            assert!((pair[1] - pair[0] - step).abs() < 1e-12);
        }
    }

    /// The locked composition order (MBA-1360 x MBA-1358 x MBA-724): bias joins the TRUE
    /// angular need first, the tracking correction divides second, quantization is last.
    /// The wrong-order value is computed too, so the test provably fails if the pipeline
    /// is ever reordered.
    #[test]
    fn printed_pipeline_keeps_the_locked_bias_then_cf_then_quantize_order() {
        let curve = test_curve(900.0);
        let click = ClickValue {
            size: 0.1,
            base: ClickBase::Mil,
        };
        // Magnitudes chosen so the two composition orders land on DIFFERENT detents: they
        // differ by `bias * (1/cf - 1)`, which must clear a half click (0.05 mil) or
        // quantization would erase the very thing this test is trying to observe.
        let (bias_mil, cf) = (2.0, 0.9);
        let req = AdaptiveRequest {
            domain_m: (300.0, 600.0),
            anchors_m: Vec::new(),
            budget: AdaptiveBudget {
                elevation: 0.2,
                windage: 0.2,
            },
            max_rows: 200,
            click: Some((&click, &click)),
            elevation_cf: cf,
            windage_cf: 1.0,
            bias_mil,
        };
        let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");

        let row = &report.rows[1];
        let true_mil = curve.at_range(row.range).expect("row on the curve").drop_mil;

        let right_order = ((true_mil + bias_mil) / cf / 0.1).round() * 0.1;
        let wrong_order = (((true_mil / cf) + bias_mil) / 0.1).round() * 0.1;
        let dialed = row.drop_adj.expect("dial");

        assert!(
            (dialed - right_order).abs() < 1e-12,
            "printed {dialed} is not (true + bias) / cf quantized ({right_order})"
        );
        assert!(
            (right_order - wrong_order).abs() > 1e-9,
            "the chosen bias/CF make both orders agree; this test would not catch a swap"
        );

        // Windage carries its own CF and no bias, and is quantized on the same lattice.
        let true_wind = curve.at_range(row.range).expect("row on the curve").wind_mil;
        let expected_wind = (true_wind / 0.1).round() * 0.1;
        assert!((row.wind_adj.expect("dial") - expected_wind).abs() < 1e-12);
    }

    /// MOA cards are drawn on this crate's locked printed-table ratio (MBA-724), never on
    /// the exact-angle 3437.7467.
    #[test]
    fn moa_cards_use_the_locked_3438_ratio() {
        assert_eq!(CardAdjustmentUnit::Mil.from_mil_factor(), 1.0);
        assert_eq!(CardAdjustmentUnit::Moa.from_mil_factor(), 3438.0 / 1000.0);
        assert_ne!(
            CardAdjustmentUnit::Moa.from_mil_factor(),
            3437.7467 / 1000.0
        );

        let curve = test_curve(900.0);
        let req = plain_request((300.0, 600.0), 0.5, 200);
        let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Moa).expect("card should build");
        let row = &report.rows[0];
        let true_mil = curve.at_range(row.range).expect("row on the curve").drop_mil;
        assert_eq!(
            row.drop_adj.expect("dial").to_bits(),
            (true_mil * (3438.0 / 1000.0)).to_bits()
        );
    }

    /// Every rejection is structured and specific -- a range a shooter asked for and did
    /// not get back must come back as a reason, never as a silently shortened card.
    #[test]
    fn request_validation_reports_every_structured_error() {
        let curve = test_curve(900.0);
        let curve_max_m = curve.max_sampled_range_m();

        let mut req = plain_request((200.0, 800.0), 0.1, 0);
        assert_eq!(
            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
            CardError::ZeroMaxRows
        );

        req = plain_request((200.0, 800.0), 0.1, 50);
        req.budget.elevation = 0.0;
        assert_eq!(
            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
            CardError::NonPositiveBudget {
                axis: "elevation",
                value: 0.0
            }
        );
        req = plain_request((200.0, 800.0), 0.1, 50);
        req.budget.windage = f64::NAN;
        assert!(matches!(
            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
            Err(CardError::NonPositiveBudget { axis: "windage", .. })
        ));

        for domain in [(800.0, 200.0), (0.0, 500.0), (-10.0, 500.0), (300.0, 300.0)] {
            let req = plain_request(domain, 0.1, 50);
            assert!(
                matches!(
                    adaptive_card(&curve, &req, CardAdjustmentUnit::Mil),
                    Err(CardError::EmptyOrInvertedDomain { .. })
                ),
                "domain {domain:?} must be rejected"
            );
        }

        let req = plain_request((200.0, curve_max_m + 1.0), 0.1, 50);
        assert_eq!(
            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
            CardError::DomainOutsideCurve {
                requested_m: curve_max_m + 1.0,
                curve_max_m
            }
        );

        let mut req = plain_request((200.0, 800.0), 0.1, 50);
        req.anchors_m = vec![900.0];
        assert_eq!(
            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
            CardError::AnchorOutsideDomain {
                anchor_m: 900.0,
                start_m: 200.0,
                end_m: 800.0
            }
        );

        // Every variant renders as a sentence, so a CLI can print the reason verbatim.
        assert!(CardError::ZeroMaxRows.to_string().contains("at least one row"));
    }

    /// An out-of-band ELEVATION tracking CF is rejected with the exact variant and payload.
    ///
    /// `95.0` is the specific realistic slip this guards: a percentage typed where the ratio
    /// `0.95` belongs. Unvalidated it does not blow up -- it divides every printed value to
    /// ~0, so every measured error is ~0 and the card comes back `budget_met: true` while
    /// being entirely wrong. The assertion below therefore also pins that the request is
    /// refused rather than answered.
    #[test]
    fn out_of_band_elevation_tracking_cf_is_rejected() {
        let curve = test_curve(900.0);
        for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
            let mut req = plain_request((200.0, 800.0), 0.1, 50);
            req.elevation_cf = bad;
            let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
                .expect_err("an out-of-band elevation CF must be refused, not answered");
            match err {
                CardError::InvalidTrackingCf { axis, value } => {
                    assert_eq!(axis, "elevation");
                    // NaN never equals itself; compare bit patterns so the payload is pinned
                    // for every case including the non-finite ones.
                    assert_eq!(value.to_bits(), bad.to_bits(), "payload must echo the input");
                }
                other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
            }
        }
        // The band's interior is accepted, so the guard is not simply refusing everything.
        let mut req = plain_request((200.0, 800.0), 0.1, 50);
        req.elevation_cf = 0.95;
        assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());
    }

    /// Same for the WINDAGE axis -- a per-axis check, because one shared guard covering only
    /// the elevation field would pass an elevation-only test and still ship the bug.
    #[test]
    fn out_of_band_windage_tracking_cf_is_rejected() {
        let curve = test_curve(900.0);
        for bad in [95.0, 0.0, 0.5, 1.5, 2.0, f64::INFINITY, f64::NAN] {
            let mut req = plain_request((200.0, 800.0), 0.1, 50);
            req.windage_cf = bad;
            let err = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil)
                .expect_err("an out-of-band windage CF must be refused, not answered");
            match err {
                CardError::InvalidTrackingCf { axis, value } => {
                    assert_eq!(axis, "windage");
                    assert_eq!(value.to_bits(), bad.to_bits(), "payload must echo the input");
                }
                other => panic!("expected InvalidTrackingCf for {bad}, got {other:?}"),
            }
        }
        let mut req = plain_request((200.0, 800.0), 0.1, 50);
        req.windage_cf = 1.05;
        assert!(adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).is_ok());

        // Elevation is reported first when both axes are bad, so the message names one
        // concrete axis rather than a vague "a tracking factor".
        let mut req = plain_request((200.0, 800.0), 0.1, 50);
        req.elevation_cf = 95.0;
        req.windage_cf = 95.0;
        assert_eq!(
            adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).unwrap_err(),
            CardError::InvalidTrackingCf {
                axis: "elevation",
                value: 95.0
            }
        );
        // And it renders as a sentence that names the ratio-vs-percentage trap.
        let text = CardError::InvalidTrackingCf {
            axis: "elevation",
            value: 95.0,
        }
        .to_string();
        assert!(text.contains("elevation") && text.contains("0.5") && text.contains("1.5"), "{text}");
    }

    /// Task 12: `adaptive-card -o json` prints this report pretty-printed VERBATIM (no
    /// hand-rebuilt `json!` object, unlike the four Task 9 surfaces), so the derived
    /// `Serialize` impl IS the wire contract. Pins the field names a CLI/binding consumer
    /// would rely on, and that a row's `None` fields serialize as `null` (never dropped),
    /// matching `CardRow`'s existing "every surface populates only the fields it has"
    /// convention -- a `skip_serializing_if` would silently make an adaptive row's JSON
    /// shape depend on which fields happened to be absent.
    #[test]
    fn report_serializes_verbatim_with_stable_field_names() {
        let curve = test_curve(900.0);
        let req = plain_request((200.0, 400.0), 0.2, 50);
        let report = adaptive_card(&curve, &req, CardAdjustmentUnit::Mil).expect("card should build");

        let json = serde_json::to_value(&report).expect("report must serialize");
        assert_eq!(json["schema_version"], ADAPTIVE_CARD_SCHEMA_VERSION_V1);
        assert_eq!(json["method"], "greedy_worst_point_insertion_on_holdcurve_grid_v1");
        assert_eq!(json["assumptions"].as_array().expect("assumptions array").len(), 5);
        assert_eq!(json["budget_met"], report.budget_met);
        assert_eq!(json["rows_capped"], report.rows_capped);
        assert!(json.get("worst_elevation_error").is_some());
        assert!(json.get("worst_windage_error").is_some());
        assert!(json.get("worst_error_range_m").is_some());
        assert!(json.get("verification_grid_step_m").is_some());

        let rows = json["rows"].as_array().expect("rows array");
        assert_eq!(rows.len(), report.rows.len());
        let first = &rows[0];
        assert!(first["range"].is_number());
        assert!(first["drop_adj"].is_number(), "a populated Some(..) field must serialize as a number");
        // Fields every adaptive row leaves `None` serialize as explicit JSON null, not as an
        // absent key -- a consumer can tell "never populated by this engine" from "absent
        // because of a version skew" only if the key is always present.
        assert!(first["come_up"].is_null());
        assert!(first["lead_adj"].is_null());
        assert_eq!(first["wind_columns"], serde_json::json!([]));
    }

    /// Review fix I-4 (review of `4e69435`): the test above pinned only 5 of `CardRow`'s 11
    /// fields (`range`, `drop_adj`, `come_up`, `lead_adj`, `wind_columns`) -- `drop_linear`,
    /// `wind_linear`, `wind_adj`, `velocity`, `energy`, `time` were unpinned key names.
    /// `CardRow` is a *shared* internal type Task 9 itself created by unifying four other
    /// structs one task ago; a further rename during refactoring is a live possibility, and
    /// it would silently invalidate a wire published under `ADAPTIVE_CARD_SCHEMA_VERSION_V1`
    /// (and the 45-line worked JSON example in CLI_USAGE.md) with nothing here noticing.
    ///
    /// Every field gets its own pairwise-distinct sentinel (all `Some`, unlike the report-level
    /// test above which reads a real, physics-derived row) so a field-name<->value
    /// transposition -- not just a missing key -- fails this, and the row's key COUNT is
    /// pinned too, so a future field addition/removal is caught even if its name happens to
    /// collide with an existing sentinel value.
    #[test]
    fn card_row_field_names_bind_to_their_sentinel_values_in_json() {
        let row = CardRow {
            range: 111.1,
            drop_linear: Some(222.2),
            drop_adj: Some(333.3),
            come_up: Some(444.4),
            wind_linear: Some(555.5),
            wind_adj: Some(666.6),
            velocity: Some(777.7),
            energy: Some(888.8),
            time: Some(9.99),
            lead_adj: Some(101.1),
            wind_columns: vec![1.0, 2.0, 3.0],
        };
        let json = serde_json::to_value(&row).expect("row must serialize");

        assert_eq!(json["range"], 111.1);
        assert_eq!(json["drop_linear"], 222.2);
        assert_eq!(json["drop_adj"], 333.3);
        assert_eq!(json["come_up"], 444.4);
        assert_eq!(json["wind_linear"], 555.5);
        assert_eq!(json["wind_adj"], 666.6);
        assert_eq!(json["velocity"], 777.7);
        assert_eq!(json["energy"], 888.8);
        assert_eq!(json["time"], 9.99);
        assert_eq!(json["lead_adj"], 101.1);
        assert_eq!(json["wind_columns"], serde_json::json!([1.0, 2.0, 3.0]));

        // No extra keys, and none silently dropped: exactly CardRow's 11 fields.
        assert_eq!(
            json.as_object().expect("row must serialize to a JSON object").len(),
            11
        );
    }
}