1use 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
19pub 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#[derive(Debug, Clone)]
33pub struct TruingExperimentPlanRequestV1 {
34 pub model: TruingModelInputsV1,
36 pub candidate_ranges_yd: Vec<f64>,
41 pub observation_count: usize,
43 pub minimum_separation_yd: f64,
45 pub measurement_sigma_1sd: f64,
47 pub drop_unit: DropUnit,
49}
50
51#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum TruingPlanSearchStrategyV1 {
73 Exhaustive,
74 GreedyExchange,
75}
76
77#[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#[derive(Debug, Clone, PartialEq, Serialize)]
88pub struct RejectedTruingCandidateV1 {
89 pub input_index: usize,
91 pub range_yd: f64,
94 pub reason: TruingCandidateRejectionReasonV1,
95 pub detail: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize)]
100pub struct TruingPlanStationV1 {
101 pub input_index: usize,
103 pub range_yd: f64,
104 pub predicted_drop: f64,
106 pub scaled_mv_sensitivity: f64,
109 pub scaled_bc_sensitivity: f64,
112 pub leave_one_out_information_gain_nats: f64,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize)]
121pub struct TruingPlanInformationV1 {
122 pub sensitivity_ratio: f64,
125 pub condition_number: Option<f64>,
128 pub minimum_singular_value: f64,
131 pub maximum_singular_value: f64,
132 pub weak_axis_fractional_sigma_1sd: Option<f64>,
136 pub log_determinant: Option<f64>,
139 pub expected_information_gain_nats: f64,
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize)]
148pub struct TruingExperimentPlanV1 {
149 pub mode: TruingPlanModeV1,
150 pub selected_stations: Vec<TruingPlanStationV1>,
151 pub unselected_candidate_ranges_yd: Vec<f64>,
153 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 pub warnings: Vec<String>,
166}
167
168#[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#[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
204pub 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 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 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 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, ¤t, 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 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
702pub 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}