ballistics_engine/
truing_plan.rs

1//! Observation-range experiment design for joint muzzle-velocity/BC truing (MBA-1346).
2//!
3//! The planner evaluates the same trajectory forward model and finite-difference
4//! Jacobian as the truing fitter.  It therefore recommends *where to measure*,
5//! without inventing observations or mutating the supplied profile.  Candidate
6//! ranges that are malformed, duplicated, or unreachable are retained as
7//! diagnostics rather than silently disappearing.
8
9use std::error::Error;
10use std::fmt;
11
12use serde::Serialize;
13
14use crate::truing::{
15    truing_jacobian_rows, DropUnit, TruingJacobianRow, TruingModelInputsV1,
16    TRUING_MAX_CONDITION_NUMBER, TRUING_MIN_BC_SENSITIVITY_RATIO,
17};
18
19/// Maximum raw combination count for exact exhaustive design search.
20///
21/// Larger design spaces use a deterministic greedy construction followed by
22/// deterministic one-for-one exchanges.  The trajectory/Jacobian evaluation is
23/// cached per candidate, so neither strategy re-runs the forward model while
24/// comparing station sets.
25pub const TRUING_PLAN_EXHAUSTIVE_COMBINATION_LIMIT_V1: u64 = 100_000;
26
27const DUPLICATE_RANGE_TOLERANCE_YD: f64 = 1.0e-6;
28const SEPARATION_TOLERANCE_YD: f64 = 1.0e-9;
29const INFORMATION_EIGENVALUE_FLOOR: f64 = 1.0e-12;
30
31/// Input to the v1 range-design planner.
32#[derive(Debug, Clone)]
33pub struct TruingExperimentPlanRequestV1 {
34    /// Nominal scalar-BC load and atmospheric model.
35    pub model: TruingModelInputsV1,
36    /// Discrete ranges available to the shooter, in yards.
37    ///
38    /// Use [`discretize_truing_range_interval_v1`] when a facility is described
39    /// as an interval rather than as discrete target stations.
40    pub candidate_ranges_yd: Vec<f64>,
41    /// Exact number of stations the returned design must contain.
42    pub observation_count: usize,
43    /// Smallest permitted distance between any two selected stations, in yards.
44    pub minimum_separation_yd: f64,
45    /// One-standard-deviation measurement resolution, expressed in `drop_unit`.
46    pub measurement_sigma_1sd: f64,
47    /// Unit in which drop is measured and `measurement_sigma_1sd` is expressed.
48    pub drop_unit: DropUnit,
49}
50
51/// Whether the selected station set can locally separate MV and BC at the
52/// supplied nominal profile.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum TruingPlanModeV1 {
56    JointMvBc,
57    MvOnly,
58}
59
60impl fmt::Display for TruingPlanModeV1 {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.write_str(match self {
63            Self::JointMvBc => "joint_mv_bc",
64            Self::MvOnly => "mv_only",
65        })
66    }
67}
68
69/// Search strategy used for the discrete station-set optimization.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum TruingPlanSearchStrategyV1 {
73    Exhaustive,
74    GreedyExchange,
75}
76
77/// Why an input candidate could not participate in the experiment design.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
79#[serde(rename_all = "snake_case")]
80pub enum TruingCandidateRejectionReasonV1 {
81    InvalidRange,
82    DuplicateRange,
83    Unreachable,
84}
85
86/// A supplied candidate that was excluded before station-set optimization.
87#[derive(Debug, Clone, PartialEq, Serialize)]
88pub struct RejectedTruingCandidateV1 {
89    /// Zero-based position in `candidate_ranges_yd`.
90    pub input_index: usize,
91    /// Original value supplied by the caller.  It may be NaN or infinite, so
92    /// JSON renderers should sanitize it when `reason == invalid_range`.
93    pub range_yd: f64,
94    pub reason: TruingCandidateRejectionReasonV1,
95    pub detail: String,
96}
97
98/// Local sensitivity and information contribution for a selected target station.
99#[derive(Debug, Clone, PartialEq, Serialize)]
100pub struct TruingPlanStationV1 {
101    /// Zero-based position in the caller's candidate list.
102    pub input_index: usize,
103    pub range_yd: f64,
104    /// Nominal predicted drop, expressed in the plan's drop unit.
105    pub predicted_drop: f64,
106    /// Change in predicted drop, measured in observation sigmas, for a unit
107    /// fractional change in MV (`MV * d(drop)/d(MV) / sigma`).
108    pub scaled_mv_sensitivity: f64,
109    /// Change in predicted drop, measured in observation sigmas, for a unit
110    /// fractional change in BC (`BC * d(drop)/d(BC) / sigma`).
111    pub scaled_bc_sensitivity: f64,
112    /// Loss of local Gaussian information when this station is removed, in
113    /// nats: `0.5 * (log det(I + F_all) - log det(I + F_without))`.  `I` is an
114    /// explicit identity reference information matrix in fractional MV/BC
115    /// coordinates; this is a design score, not a fitted posterior claim.
116    pub leave_one_out_information_gain_nats: f64,
117}
118
119/// Information diagnostics for the selected design.
120#[derive(Debug, Clone, PartialEq, Serialize)]
121pub struct TruingPlanInformationV1 {
122    /// `||BC*d(drop)/d(BC)|| / ||MV*d(drop)/d(MV)||`, after weighting by
123    /// the declared observation sigma.
124    pub sensitivity_ratio: f64,
125    /// Condition number of the independently column-normalized MV/BC normal
126    /// matrix. `None` means one column is zero or the design is rank deficient.
127    pub condition_number: Option<f64>,
128    /// Smallest singular value of the observation-sigma-weighted fractional
129    /// Jacobian.  Its unit is inverse observation sigma (dimensionless here).
130    pub minimum_singular_value: f64,
131    pub maximum_singular_value: f64,
132    /// Local 1-sigma uncertainty along the weakest MV/BC fractional-parameter
133    /// direction (`1 / minimum_singular_value`).  It scales linearly with the
134    /// declared measurement sigma. `None` denotes a rank-deficient design.
135    pub weak_axis_fractional_sigma_1sd: Option<f64>,
136    /// Natural log of the unregularized 2x2 information determinant. `None`
137    /// denotes a rank-deficient design.
138    pub log_determinant: Option<f64>,
139    /// `0.5 * log det(I + F)`, a finite local-Gaussian information summary in
140    /// nats.  `I` is an identity reference information matrix in fractional
141    /// MV/BC coordinates (equivalently, unit reference covariance for those
142    /// fractional perturbations), not a hidden truing prior.
143    pub expected_information_gain_nats: f64,
144}
145
146/// Successful v1 experiment design.
147#[derive(Debug, Clone, PartialEq, Serialize)]
148pub struct TruingExperimentPlanV1 {
149    pub mode: TruingPlanModeV1,
150    pub selected_stations: Vec<TruingPlanStationV1>,
151    /// Reachable, unique candidates not selected by the optimizer.
152    pub unselected_candidate_ranges_yd: Vec<f64>,
153    /// Invalid, duplicated, and forward-model-unreachable input candidates.
154    pub rejected_candidates: Vec<RejectedTruingCandidateV1>,
155    pub information: TruingPlanInformationV1,
156    pub requested_observation_count: usize,
157    pub minimum_separation_yd: f64,
158    pub measurement_sigma_1sd: f64,
159    pub measurement_drop_unit: String,
160    pub eligible_candidate_count: usize,
161    pub raw_combination_count: u64,
162    pub evaluated_design_count: u64,
163    pub search_strategy: TruingPlanSearchStrategyV1,
164    /// Explicit limitations or actions needed before attempting a joint fit.
165    pub warnings: Vec<String>,
166}
167
168/// Stable categories for planner failures that occur before a design can be returned.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "snake_case")]
171pub enum TruingPlanErrorCodeV1 {
172    InvalidRequest,
173    InsufficientReachableCandidates,
174    NoFeasibleDesign,
175}
176
177/// Structured planner error.  Candidate diagnostics are retained when the
178/// request was valid but too few stations survived preprocessing.
179#[derive(Debug, Clone, PartialEq, Serialize)]
180pub struct TruingPlanErrorV1 {
181    pub code: TruingPlanErrorCodeV1,
182    pub message: String,
183    pub rejected_candidates: Vec<RejectedTruingCandidateV1>,
184}
185
186impl fmt::Display for TruingPlanErrorV1 {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        f.write_str(&self.message)
189    }
190}
191
192impl Error for TruingPlanErrorV1 {}
193
194impl TruingPlanErrorV1 {
195    fn invalid(message: impl Into<String>) -> Self {
196        Self {
197            code: TruingPlanErrorCodeV1::InvalidRequest,
198            message: message.into(),
199            rejected_candidates: Vec::new(),
200        }
201    }
202}
203
204/// Explicitly discretize an inclusive facility interval into available ranges.
205///
206/// The end point is included when it lies on the step grid (within floating
207/// point tolerance); it is not appended as an off-grid extra station.  This
208/// makes interval expansion reproducible and keeps every selected range on a
209/// declared station grid.
210pub fn discretize_truing_range_interval_v1(
211    start_yd: f64,
212    end_yd: f64,
213    step_yd: f64,
214) -> Result<Vec<f64>, TruingPlanErrorV1> {
215    if !start_yd.is_finite() || start_yd <= 0.0 {
216        return Err(TruingPlanErrorV1::invalid(
217            "range interval start must be positive and finite",
218        ));
219    }
220    if !end_yd.is_finite() || end_yd < start_yd {
221        return Err(TruingPlanErrorV1::invalid(
222            "range interval end must be finite and no smaller than its start",
223        ));
224    }
225    if !step_yd.is_finite() || step_yd <= 0.0 {
226        return Err(TruingPlanErrorV1::invalid(
227            "range interval step must be positive and finite",
228        ));
229    }
230
231    let step_count = ((end_yd - start_yd) / step_yd).floor();
232    if !step_count.is_finite() || step_count > 100_000.0 {
233        return Err(TruingPlanErrorV1::invalid(
234            "range interval expands to more than 100001 candidates",
235        ));
236    }
237    let count = step_count as usize + 1;
238    let mut ranges = Vec::with_capacity(count);
239    for i in 0..count {
240        let range = start_yd + i as f64 * step_yd;
241        if range <= end_yd + SEPARATION_TOLERANCE_YD {
242            ranges.push(range.min(end_yd));
243        }
244    }
245    Ok(ranges)
246}
247
248#[derive(Debug, Clone)]
249struct CandidateInformation {
250    input_index: usize,
251    range_yd: f64,
252    predicted_drop: f64,
253    scaled_mv: f64,
254    scaled_bc: f64,
255}
256
257#[derive(Debug, Clone, Copy)]
258struct InformationMetrics {
259    f00: f64,
260    f01: f64,
261    f11: f64,
262    sensitivity_ratio: f64,
263    condition_number: Option<f64>,
264    min_singular: f64,
265    max_singular: f64,
266    log_determinant: Option<f64>,
267    regularized_log_determinant: f64,
268    expected_information_gain_nats: f64,
269    joint_identifiable: bool,
270}
271
272impl InformationMetrics {
273    fn from_indices(candidates: &[CandidateInformation], indices: &[usize]) -> Self {
274        let mut f00 = 0.0;
275        let mut f01 = 0.0;
276        let mut f11 = 0.0;
277        for &index in indices {
278            let row = &candidates[index];
279            f00 += row.scaled_mv * row.scaled_mv;
280            f01 += row.scaled_mv * row.scaled_bc;
281            f11 += row.scaled_bc * row.scaled_bc;
282        }
283        Self::from_information_matrix(f00, f01, f11)
284    }
285
286    fn from_information_matrix(f00: f64, f01: f64, f11: f64) -> Self {
287        let trace = f00 + f11;
288        let discriminant = ((f00 - f11) * (f00 - f11) + 4.0 * f01 * f01).sqrt();
289        let lambda_max = ((trace + discriminant) * 0.5).max(0.0);
290        let determinant = f00 * f11 - f01 * f01;
291        // `trace - discriminant` loses nearly every significant bit for the
292        // weak axis of an ill-conditioned design.  For a positive-semidefinite
293        // 2x2 information matrix, det = lambda_max * lambda_min is the stable
294        // way to recover that eigenvalue.  A tiny negative determinant from
295        // accumulated roundoff represents the rank-one limit.
296        let lambda_min = if lambda_max > 0.0 {
297            (determinant / lambda_max).max(0.0)
298        } else {
299            0.0
300        };
301        let min_singular = lambda_min.sqrt();
302        let max_singular = lambda_max.sqrt();
303
304        let norm_mv = f00.max(0.0).sqrt();
305        let norm_bc = f11.max(0.0).sqrt();
306        let sensitivity_ratio = if norm_mv > 0.0 {
307            norm_bc / norm_mv
308        } else {
309            0.0
310        };
311        let condition_number = if norm_mv > 0.0 && norm_bc > 0.0 {
312            let correlation = (f01 / (norm_mv * norm_bc)).clamp(-1.0, 1.0).abs();
313            if 1.0 - correlation > 1.0e-15 {
314                Some((1.0 + correlation) / (1.0 - correlation))
315            } else {
316                None
317            }
318        } else {
319            None
320        };
321
322        let log_determinant =
323            (determinant > INFORMATION_EIGENVALUE_FLOOR).then(|| determinant.ln());
324        let regularized_determinant = (1.0 + f00) * (1.0 + f11) - f01 * f01;
325        let regularized_log_determinant = regularized_determinant.max(1.0).ln();
326        let expected_information_gain_nats = 0.5 * regularized_log_determinant;
327        let joint_identifiable = sensitivity_ratio >= TRUING_MIN_BC_SENSITIVITY_RATIO
328            && condition_number.is_some_and(|condition| condition <= TRUING_MAX_CONDITION_NUMBER)
329            && lambda_min > INFORMATION_EIGENVALUE_FLOOR;
330
331        Self {
332            f00,
333            f01,
334            f11,
335            sensitivity_ratio,
336            condition_number,
337            min_singular,
338            max_singular,
339            log_determinant,
340            regularized_log_determinant,
341            expected_information_gain_nats,
342            joint_identifiable,
343        }
344    }
345}
346
347#[derive(Debug, Clone)]
348struct ScoredDesign {
349    indices: Vec<usize>,
350    information: InformationMetrics,
351}
352
353fn design_is_better(
354    candidate: &ScoredDesign,
355    incumbent: &ScoredDesign,
356    candidates: &[CandidateInformation],
357) -> bool {
358    // An identifiable design always wins over an MV-only design.  Within the
359    // same class, use regularized D-optimality, then maximin singular value,
360    // then lower normalized condition, then lexicographically smaller ranges.
361    if candidate.information.joint_identifiable != incumbent.information.joint_identifiable {
362        return candidate.information.joint_identifiable;
363    }
364    let ordering = candidate
365        .information
366        .regularized_log_determinant
367        .total_cmp(&incumbent.information.regularized_log_determinant);
368    if !ordering.is_eq() {
369        return ordering.is_gt();
370    }
371    let ordering = candidate
372        .information
373        .min_singular
374        .total_cmp(&incumbent.information.min_singular);
375    if !ordering.is_eq() {
376        return ordering.is_gt();
377    }
378    match (
379        candidate.information.condition_number,
380        incumbent.information.condition_number,
381    ) {
382        (Some(a), Some(b)) if a.total_cmp(&b).is_ne() => return a < b,
383        (Some(_), None) => return true,
384        (None, Some(_)) => return false,
385        _ => {}
386    }
387
388    let mut candidate_ranges: Vec<f64> = candidate
389        .indices
390        .iter()
391        .map(|&i| candidates[i].range_yd)
392        .collect();
393    let mut incumbent_ranges: Vec<f64> = incumbent
394        .indices
395        .iter()
396        .map(|&i| candidates[i].range_yd)
397        .collect();
398    candidate_ranges.sort_by(f64::total_cmp);
399    incumbent_ranges.sort_by(f64::total_cmp);
400    candidate_ranges
401        .iter()
402        .zip(&incumbent_ranges)
403        .find_map(|(a, b)| {
404            let ordering = a.total_cmp(b);
405            (!ordering.is_eq()).then_some(ordering.is_lt())
406        })
407        .unwrap_or(false)
408}
409
410fn separated(
411    candidates: &[CandidateInformation],
412    indices: &[usize],
413    minimum_separation_yd: f64,
414) -> bool {
415    for left in 0..indices.len() {
416        for right in (left + 1)..indices.len() {
417            let distance =
418                (candidates[indices[left]].range_yd - candidates[indices[right]].range_yd).abs();
419            if distance + SEPARATION_TOLERANCE_YD < minimum_separation_yd {
420                return false;
421            }
422        }
423    }
424    true
425}
426
427fn maximum_compatible_count_with_fixed(
428    candidates: &[CandidateInformation],
429    fixed: &[usize],
430    minimum_separation_yd: f64,
431) -> usize {
432    if !separated(candidates, fixed, minimum_separation_yd) {
433        return 0;
434    }
435    let mut available: Vec<usize> = (0..candidates.len())
436        .filter(|index| !fixed.contains(index))
437        .filter(|index| {
438            fixed.iter().all(|fixed_index| {
439                (candidates[*index].range_yd - candidates[*fixed_index].range_yd).abs()
440                    + SEPARATION_TOLERANCE_YD
441                    >= minimum_separation_yd
442            })
443        })
444        .collect();
445    available.sort_by(|a, b| {
446        candidates[*a]
447            .range_yd
448            .total_cmp(&candidates[*b].range_yd)
449            .then_with(|| candidates[*a].input_index.cmp(&candidates[*b].input_index))
450    });
451
452    let mut count = fixed.len();
453    let mut last_selected_range: Option<f64> = None;
454    for index in available {
455        let range = candidates[index].range_yd;
456        if last_selected_range
457            .is_none_or(|last| range - last + SEPARATION_TOLERANCE_YD >= minimum_separation_yd)
458        {
459            last_selected_range = Some(range);
460            count += 1;
461        }
462    }
463    count
464}
465
466fn binomial_capped(n: usize, k: usize, cap: u64) -> u64 {
467    if k > n {
468        return 0;
469    }
470    let k = k.min(n - k);
471    let mut value: u128 = 1;
472    for i in 1..=k {
473        value = value * (n - k + i) as u128 / i as u128;
474        if value > cap as u128 {
475            return cap.saturating_add(1);
476        }
477    }
478    value as u64
479}
480
481fn exhaustive_design(
482    candidates: &[CandidateInformation],
483    observation_count: usize,
484    minimum_separation_yd: f64,
485) -> (Option<ScoredDesign>, u64) {
486    struct Search<'a> {
487        candidates: &'a [CandidateInformation],
488        target_count: usize,
489        minimum_separation_yd: f64,
490        evaluated: u64,
491        best: Option<ScoredDesign>,
492    }
493
494    impl Search<'_> {
495        fn visit(&mut self, start: usize, selected: &mut Vec<usize>) {
496            if selected.len() == self.target_count {
497                self.evaluated += 1;
498                let design = ScoredDesign {
499                    information: InformationMetrics::from_indices(self.candidates, selected),
500                    indices: selected.clone(),
501                };
502                if self
503                    .best
504                    .as_ref()
505                    .is_none_or(|best| design_is_better(&design, best, self.candidates))
506                {
507                    self.best = Some(design);
508                }
509                return;
510            }
511
512            let needed = self.target_count - selected.len();
513            if self.candidates.len().saturating_sub(start) < needed {
514                return;
515            }
516            for index in start..=self.candidates.len() - needed {
517                if selected.iter().all(|selected_index| {
518                    (self.candidates[index].range_yd - self.candidates[*selected_index].range_yd)
519                        .abs()
520                        + SEPARATION_TOLERANCE_YD
521                        >= self.minimum_separation_yd
522                }) {
523                    selected.push(index);
524                    self.visit(index + 1, selected);
525                    selected.pop();
526                }
527            }
528        }
529    }
530
531    let mut search = Search {
532        candidates,
533        target_count: observation_count,
534        minimum_separation_yd,
535        evaluated: 0,
536        best: None,
537    };
538    search.visit(0, &mut Vec::with_capacity(observation_count));
539    (search.best, search.evaluated)
540}
541
542fn greedy_exchange_design(
543    candidates: &[CandidateInformation],
544    observation_count: usize,
545    minimum_separation_yd: f64,
546) -> (Option<ScoredDesign>, u64) {
547    let mut selected = Vec::with_capacity(observation_count);
548    let mut evaluated = 0_u64;
549
550    while selected.len() < observation_count {
551        let mut best_addition: Option<ScoredDesign> = None;
552        for index in 0..candidates.len() {
553            if selected.contains(&index) {
554                continue;
555            }
556            let mut trial = selected.clone();
557            trial.push(index);
558            if !separated(candidates, &trial, minimum_separation_yd)
559                || maximum_compatible_count_with_fixed(candidates, &trial, minimum_separation_yd)
560                    < observation_count
561            {
562                continue;
563            }
564            evaluated += 1;
565            let design = ScoredDesign {
566                information: InformationMetrics::from_indices(candidates, &trial),
567                indices: trial,
568            };
569            if best_addition
570                .as_ref()
571                .is_none_or(|best| design_is_better(&design, best, candidates))
572            {
573                best_addition = Some(design);
574            }
575        }
576        let Some(best_addition) = best_addition else {
577            return (None, evaluated);
578        };
579        selected = best_addition.indices;
580    }
581
582    let mut current = ScoredDesign {
583        information: InformationMetrics::from_indices(candidates, &selected),
584        indices: selected,
585    };
586    // Each accepted exchange strictly improves a finite score tuple, so this
587    // terminates.  The cap is a defensive bound against pathological exact-tie
588    // cycles caused by future score changes.
589    for _ in 0..candidates.len().saturating_mul(observation_count).max(1) {
590        let mut best_exchange: Option<ScoredDesign> = None;
591        for selected_position in 0..current.indices.len() {
592            for replacement in 0..candidates.len() {
593                if current.indices.contains(&replacement) {
594                    continue;
595                }
596                let mut trial = current.indices.clone();
597                trial[selected_position] = replacement;
598                if !separated(candidates, &trial, minimum_separation_yd) {
599                    continue;
600                }
601                evaluated += 1;
602                let design = ScoredDesign {
603                    information: InformationMetrics::from_indices(candidates, &trial),
604                    indices: trial,
605                };
606                if design_is_better(&design, &current, candidates)
607                    && best_exchange
608                        .as_ref()
609                        .is_none_or(|best| design_is_better(&design, best, candidates))
610                {
611                    best_exchange = Some(design);
612                }
613            }
614        }
615        let Some(next) = best_exchange else {
616            break;
617        };
618        current = next;
619    }
620    (Some(current), evaluated)
621}
622
623fn optimize_design(
624    candidates: &[CandidateInformation],
625    observation_count: usize,
626    minimum_separation_yd: f64,
627    raw_combination_count: u64,
628) -> (TruingPlanSearchStrategyV1, Option<ScoredDesign>, u64) {
629    if raw_combination_count <= TRUING_PLAN_EXHAUSTIVE_COMBINATION_LIMIT_V1 {
630        let (design, evaluated) =
631            exhaustive_design(candidates, observation_count, minimum_separation_yd);
632        (TruingPlanSearchStrategyV1::Exhaustive, design, evaluated)
633    } else {
634        let (design, evaluated) =
635            greedy_exchange_design(candidates, observation_count, minimum_separation_yd);
636        (
637            TruingPlanSearchStrategyV1::GreedyExchange,
638            design,
639            evaluated,
640        )
641    }
642}
643
644fn selected_input_indices(
645    design: &ScoredDesign,
646    candidates: &[CandidateInformation],
647) -> Vec<usize> {
648    let mut indices: Vec<usize> = design
649        .indices
650        .iter()
651        .map(|&index| candidates[index].input_index)
652        .collect();
653    indices.sort_unstable();
654    indices
655}
656
657fn resolution_changes_design(
658    candidates: &[CandidateInformation],
659    baseline: &ScoredDesign,
660    observation_count: usize,
661    minimum_separation_yd: f64,
662    raw_combination_count: u64,
663    sigma_multiplier: f64,
664) -> bool {
665    // Sensitivities are divided by sigma, so changing sigma by `m` rescales
666    // every information row by `1/m`.  Re-running the discrete optimizer is
667    // cheap because the expensive trajectory/Jacobian values remain cached.
668    let mut rescaled = candidates.to_vec();
669    for candidate in &mut rescaled {
670        candidate.scaled_mv /= sigma_multiplier;
671        candidate.scaled_bc /= sigma_multiplier;
672    }
673    let (_, alternative, _) = optimize_design(
674        &rescaled,
675        observation_count,
676        minimum_separation_yd,
677        raw_combination_count,
678    );
679    alternative.is_some_and(|alternative| {
680        alternative.information.joint_identifiable != baseline.information.joint_identifiable
681            || selected_input_indices(&alternative, &rescaled)
682                != selected_input_indices(baseline, candidates)
683    })
684}
685
686fn scaled_candidate(
687    input_index: usize,
688    range_yd: f64,
689    row: TruingJacobianRow,
690    model: TruingModelInputsV1,
691    measurement_sigma_1sd: f64,
692) -> CandidateInformation {
693    CandidateInformation {
694        input_index,
695        range_yd,
696        predicted_drop: row.predicted_drop,
697        scaled_mv: model.muzzle_velocity_fps * row.d_drop_d_mv / measurement_sigma_1sd,
698        scaled_bc: model.ballistic_coefficient * row.d_drop_d_bc / measurement_sigma_1sd,
699    }
700}
701
702/// Recommend an exact-size set of observation ranges for MV/BC truing.
703///
704/// This function is deterministic for a fixed request.  Every expensive
705/// trajectory/Jacobian evaluation happens once per unique valid candidate.
706/// Short-range or otherwise collinear facilities return an explicit MV-only
707/// plan, not a falsely precise BC recommendation.
708pub fn plan_truing_experiment_v1(
709    request: &TruingExperimentPlanRequestV1,
710) -> Result<TruingExperimentPlanV1, TruingPlanErrorV1> {
711    request
712        .model
713        .validate()
714        .map_err(TruingPlanErrorV1::invalid)?;
715    if request.observation_count < 2 {
716        return Err(TruingPlanErrorV1::invalid(
717            "observation_count must be at least two for an MV/BC experiment design",
718        ));
719    }
720    if request.candidate_ranges_yd.is_empty() {
721        return Err(TruingPlanErrorV1::invalid(
722            "candidate_ranges_yd must not be empty",
723        ));
724    }
725    if !request.minimum_separation_yd.is_finite() || request.minimum_separation_yd < 0.0 {
726        return Err(TruingPlanErrorV1::invalid(
727            "minimum_separation_yd must be finite and non-negative",
728        ));
729    }
730    if !request.measurement_sigma_1sd.is_finite() || request.measurement_sigma_1sd <= 0.0 {
731        return Err(TruingPlanErrorV1::invalid(
732            "measurement_sigma_1sd must be positive and finite",
733        ));
734    }
735
736    let mut rejected = Vec::new();
737    let mut valid_inputs: Vec<(usize, f64)> = Vec::new();
738    for (input_index, &range_yd) in request.candidate_ranges_yd.iter().enumerate() {
739        if !range_yd.is_finite() || range_yd <= 0.0 {
740            rejected.push(RejectedTruingCandidateV1 {
741                input_index,
742                range_yd,
743                reason: TruingCandidateRejectionReasonV1::InvalidRange,
744                detail: "candidate range must be positive and finite".to_string(),
745            });
746            continue;
747        }
748        if let Some((duplicate_index, duplicate_range)) = valid_inputs
749            .iter()
750            .find(|(_, prior)| (range_yd - *prior).abs() < DUPLICATE_RANGE_TOLERANCE_YD)
751        {
752            rejected.push(RejectedTruingCandidateV1 {
753                input_index,
754                range_yd,
755                reason: TruingCandidateRejectionReasonV1::DuplicateRange,
756                detail: format!(
757                    "duplicates candidate #{duplicate_index} at {duplicate_range:.6} yd"
758                ),
759            });
760            continue;
761        }
762        valid_inputs.push((input_index, range_yd));
763    }
764    valid_inputs.sort_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
765
766    let ranges_yd: Vec<f64> = valid_inputs.iter().map(|(_, range)| *range).collect();
767    let batched_rows = request
768        .model
769        .with_forward_model(request.drop_unit, |model| {
770            truing_jacobian_rows(
771                model,
772                request.model.muzzle_velocity_fps,
773                request.model.ballistic_coefficient,
774                &ranges_yd,
775                request.drop_unit,
776            )
777        });
778    let mut candidates = Vec::with_capacity(valid_inputs.len());
779    match batched_rows {
780        Ok(rows) => {
781            for ((input_index, range_yd), row) in valid_inputs.into_iter().zip(rows) {
782                match row {
783                    Some(row)
784                        if row.predicted_drop.is_finite()
785                            && row.d_drop_d_mv.is_finite()
786                            && row.d_drop_d_bc.is_finite() =>
787                    {
788                        candidates.push(scaled_candidate(
789                            input_index,
790                            range_yd,
791                            row,
792                            request.model,
793                            request.measurement_sigma_1sd,
794                        ));
795                    }
796                    Some(_) => rejected.push(RejectedTruingCandidateV1 {
797                        input_index,
798                        range_yd,
799                        reason: TruingCandidateRejectionReasonV1::Unreachable,
800                        detail: "forward model returned a non-finite prediction or sensitivity"
801                            .to_string(),
802                    }),
803                    None => rejected.push(RejectedTruingCandidateV1 {
804                        input_index,
805                        range_yd,
806                        reason: TruingCandidateRejectionReasonV1::Unreachable,
807                        detail: "nominal or perturbed trajectory did not reach candidate range"
808                            .to_string(),
809                    }),
810                }
811            }
812        }
813        Err(error) => {
814            let detail = error.to_string();
815            for (input_index, range_yd) in valid_inputs {
816                rejected.push(RejectedTruingCandidateV1 {
817                    input_index,
818                    range_yd,
819                    reason: TruingCandidateRejectionReasonV1::Unreachable,
820                    detail: detail.clone(),
821                });
822            }
823        }
824    }
825    rejected.sort_by_key(|candidate| candidate.input_index);
826
827    if candidates.len() < request.observation_count {
828        return Err(TruingPlanErrorV1 {
829            code: TruingPlanErrorCodeV1::InsufficientReachableCandidates,
830            message: format!(
831                "requested {} observation stations but only {} unique reachable candidates remain",
832                request.observation_count,
833                candidates.len()
834            ),
835            rejected_candidates: rejected,
836        });
837    }
838    if maximum_compatible_count_with_fixed(&candidates, &[], request.minimum_separation_yd)
839        < request.observation_count
840    {
841        return Err(TruingPlanErrorV1 {
842            code: TruingPlanErrorCodeV1::NoFeasibleDesign,
843            message: format!(
844                "no {}-station design satisfies the {:.3} yd minimum separation",
845                request.observation_count, request.minimum_separation_yd
846            ),
847            rejected_candidates: rejected,
848        });
849    }
850
851    let raw_combination_count =
852        binomial_capped(candidates.len(), request.observation_count, u64::MAX - 1);
853    let (search_strategy, design, evaluated_design_count) = optimize_design(
854        &candidates,
855        request.observation_count,
856        request.minimum_separation_yd,
857        raw_combination_count,
858    );
859
860    let Some(mut design) = design else {
861        return Err(TruingPlanErrorV1 {
862            code: TruingPlanErrorCodeV1::NoFeasibleDesign,
863            message: format!(
864                "no {}-station design satisfies the {:.3} yd minimum separation",
865                request.observation_count, request.minimum_separation_yd
866            ),
867            rejected_candidates: rejected,
868        });
869    };
870    design.indices.sort_by(|a, b| {
871        candidates[*a]
872            .range_yd
873            .total_cmp(&candidates[*b].range_yd)
874            .then_with(|| candidates[*a].input_index.cmp(&candidates[*b].input_index))
875    });
876
877    let selected_input_indices: Vec<usize> = design
878        .indices
879        .iter()
880        .map(|&index| candidates[index].input_index)
881        .collect();
882    let selected_stations = design
883        .indices
884        .iter()
885        .map(|&index| {
886            let station = &candidates[index];
887            let without_f00 = design.information.f00 - station.scaled_mv * station.scaled_mv;
888            let without_f01 = design.information.f01 - station.scaled_mv * station.scaled_bc;
889            let without_f11 = design.information.f11 - station.scaled_bc * station.scaled_bc;
890            let without = InformationMetrics::from_information_matrix(
891                without_f00.max(0.0),
892                without_f01,
893                without_f11.max(0.0),
894            );
895            TruingPlanStationV1 {
896                input_index: station.input_index,
897                range_yd: station.range_yd,
898                predicted_drop: station.predicted_drop,
899                scaled_mv_sensitivity: station.scaled_mv,
900                scaled_bc_sensitivity: station.scaled_bc,
901                leave_one_out_information_gain_nats: (design
902                    .information
903                    .expected_information_gain_nats
904                    - without.expected_information_gain_nats)
905                    .max(0.0),
906            }
907        })
908        .collect();
909    let unselected_candidate_ranges_yd = candidates
910        .iter()
911        .filter(|candidate| !selected_input_indices.contains(&candidate.input_index))
912        .map(|candidate| candidate.range_yd)
913        .collect();
914
915    let mode = if design.information.joint_identifiable {
916        TruingPlanModeV1::JointMvBc
917    } else {
918        TruingPlanModeV1::MvOnly
919    };
920    let mut warnings = vec![format!(
921        "measurement model assumes independent 1-sigma {:.6} {} drop uncertainty at every station",
922        request.measurement_sigma_1sd,
923        request.drop_unit.label()
924    )];
925    warnings.push(
926        "information-gain values use an identity reference information matrix in fractional MV/BC coordinates; they are experiment-design scores, not posterior intervals or an undeclared fit prior"
927            .to_string(),
928    );
929    for sigma_multiplier in [0.5, 2.0] {
930        if resolution_changes_design(
931            &candidates,
932            &design,
933            request.observation_count,
934            request.minimum_separation_yd,
935            raw_combination_count,
936            sigma_multiplier,
937        ) {
938            warnings.push(format!(
939                "recommendation is sensitive to measurement resolution: at {sigma_multiplier:.1}x the declared sigma the optimizer changes the station set or joint/MV-only classification; rerun with that sigma before collecting data"
940            ));
941        }
942    }
943    if design.information.min_singular > INFORMATION_EIGENVALUE_FLOOR.sqrt() {
944        let weak_axis_sigma = 1.0 / design.information.min_singular;
945        warnings.push(format!(
946            "local weak-axis fractional 1-sigma is {weak_axis_sigma:.4}; it scales linearly with measurement resolution ({:.4} at 0.5x sigma, {:.4} at 2x sigma)",
947            weak_axis_sigma * 0.5,
948            weak_axis_sigma * 2.0
949        ));
950    } else {
951        warnings.push(
952            "local weak-axis fractional 1-sigma is unbounded at this measurement resolution"
953                .to_string(),
954        );
955    }
956    if mode == TruingPlanModeV1::MvOnly {
957        let condition = design
958            .information
959            .condition_number
960            .map(|value| format!("{value:.3e}"))
961            .unwrap_or_else(|| "unbounded".to_string());
962        warnings.push(format!(
963            "available ranges do not reliably separate MV from BC at the nominal profile (BC sensitivity ratio {:.4}, condition {condition}); use this as an MV-only design or add a longer-range station",
964            design.information.sensitivity_ratio
965        ));
966    } else if design.information.sensitivity_ratio < TRUING_MIN_BC_SENSITIVITY_RATIO * 1.5 {
967        warnings.push(format!(
968            "BC sensitivity ratio {:.4} is close to the {:.2} identifiability threshold; small profile or measurement changes may make the design MV-only",
969            design.information.sensitivity_ratio, TRUING_MIN_BC_SENSITIVITY_RATIO
970        ));
971    }
972
973    Ok(TruingExperimentPlanV1 {
974        mode,
975        selected_stations,
976        unselected_candidate_ranges_yd,
977        rejected_candidates: rejected,
978        information: TruingPlanInformationV1 {
979            sensitivity_ratio: design.information.sensitivity_ratio,
980            condition_number: design.information.condition_number,
981            minimum_singular_value: design.information.min_singular,
982            maximum_singular_value: design.information.max_singular,
983            weak_axis_fractional_sigma_1sd: (design.information.min_singular
984                > INFORMATION_EIGENVALUE_FLOOR.sqrt())
985            .then(|| 1.0 / design.information.min_singular),
986            log_determinant: design.information.log_determinant,
987            expected_information_gain_nats: design.information.expected_information_gain_nats,
988        },
989        requested_observation_count: request.observation_count,
990        minimum_separation_yd: request.minimum_separation_yd,
991        measurement_sigma_1sd: request.measurement_sigma_1sd,
992        measurement_drop_unit: request.drop_unit.label().to_string(),
993        eligible_candidate_count: candidates.len(),
994        raw_combination_count,
995        evaluated_design_count,
996        search_strategy,
997        warnings,
998    })
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004
1005    fn synthetic_candidates(count: usize) -> Vec<CandidateInformation> {
1006        (0..count)
1007            .map(|index| {
1008                let range = 100.0 + index as f64 * 50.0;
1009                CandidateInformation {
1010                    input_index: index,
1011                    range_yd: range,
1012                    predicted_drop: range / 100.0,
1013                    scaled_mv: 1.0 + index as f64 * 0.1,
1014                    scaled_bc: (index as f64 * 0.35).powi(2) + 0.05,
1015                }
1016            })
1017            .collect()
1018    }
1019
1020    #[test]
1021    fn interval_discretization_stays_on_grid() {
1022        assert_eq!(
1023            discretize_truing_range_interval_v1(100.0, 325.0, 100.0).unwrap(),
1024            vec![100.0, 200.0, 300.0]
1025        );
1026        assert_eq!(
1027            discretize_truing_range_interval_v1(100.0, 300.0, 100.0).unwrap(),
1028            vec![100.0, 200.0, 300.0]
1029        );
1030    }
1031
1032    #[test]
1033    fn exhaustive_search_honors_count_and_separation() {
1034        let candidates = synthetic_candidates(8);
1035        let (design, evaluated) = exhaustive_design(&candidates, 3, 150.0);
1036        let design = design.expect("feasible design");
1037        assert_eq!(design.indices.len(), 3);
1038        assert!(separated(&candidates, &design.indices, 150.0));
1039        assert!(evaluated > 0);
1040    }
1041
1042    #[test]
1043    fn large_space_uses_deterministic_feasible_greedy_exchange() {
1044        let candidates = synthetic_candidates(25);
1045        assert!(
1046            binomial_capped(
1047                candidates.len(),
1048                6,
1049                TRUING_PLAN_EXHAUSTIVE_COMBINATION_LIMIT_V1
1050            ) > TRUING_PLAN_EXHAUSTIVE_COMBINATION_LIMIT_V1
1051        );
1052        let (first, _) = greedy_exchange_design(&candidates, 6, 100.0);
1053        let (second, _) = greedy_exchange_design(&candidates, 6, 100.0);
1054        let first = first.expect("feasible design");
1055        let second = second.expect("feasible design");
1056        assert_eq!(first.indices, second.indices);
1057        assert_eq!(first.indices.len(), 6);
1058        assert!(separated(&candidates, &first.indices, 100.0));
1059    }
1060
1061    #[test]
1062    fn information_metrics_distinguish_collinear_rows() {
1063        let collinear = vec![
1064            CandidateInformation {
1065                input_index: 0,
1066                range_yd: 100.0,
1067                predicted_drop: 0.0,
1068                scaled_mv: 1.0,
1069                scaled_bc: 1.0,
1070            },
1071            CandidateInformation {
1072                input_index: 1,
1073                range_yd: 200.0,
1074                predicted_drop: 0.0,
1075                scaled_mv: 2.0,
1076                scaled_bc: 2.0,
1077            },
1078        ];
1079        let spread = vec![
1080            collinear[0].clone(),
1081            CandidateInformation {
1082                scaled_bc: 4.0,
1083                ..collinear[1].clone()
1084            },
1085        ];
1086        let weak = InformationMetrics::from_indices(&collinear, &[0, 1]);
1087        let strong = InformationMetrics::from_indices(&spread, &[0, 1]);
1088        assert!(weak.condition_number.is_none());
1089        assert_eq!(weak.min_singular, 0.0);
1090        assert!(strong.condition_number.is_some());
1091        assert!(strong.min_singular > weak.min_singular);
1092    }
1093}