Skip to main content

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