1use 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
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, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct TruingExperimentPlanRequestV1 {
35 pub model: TruingModelInputsV1,
37 pub candidate_ranges_yd: Vec<f64>,
42 pub observation_count: usize,
44 pub minimum_separation_yd: f64,
46 pub measurement_sigma_1sd: f64,
48 pub drop_unit: DropUnit,
50}
51
52#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub enum TruingPlanSearchStrategyV1 {
74 Exhaustive,
75 GreedyExchange,
76}
77
78#[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#[derive(Debug, Clone, PartialEq, Serialize)]
89pub struct RejectedTruingCandidateV1 {
90 pub input_index: usize,
92 pub range_yd: f64,
95 pub reason: TruingCandidateRejectionReasonV1,
96 pub detail: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize)]
101pub struct TruingPlanStationV1 {
102 pub input_index: usize,
104 pub range_yd: f64,
105 pub predicted_drop: f64,
107 pub scaled_mv_sensitivity: f64,
110 pub scaled_bc_sensitivity: f64,
113 pub leave_one_out_information_gain_nats: f64,
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize)]
122pub struct TruingPlanInformationV1 {
123 pub sensitivity_ratio: f64,
126 pub condition_number: Option<f64>,
129 pub minimum_singular_value: f64,
132 pub maximum_singular_value: f64,
133 pub weak_axis_fractional_sigma_1sd: Option<f64>,
137 pub log_determinant: Option<f64>,
140 pub expected_information_gain_nats: f64,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize)]
149pub struct TruingExperimentPlanV1 {
150 pub mode: TruingPlanModeV1,
151 pub selected_stations: Vec<TruingPlanStationV1>,
152 pub unselected_candidate_ranges_yd: Vec<f64>,
154 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 pub warnings: Vec<String>,
167}
168
169#[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#[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 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
224pub 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 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 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 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, ¤t, 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 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
722pub 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}