1use crate::evidence::{
39 GaussianMixtureConfig, StackingConfig, StackingWeights, TopologyScoreScale,
40 UNION_STRUCTURE_LADDER, UnionStructure, UnionStructureFit, fit_gaussian_mixture,
41 fit_union_ladder, fit_union_structure, solve_stacking_weights, union_per_point_log_density,
42};
43use crate::priority_selection::{PriorityCandidate, rank_priority_candidates};
44use crate::row_sampling_measure::CoresetCertificate;
45use ndarray::{Array2, ArrayView2};
46use serde_json::Value as JsonValue;
47use statrs::distribution::{ChiSquared, ContinuousCDF};
48use std::sync::Mutex;
49use std::time::{Duration, Instant};
50
51const TK_LOG_2PI: f64 = 1.8378770664093453_f64;
52
53pub const MIXTURE_K_LADDER: &[usize] = &[1, 2, 3, 5, 7, 9];
58
59pub const STACKING_CV_FOLDS: usize = 5;
62
63pub const STACKING_CV_SEED: u64 = 11;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub enum AutoTopologyKind {
72 Euclidean,
73 Circle,
74 Sphere,
75 Torus,
76 Cylinder,
77 Mobius,
82 DuchonSheet,
89 ConstantCurvature,
99 Mixture {
103 k: usize,
104 },
105 Union {
112 structure: UnionStructure,
113 },
114}
115
116impl AutoTopologyKind {
117 pub const fn as_str(self) -> &'static str {
122 match self {
123 AutoTopologyKind::Euclidean => "euclidean",
124 AutoTopologyKind::Circle => "circle",
125 AutoTopologyKind::Sphere => "sphere",
126 AutoTopologyKind::Torus => "torus",
127 AutoTopologyKind::Cylinder => "cylinder",
128 AutoTopologyKind::Mobius => "mobius",
129 AutoTopologyKind::DuchonSheet => "duchon_sheet",
130 AutoTopologyKind::ConstantCurvature => "constant_curvature",
131 AutoTopologyKind::Mixture { .. } => "mixture",
132 AutoTopologyKind::Union { structure } => structure.as_str(),
133 }
134 }
135
136 pub fn display_name(self) -> String {
139 match self {
140 AutoTopologyKind::Mixture { k } => format!("mixture_k{k}"),
141 other => other.as_str().to_string(),
142 }
143 }
144
145 pub const fn is_discrete_mixture(self) -> bool {
148 matches!(self, AutoTopologyKind::Mixture { .. })
149 }
150
151 pub const fn is_structured_union(self) -> bool {
153 matches!(self, AutoTopologyKind::Union { .. })
154 }
155
156 pub const fn is_discrete_class(self) -> bool {
161 self.is_discrete_mixture() || self.is_structured_union()
162 }
163
164 pub fn parse(value: &str) -> Result<Self, String> {
165 let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
166 if let Some(structure) = parse_union_name(&normalized) {
167 return Ok(AutoTopologyKind::Union { structure });
168 }
169 if let Some(rest) = normalized.strip_prefix("mixture") {
170 let digits: String = rest.chars().filter(|c| c.is_ascii_digit()).collect();
172 if digits.is_empty() {
173 return Ok(AutoTopologyKind::Mixture {
177 k: *MIXTURE_K_LADDER.last().unwrap_or(&7),
178 });
179 }
180 let k: usize = digits
181 .parse()
182 .map_err(|_| format!("mixture order must be a positive integer; got {value:?}"))?;
183 if k == 0 {
184 return Err("mixture order k must be >= 1".to_string());
185 }
186 return Ok(AutoTopologyKind::Mixture { k });
187 }
188 match normalized.as_str() {
189 "euclidean" | "flat" | "euclidean_patch" | "euclideanpatch" => {
190 Ok(AutoTopologyKind::Euclidean)
191 }
192 "circle" | "periodic" | "s1" => Ok(AutoTopologyKind::Circle),
193 "sphere" | "s2" => Ok(AutoTopologyKind::Sphere),
194 "torus" => Ok(AutoTopologyKind::Torus),
195 "cylinder" => Ok(AutoTopologyKind::Cylinder),
196 "duchon" | "duchon_sheet" | "duchonsheet" | "thin_plate" | "thinplate" => {
197 Ok(AutoTopologyKind::DuchonSheet)
198 }
199 "constant_curvature" | "curv" | "curvature" | "mkappa" | "m_kappa" => {
200 Ok(AutoTopologyKind::ConstantCurvature)
201 }
202 other => Err(format!(
203 "topology candidate must be euclidean, circle, sphere, torus, cylinder, duchon_sheet, constant_curvature, mixture[_k{{n}}], or a union (union_circle+circle, union_circle+cluster, union_line+cluster); got {other:?}"
204 )),
205 }
206 }
207
208 pub fn all() -> Vec<Self> {
209 vec![
210 AutoTopologyKind::Euclidean,
211 AutoTopologyKind::Circle,
212 AutoTopologyKind::Sphere,
213 AutoTopologyKind::Torus,
214 AutoTopologyKind::Cylinder,
215 ]
216 }
217
218 pub const fn is_fixed_constant_curvature_form(self) -> bool {
230 matches!(self, AutoTopologyKind::Euclidean | AutoTopologyKind::Sphere)
231 }
232
233 pub fn fuse_constant_curvature_family(candidates: &[Self]) -> Vec<Self> {
249 let already_has_cc = candidates
250 .iter()
251 .any(|c| matches!(c, AutoTopologyKind::ConstantCurvature));
252 let fixed_form_count = candidates
253 .iter()
254 .filter(|c| c.is_fixed_constant_curvature_form())
255 .count();
256 let should_fuse = fixed_form_count >= 2 || (already_has_cc && fixed_form_count >= 1);
259 if !should_fuse {
260 return candidates.to_vec();
261 }
262 let mut out = Vec::with_capacity(candidates.len());
263 let mut emitted_cc = false;
264 for &c in candidates {
265 if c.is_fixed_constant_curvature_form() {
266 if !already_has_cc && !emitted_cc {
269 out.push(AutoTopologyKind::ConstantCurvature);
270 emitted_cc = true;
271 }
272 continue;
273 }
274 if matches!(c, AutoTopologyKind::ConstantCurvature) {
275 if emitted_cc {
276 continue; }
278 emitted_cc = true;
279 }
280 out.push(c);
281 }
282 out
283 }
284
285 pub fn mixture_ladder() -> Vec<Self> {
288 MIXTURE_K_LADDER
289 .iter()
290 .map(|&k| AutoTopologyKind::Mixture { k })
291 .collect()
292 }
293
294 pub fn union_ladder() -> Vec<Self> {
298 UNION_STRUCTURE_LADDER
299 .iter()
300 .map(|&structure| AutoTopologyKind::Union { structure })
301 .collect()
302 }
303}
304
305pub fn parse_union_name(normalized: &str) -> Option<UnionStructure> {
311 let Some(rest) = normalized.strip_prefix("union") else {
312 return None;
313 };
314 let body: String = rest
319 .chars()
320 .map(|c| if c == '_' || c == '-' { '+' } else { c })
321 .collect();
322 let body = body.trim_matches('+');
323 match body {
324 "circle+circle" => Some(UnionStructure::CircleCircle),
325 "circle+cluster" | "circle+point+cluster" | "circle+pointcluster" => {
326 Some(UnionStructure::CirclePointCluster)
327 }
328 "line+cluster" | "line+point+cluster" | "line+pointcluster" => {
329 Some(UnionStructure::LineCluster)
330 }
331 _ => None,
332 }
333}
334
335#[derive(Debug, Clone)]
336pub struct TopologyAutoSelector {
337 pub candidates: Vec<AutoTopologyKind>,
338 pub score_scale: TopologyScoreScale,
339 pub latent: Option<String>,
340}
341
342impl TopologyAutoSelector {
343 pub fn new(candidates: Option<Vec<AutoTopologyKind>>) -> Self {
344 Self {
345 candidates: candidates.unwrap_or_else(AutoTopologyKind::all),
346 score_scale: TopologyScoreScale::PerEffectiveDim,
347 latent: None,
348 }
349 }
350
351 pub fn from_json(value: &JsonValue) -> Result<Self, String> {
352 let obj = value
353 .as_object()
354 .ok_or_else(|| "topology_auto_selector must be an object".to_string())?;
355 let candidates = match obj.get("candidates").filter(|value| !value.is_null()) {
356 None => AutoTopologyKind::all(),
357 Some(raw) => {
358 let items = raw.as_array().ok_or_else(|| {
359 "topology_auto_selector.candidates must be a list".to_string()
360 })?;
361 if items.is_empty() {
362 return Err(
363 "topology_auto_selector.candidates must have at least one entry"
364 .to_string(),
365 );
366 }
367 let mut out = Vec::with_capacity(items.len());
368 for (idx, item) in items.iter().enumerate() {
369 let name = item.as_str().ok_or_else(|| {
370 format!("topology_auto_selector.candidates[{idx}] must be a string")
371 })?;
372 let kind = AutoTopologyKind::parse(name)?;
373 if out.contains(&kind) {
374 return Err(format!(
375 "topology_auto_selector duplicate candidate {:?}",
376 kind.as_str()
377 ));
378 }
379 out.push(kind);
380 }
381 out
382 }
383 };
384 let score_scale = match obj
385 .get("score_scale")
386 .and_then(JsonValue::as_str)
387 .unwrap_or("per_effective_dim")
388 .trim()
389 .to_ascii_lowercase()
390 .replace('-', "_")
391 .as_str()
392 {
393 "per_observation" => TopologyScoreScale::PerObservation,
394 "per_effective_dim" => TopologyScoreScale::PerEffectiveDim,
395 other => {
396 return Err(format!(
397 "topology_auto_selector.score_scale must be per_effective_dim or per_observation; got {other:?}"
398 ));
399 }
400 };
401 let latent = obj
402 .get("latent")
403 .filter(|value| !value.is_null())
404 .map(|value| {
405 value
406 .as_str()
407 .map(str::to_string)
408 .ok_or_else(|| "topology_auto_selector.latent must be a string".to_string())
409 })
410 .transpose()?;
411 Ok(Self {
412 candidates,
413 score_scale,
414 latent,
415 })
416 }
417}
418
419#[derive(Debug, Clone)]
420pub struct TopologyAutoFitEvidence<FitHandle> {
421 pub topology_name: String,
422 pub raw_reml: f64,
423 pub null_dim: f64,
424 pub null_space_logdet: Option<f64>,
425 pub effective_dim: f64,
426 pub n_obs: usize,
427 pub fit_handle: FitHandle,
428}
429
430#[derive(Debug, Clone)]
431pub struct TopologyAutoRankedFit<FitHandle> {
432 pub topology_name: String,
433 pub tk_score: f64,
434 pub raw_reml: f64,
435 pub effective_dim: f64,
436 pub n_obs: usize,
437 pub fit_handle: FitHandle,
438}
439
440#[derive(Debug, Clone)]
441pub struct TopologyAutoSelectorResult<FitHandle> {
442 pub ranked: Vec<TopologyAutoRankedFit<FitHandle>>,
443 pub winner_index: usize,
444 pub failed: Vec<TopologyAutoFailedCandidate>,
448}
449
450impl<FitHandle> TopologyAutoSelectorResult<FitHandle> {
451 pub fn winner(&self) -> Option<&TopologyAutoRankedFit<FitHandle>> {
452 self.ranked.get(self.winner_index)
453 }
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub enum TopologyCandidateFailureStage {
459 Assembly,
460 Fit,
461 Evidence,
462}
463
464impl TopologyCandidateFailureStage {
465 pub const fn as_str(self) -> &'static str {
466 match self {
467 Self::Assembly => "assembly",
468 Self::Fit => "fit",
469 Self::Evidence => "evidence",
470 }
471 }
472}
473
474#[derive(Debug, Clone)]
480pub struct TopologyAutoFailedCandidate {
481 pub candidate: AutoTopologyKind,
482 pub topology_name: String,
483 pub stage: TopologyCandidateFailureStage,
484 pub message: String,
485 pub evidence_at_failure: Option<f64>,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub enum TopologySelectionScoreKind {
491 Reml,
492 Laml,
493 Bic,
494 Tk,
495}
496
497impl TopologySelectionScoreKind {
498 pub const fn as_str(self) -> &'static str {
499 match self {
500 Self::Reml => "reml",
501 Self::Laml => "laml",
502 Self::Bic => "bic",
503 Self::Tk => "tk",
504 }
505 }
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum TopologySelectionScoreScale {
511 Raw,
512 PerObservation,
513 PerEffectiveDim,
514}
515
516impl TopologySelectionScoreScale {
517 pub const fn as_str(self) -> &'static str {
518 match self {
519 Self::Raw => "raw",
520 Self::PerObservation => "per_observation",
521 Self::PerEffectiveDim => "per_effective_dim",
522 }
523 }
524}
525
526#[derive(Debug, Clone)]
534pub struct TopologyCandidateEvidence {
535 pub name: String,
536 pub raw_reml: f64,
537 pub laml: Option<f64>,
538 pub deviance: Option<f64>,
539 pub null_dim: Option<f64>,
540 pub null_space_logdet: Option<f64>,
541 pub effective_dim: f64,
542 pub basis_size: usize,
543 pub n_obs: usize,
544}
545
546#[derive(Debug, Clone)]
548pub struct TopologyCandidateFailure {
549 pub name: String,
550 pub stage: TopologyCandidateFailureStage,
551 pub error_type: String,
552 pub message: String,
553 pub evidence_at_failure: Option<f64>,
554}
555
556#[derive(Debug, Clone)]
558pub enum TopologyCandidateOutcome {
559 Fitted(TopologyCandidateEvidence),
560 Failed(TopologyCandidateFailure),
561}
562
563impl TopologyCandidateOutcome {
564 fn name(&self) -> &str {
565 match self {
566 Self::Fitted(evidence) => &evidence.name,
567 Self::Failed(failure) => &failure.name,
568 }
569 }
570}
571
572#[derive(Debug, Clone)]
574pub struct TopologyCandidateRanked {
575 pub name: String,
576 pub score: f64,
577 pub raw_reml: f64,
578 pub effective_dim: f64,
579 pub basis_size: usize,
580 pub n_obs: usize,
581}
582
583#[derive(Debug, Clone)]
585pub struct TopologyCandidateSelectionResult {
586 pub ranked: Vec<TopologyCandidateRanked>,
587 pub winner_index: Option<usize>,
588 pub failed: Vec<TopologyCandidateFailure>,
589 pub warnings: Vec<String>,
590}
591
592fn failed_topology_summary(failed: &[TopologyAutoFailedCandidate]) -> String {
593 failed
594 .iter()
595 .map(|failure| {
596 format!(
597 "{} [{}]: {}",
598 failure.topology_name,
599 failure.stage.as_str(),
600 failure.message
601 )
602 })
603 .collect::<Vec<_>>()
604 .join("; ")
605}
606
607#[derive(Debug, Clone)]
609pub struct TopologyRaceParallelCandidate<FitResult> {
610 pub candidate_index: usize,
612 pub per_fit_threads: usize,
614 pub wall_time: Duration,
616 pub result: FitResult,
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622struct TopologyRaceThreadPlan {
623 coordinator_threads: usize,
624 per_fit_threads: usize,
625 concurrent_fits: usize,
626}
627
628impl TopologyRaceThreadPlan {
629 fn for_budget(candidate_count: usize, max_total_threads: usize) -> Self {
630 let max_total_threads = max_total_threads.max(1);
631 if candidate_count <= 1 {
632 return Self {
633 coordinator_threads: 0,
634 per_fit_threads: max_total_threads,
635 concurrent_fits: candidate_count,
636 };
637 }
638
639 let concurrent_fits = if max_total_threads >= 4 {
640 candidate_count.min(max_total_threads / 2).max(1)
641 } else {
642 1
643 };
644 let coordinator_threads = concurrent_fits;
645 let remaining = max_total_threads.saturating_sub(coordinator_threads);
646 let per_fit_threads = if remaining == 0 {
647 1
648 } else {
649 (remaining / concurrent_fits).max(1)
650 };
651 Self {
652 coordinator_threads,
653 per_fit_threads,
654 concurrent_fits,
655 }
656 }
657}
658
659pub fn run_topology_race_parallel<Candidate, FitResult, FitOne>(
673 candidates: Vec<Candidate>,
674 fit_one: FitOne,
675) -> Result<Vec<TopologyRaceParallelCandidate<FitResult>>, String>
676where
677 Candidate: Send,
678 FitResult: Send,
679 FitOne: Fn(Candidate) -> FitResult + Sync,
680{
681 let max_total_threads = std::thread::available_parallelism()
682 .map(std::num::NonZeroUsize::get)
683 .unwrap_or(1);
684 run_topology_race_parallel_with_budget(candidates, fit_one, max_total_threads)
685}
686
687fn run_topology_race_parallel_with_budget<Candidate, FitResult, FitOne>(
688 candidates: Vec<Candidate>,
689 fit_one: FitOne,
690 max_total_threads: usize,
691) -> Result<Vec<TopologyRaceParallelCandidate<FitResult>>, String>
692where
693 Candidate: Send,
694 FitResult: Send,
695 FitOne: Fn(Candidate) -> FitResult + Sync,
696{
697 let candidate_count = candidates.len();
698 if candidate_count == 0 {
699 return Ok(Vec::new());
700 }
701
702 let plan = TopologyRaceThreadPlan::for_budget(candidate_count, max_total_threads);
703 let mut candidates: Vec<Option<Candidate>> = candidates.into_iter().map(Some).collect();
704 let slots: Vec<Mutex<Option<TopologyRaceParallelCandidate<FitResult>>>> =
705 (0..candidate_count).map(|_| Mutex::new(None)).collect();
706 let pool_error: Mutex<Option<String>> = Mutex::new(None);
707
708 if plan.concurrent_fits <= 1 {
709 for idx in 0..candidate_count {
710 let candidate = candidates[idx]
711 .take()
712 .expect("topology race candidate must be present");
713 run_one_topology_race_candidate(
714 idx,
715 candidate,
716 &fit_one,
717 plan.per_fit_threads,
718 &slots[idx],
719 &pool_error,
720 );
721 if let Some(err) = pool_error.lock().expect("pool_error mutex poisoned").take() {
722 return Err(err);
723 }
724 }
725 } else {
726 let coordinator_pool = rayon::ThreadPoolBuilder::new()
727 .num_threads(plan.coordinator_threads)
728 .thread_name(|idx| format!("topology-race-coordinator-{idx}"))
729 .build()
730 .map_err(|err| format!("topology race coordinator Rayon pool: {err}"))?;
731 let mut batch_start = 0usize;
732 while batch_start < candidate_count {
733 let batch_end = (batch_start + plan.concurrent_fits).min(candidate_count);
734 coordinator_pool.scope(|scope| {
735 for idx in batch_start..batch_end {
736 let candidate = candidates[idx]
737 .take()
738 .expect("topology race candidate must be present");
739 let slot = &slots[idx];
740 let pool_error = &pool_error;
741 let fit_one = &fit_one;
742 scope.spawn(move |_| {
743 run_one_topology_race_candidate(
744 idx,
745 candidate,
746 fit_one,
747 plan.per_fit_threads,
748 slot,
749 pool_error,
750 );
751 });
752 }
753 });
754 if let Some(err) = pool_error.lock().expect("pool_error mutex poisoned").take() {
755 return Err(err);
756 }
757 batch_start = batch_end;
758 }
759 }
760
761 let mut out = Vec::with_capacity(candidate_count);
762 for (idx, slot) in slots.into_iter().enumerate() {
763 let row = slot
764 .into_inner()
765 .expect("topology race result mutex poisoned")
766 .ok_or_else(|| format!("topology race candidate {idx} did not produce a result"))?;
767 out.push(row);
768 }
769 Ok(out)
770}
771
772fn run_one_topology_race_candidate<Candidate, FitResult, FitOne>(
773 candidate_index: usize,
774 candidate: Candidate,
775 fit_one: &FitOne,
776 per_fit_threads: usize,
777 slot: &Mutex<Option<TopologyRaceParallelCandidate<FitResult>>>,
778 pool_error: &Mutex<Option<String>>,
779) where
780 Candidate: Send,
781 FitResult: Send,
782 FitOne: Fn(Candidate) -> FitResult + Sync,
783{
784 let pool = match rayon::ThreadPoolBuilder::new()
785 .num_threads(per_fit_threads)
786 .thread_name(move |idx| format!("topology-race-fit-{candidate_index}-{idx}"))
787 .build()
788 {
789 Ok(pool) => pool,
790 Err(err) => {
791 *pool_error.lock().expect("pool_error mutex poisoned") =
792 Some(format!("topology race candidate Rayon pool: {err}"));
793 return;
794 }
795 };
796
797 let started = Instant::now();
798 let result =
808 pool.install(|| gam_linalg::faer_ndarray::with_faer_sequential(|| fit_one(candidate)));
809 let wall_time = started.elapsed();
810 *slot.lock().expect("topology race result mutex poisoned") =
811 Some(TopologyRaceParallelCandidate {
812 candidate_index,
813 per_fit_threads,
814 wall_time,
815 result,
816 });
817}
818
819pub fn select_topology_with_fit<FitHandle, FitErr>(
820 selector: &TopologyAutoSelector,
821 mut fit_one: impl FnMut(AutoTopologyKind) -> Result<TopologyAutoFitEvidence<FitHandle>, FitErr>,
822) -> Result<TopologyAutoSelectorResult<FitHandle>, String>
823where
824 FitErr: ToString,
825{
826 let fused = AutoTopologyKind::fuse_constant_curvature_family(&selector.candidates);
830 let mut ranked = Vec::with_capacity(fused.len());
831 let mut failed = Vec::new();
832 for candidate in &fused {
833 match fit_one(*candidate) {
834 Ok(evidence) => {
835 let tk_score = match tk_normalized_score(
836 evidence.raw_reml,
837 evidence.null_dim,
838 evidence.null_space_logdet,
839 evidence.effective_dim,
840 evidence.n_obs,
841 selector.score_scale,
842 ) {
843 Ok(score) => score,
844 Err(message) => {
845 failed.push(TopologyAutoFailedCandidate {
846 candidate: *candidate,
847 topology_name: evidence.topology_name,
848 stage: TopologyCandidateFailureStage::Evidence,
849 message,
850 evidence_at_failure: evidence
851 .raw_reml
852 .is_finite()
853 .then_some(evidence.raw_reml),
854 });
855 continue;
856 }
857 };
858 ranked.push(TopologyAutoRankedFit {
859 topology_name: evidence.topology_name,
860 tk_score,
861 raw_reml: evidence.raw_reml,
862 effective_dim: evidence.effective_dim,
863 n_obs: evidence.n_obs,
864 fit_handle: evidence.fit_handle,
865 });
866 }
867 Err(err) => failed.push(TopologyAutoFailedCandidate {
868 candidate: *candidate,
869 topology_name: candidate.display_name(),
870 stage: TopologyCandidateFailureStage::Fit,
871 message: err.to_string(),
872 evidence_at_failure: None,
873 }),
874 }
875 }
876 if ranked.is_empty() {
877 return Err(format!(
878 "TopologyAutoSelector found no fittable topology candidates{}",
879 if failed.is_empty() {
880 String::new()
881 } else {
882 format!(" ({})", failed_topology_summary(&failed))
883 }
884 ));
885 }
886 ranked = rank_priority_candidates(
891 ranked
892 .into_iter()
893 .enumerate()
894 .map(|(idx, row)| {
895 let score = row.tk_score;
896 PriorityCandidate::new(row, idx, score, 0)
897 })
898 .collect(),
899 )
900 .into_iter()
901 .map(|row| row.item)
902 .collect();
903 Ok(TopologyAutoSelectorResult {
904 ranked,
905 winner_index: 0,
906 failed,
907 })
908}
909
910pub fn select_topology_with_fit_parallel<FitHandle, FitErr>(
923 selector: &TopologyAutoSelector,
924 fit_one: impl Fn(AutoTopologyKind) -> Result<TopologyAutoFitEvidence<FitHandle>, FitErr> + Sync,
925) -> Result<TopologyAutoSelectorResult<FitHandle>, String>
926where
927 FitHandle: Send,
928 FitErr: ToString + Send,
929{
930 let candidates: Vec<AutoTopologyKind> =
933 AutoTopologyKind::fuse_constant_curvature_family(&selector.candidates);
934 let race = run_topology_race_parallel(candidates, |candidate| {
935 (candidate, fit_one(candidate))
938 })?;
939
940 let mut ranked = Vec::with_capacity(race.len());
941 let mut failed = Vec::new();
942 for entry in race {
943 let (candidate, fit_result) = entry.result;
944 match fit_result {
945 Ok(evidence) => {
946 let tk_score = match tk_normalized_score(
947 evidence.raw_reml,
948 evidence.null_dim,
949 evidence.null_space_logdet,
950 evidence.effective_dim,
951 evidence.n_obs,
952 selector.score_scale,
953 ) {
954 Ok(score) => score,
955 Err(message) => {
956 failed.push(TopologyAutoFailedCandidate {
957 candidate,
958 topology_name: evidence.topology_name,
959 stage: TopologyCandidateFailureStage::Evidence,
960 message,
961 evidence_at_failure: evidence
962 .raw_reml
963 .is_finite()
964 .then_some(evidence.raw_reml),
965 });
966 continue;
967 }
968 };
969 ranked.push(TopologyAutoRankedFit {
970 topology_name: evidence.topology_name,
971 tk_score,
972 raw_reml: evidence.raw_reml,
973 effective_dim: evidence.effective_dim,
974 n_obs: evidence.n_obs,
975 fit_handle: evidence.fit_handle,
976 });
977 }
978 Err(err) => failed.push(TopologyAutoFailedCandidate {
979 candidate,
980 topology_name: candidate.display_name(),
981 stage: TopologyCandidateFailureStage::Fit,
982 message: err.to_string(),
983 evidence_at_failure: None,
984 }),
985 }
986 }
987 if ranked.is_empty() {
988 return Err(format!(
989 "TopologyAutoSelector found no fittable topology candidates{}",
990 if failed.is_empty() {
991 String::new()
992 } else {
993 format!(" ({})", failed_topology_summary(&failed))
994 }
995 ));
996 }
997 ranked = rank_priority_candidates(
1001 ranked
1002 .into_iter()
1003 .enumerate()
1004 .map(|(idx, row)| {
1005 let score = row.tk_score;
1006 PriorityCandidate::new(row, idx, score, 0)
1007 })
1008 .collect(),
1009 )
1010 .into_iter()
1011 .map(|row| row.item)
1012 .collect();
1013 Ok(TopologyAutoSelectorResult {
1014 ranked,
1015 winner_index: 0,
1016 failed,
1017 })
1018}
1019
1020pub fn tk_normalized_score(
1021 raw_reml: f64,
1022 null_dim: f64,
1023 null_space_logdet: Option<f64>,
1024 effective_dim: f64,
1025 n_obs: usize,
1026 score_scale: TopologyScoreScale,
1027) -> Result<f64, String> {
1028 let tk = raw_reml + topology_tk_normalizer(Some(null_dim), null_space_logdet)?;
1029 match score_scale {
1030 TopologyScoreScale::PerObservation => {
1031 if n_obs == 0 {
1032 Err("TopologyAutoSelector requires n_obs > 0".to_string())
1033 } else {
1034 Ok(tk / n_obs as f64)
1035 }
1036 }
1037 TopologyScoreScale::PerEffectiveDim => {
1038 if !(effective_dim.is_finite() && effective_dim > 0.0) {
1039 Err("TopologyAutoSelector requires finite positive effective_dim".to_string())
1040 } else {
1041 Ok(tk / effective_dim)
1042 }
1043 }
1044 }
1045}
1046
1047fn topology_tk_normalizer(
1048 null_dim: Option<f64>,
1049 null_space_logdet: Option<f64>,
1050) -> Result<f64, String> {
1051 let null_dim = null_dim.ok_or_else(|| {
1052 "topology evidence requires null-dimension metadata for TK normalization".to_string()
1053 })?;
1054 if !null_dim.is_finite() || null_dim < -1.0e-9 {
1055 return Err("topology evidence null dimension must be finite and non-negative".to_string());
1056 }
1057 if null_dim.max(0.0) == 0.0 {
1058 return Ok(0.0);
1059 }
1060 let logdet = null_space_logdet.ok_or_else(|| {
1061 "topology evidence TK normalizer requires null-space Hessian logdet".to_string()
1062 })?;
1063 if !logdet.is_finite() {
1064 return Err("topology evidence null-space Hessian logdet must be finite".to_string());
1065 }
1066 Ok(-0.5 * null_dim.max(0.0) * TK_LOG_2PI + 0.5 * logdet)
1067}
1068
1069fn topology_candidate_raw_score(
1070 evidence: &TopologyCandidateEvidence,
1071 score_kind: TopologySelectionScoreKind,
1072) -> Result<f64, String> {
1073 if !evidence.effective_dim.is_finite() {
1074 return Err(format!(
1075 "candidate {:?} has non-finite effective_dim {:?}",
1076 evidence.name, evidence.effective_dim
1077 ));
1078 }
1079 if evidence.n_obs == 0 {
1080 return Err(format!("candidate {:?} requires n_obs > 0", evidence.name));
1081 }
1082 if !evidence.raw_reml.is_finite() {
1083 return Err(format!(
1084 "candidate {:?} has non-finite REML evidence {:?}",
1085 evidence.name, evidence.raw_reml
1086 ));
1087 }
1088 match score_kind {
1089 TopologySelectionScoreKind::Reml => Ok(evidence.raw_reml),
1090 TopologySelectionScoreKind::Tk => Ok(evidence.raw_reml
1091 + topology_tk_normalizer(evidence.null_dim, evidence.null_space_logdet)?),
1092 TopologySelectionScoreKind::Laml => {
1093 let laml = evidence.laml.ok_or_else(|| {
1094 format!(
1095 "candidate {:?} is missing LAML evidence metadata",
1096 evidence.name
1097 )
1098 })?;
1099 if !laml.is_finite() {
1100 return Err(format!(
1101 "candidate {:?} has non-finite LAML evidence {laml:?}",
1102 evidence.name
1103 ));
1104 }
1105 Ok(laml + topology_tk_normalizer(evidence.null_dim, evidence.null_space_logdet)?)
1106 }
1107 TopologySelectionScoreKind::Bic => {
1108 let deviance = evidence.deviance.ok_or_else(|| {
1109 format!(
1110 "candidate {:?} is missing deviance metadata required for BIC",
1111 evidence.name
1112 )
1113 })?;
1114 bic_score(deviance, evidence.n_obs, evidence.basis_size)
1115 }
1116 }
1117}
1118
1119fn scale_topology_candidate_score(
1120 score: f64,
1121 scale: TopologySelectionScoreScale,
1122 evidence: &TopologyCandidateEvidence,
1123) -> Result<f64, String> {
1124 if !score.is_finite() {
1125 return Err(format!(
1126 "candidate {:?} has non-finite selected evidence {score:?}",
1127 evidence.name
1128 ));
1129 }
1130 match scale {
1131 TopologySelectionScoreScale::Raw => Ok(score),
1132 TopologySelectionScoreScale::PerObservation => {
1133 if evidence.n_obs == 0 {
1134 Err(format!(
1135 "candidate {:?} requires n_obs > 0 for per-observation scoring",
1136 evidence.name
1137 ))
1138 } else {
1139 Ok(score / evidence.n_obs as f64)
1140 }
1141 }
1142 TopologySelectionScoreScale::PerEffectiveDim => {
1143 if !(evidence.effective_dim.is_finite() && evidence.effective_dim > 0.0) {
1144 Err(format!(
1145 "candidate {:?} requires finite positive effective_dim for per-effective-dimension scoring; got {:?}",
1146 evidence.name, evidence.effective_dim
1147 ))
1148 } else {
1149 Ok(score / evidence.effective_dim)
1150 }
1151 }
1152 }
1153}
1154
1155fn topology_candidate_score(
1156 evidence: &TopologyCandidateEvidence,
1157 score_kind: TopologySelectionScoreKind,
1158 score_scale: TopologySelectionScoreScale,
1159) -> Result<f64, String> {
1160 let raw = topology_candidate_raw_score(evidence, score_kind)?;
1161 scale_topology_candidate_score(raw, score_scale, evidence)
1162}
1163
1164pub fn select_topology_candidate_lifecycle(
1173 outcomes: Vec<TopologyCandidateOutcome>,
1174 score_kind: TopologySelectionScoreKind,
1175 score_scale: TopologySelectionScoreScale,
1176) -> Result<TopologyCandidateSelectionResult, String> {
1177 if outcomes.is_empty() {
1178 return Err("topology selection requires at least one candidate outcome".to_string());
1179 }
1180 let mut names = std::collections::BTreeSet::new();
1181 for outcome in &outcomes {
1182 let name = outcome.name();
1183 if name.is_empty() {
1184 return Err("topology candidate names cannot be empty".to_string());
1185 }
1186 if !names.insert(name.to_string()) {
1187 return Err(format!("duplicate topology candidate {name:?}"));
1188 }
1189 }
1190
1191 let mut evidence_survivors = Vec::new();
1192 let mut ranked = Vec::new();
1193 let mut failed = Vec::new();
1194 for (candidate_index, outcome) in outcomes.into_iter().enumerate() {
1195 match outcome {
1196 TopologyCandidateOutcome::Failed(failure) => failed.push(failure),
1197 TopologyCandidateOutcome::Fitted(evidence) => {
1198 match topology_candidate_score(&evidence, score_kind, score_scale) {
1199 Ok(score) => {
1200 ranked.push(PriorityCandidate::new(
1201 TopologyCandidateRanked {
1202 name: evidence.name.clone(),
1203 score,
1204 raw_reml: evidence.raw_reml,
1205 effective_dim: evidence.effective_dim,
1206 basis_size: evidence.basis_size,
1207 n_obs: evidence.n_obs,
1208 },
1209 candidate_index,
1210 score,
1211 0,
1212 ));
1213 evidence_survivors.push(evidence);
1214 }
1215 Err(message) => failed.push(TopologyCandidateFailure {
1216 name: evidence.name,
1217 stage: TopologyCandidateFailureStage::Evidence,
1218 error_type: "gam_solve::topology_selector::EvidenceValidationError"
1219 .to_string(),
1220 message,
1221 evidence_at_failure: evidence
1222 .raw_reml
1223 .is_finite()
1224 .then_some(evidence.raw_reml),
1225 }),
1226 }
1227 }
1228 }
1229 }
1230 let ranked: Vec<TopologyCandidateRanked> = rank_priority_candidates(ranked)
1231 .into_iter()
1232 .map(|candidate| candidate.item)
1233 .collect();
1234 let warnings = topology_score_disagreement_warnings(&evidence_survivors, score_scale);
1235 Ok(TopologyCandidateSelectionResult {
1236 winner_index: (!ranked.is_empty()).then_some(0),
1237 ranked,
1238 failed,
1239 warnings,
1240 })
1241}
1242
1243fn topology_score_disagreement_warnings(
1244 evidence: &[TopologyCandidateEvidence],
1245 score_scale: TopologySelectionScoreScale,
1246) -> Vec<String> {
1247 let mut orders = Vec::new();
1248 for kind in [
1249 TopologySelectionScoreKind::Reml,
1250 TopologySelectionScoreKind::Laml,
1251 TopologySelectionScoreKind::Bic,
1252 ] {
1253 let scored: Result<Vec<_>, _> = evidence
1254 .iter()
1255 .enumerate()
1256 .map(|(index, row)| {
1257 topology_candidate_score(row, kind, score_scale)
1258 .map(|score| PriorityCandidate::new(row.name.clone(), index, score, 0))
1259 })
1260 .collect();
1261 let Ok(scored) = scored else {
1262 continue;
1263 };
1264 let order: Vec<String> = rank_priority_candidates(scored)
1265 .into_iter()
1266 .map(|row| row.item)
1267 .collect();
1268 orders.push((kind, order));
1269 }
1270 if orders.len() < 2 || orders.windows(2).all(|pair| pair[0].1 == pair[1].1) {
1271 return Vec::new();
1272 }
1273 let detail = orders
1274 .iter()
1275 .map(|(kind, order)| format!("{}: {}", kind.as_str(), order.join(", ")))
1276 .collect::<Vec<_>>()
1277 .join("; ");
1278 if score_scale == TopologySelectionScoreScale::Raw {
1279 vec![format!(
1280 "Topology score rankings differ across score kinds ({detail}). BIC and REML can disagree when candidate basis sizes differ wildly."
1281 )]
1282 } else {
1283 vec![format!(
1284 "Scaled topology score rankings still differ across score kinds under score_scale={:?} ({detail}). Treat BIC as a secondary diagnostic; the Tierney-Kadane Laplace normalizer handles the known cross-basis evidence scale issue.",
1285 score_scale.as_str()
1286 )]
1287 }
1288}
1289
1290pub fn bic_score(deviance: f64, n_obs: usize, basis_size: usize) -> Result<f64, String> {
1291 if n_obs <= 1 {
1292 return Err("BIC scoring requires at least two observations".to_string());
1293 }
1294 if !deviance.is_finite() {
1295 return Err("BIC scoring requires finite deviance".to_string());
1296 }
1297 Ok(deviance + (n_obs as f64).ln() * basis_size as f64)
1298}
1299
1300#[derive(Debug, Clone)]
1309pub struct MixtureRungFit {
1310 pub k: usize,
1311 pub fit: crate::evidence::GaussianMixtureFit,
1312 pub num_parameters: usize,
1315 pub negative_log_evidence: f64,
1317}
1318
1319#[derive(Debug, Clone)]
1322pub struct MixtureRungResult {
1323 pub fits: Vec<MixtureRungFit>,
1324 pub winner_index: usize,
1325}
1326
1327impl MixtureRungResult {
1328 pub fn winner(&self) -> &MixtureRungFit {
1329 &self.fits[self.winner_index]
1330 }
1331}
1332
1333pub const MIXTURE_REFINEMENT_MAX_PROBES: usize = 16;
1342
1343pub fn fit_mixture_rung(
1359 data: ArrayView2<'_, f64>,
1360 ladder: &[usize],
1361 config: GaussianMixtureConfig,
1362) -> Result<MixtureRungResult, String> {
1363 let n = data.nrows();
1364 let mut fits: Vec<MixtureRungFit> = Vec::new();
1365 let mut errors: Vec<String> = Vec::new();
1366 let mut attempted: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
1369
1370 let try_order = |k: usize,
1371 fits: &mut Vec<MixtureRungFit>,
1372 errors: &mut Vec<String>,
1373 attempted: &mut std::collections::BTreeSet<usize>| {
1374 if k == 0 || k > n || !attempted.insert(k) {
1375 return;
1376 }
1377 match fit_gaussian_mixture(data, k, config) {
1378 Ok(fit) => match fit.laplace_negative_log_evidence(data) {
1379 Ok(nle) => {
1380 let num_parameters = fit.num_free_parameters();
1381 fits.push(MixtureRungFit {
1382 k,
1383 fit,
1384 num_parameters,
1385 negative_log_evidence: nle,
1386 });
1387 }
1388 Err(e) => errors.push(format!("mixture k={k} evidence: {e}")),
1389 },
1390 Err(e) => errors.push(format!("mixture k={k} fit: {e}")),
1391 }
1392 };
1393
1394 for &k in ladder {
1395 try_order(k, &mut fits, &mut errors, &mut attempted);
1396 }
1397 if fits.is_empty() {
1398 return Err(format!(
1399 "mixture rung produced no fittable orders{}",
1400 if errors.is_empty() {
1401 String::new()
1402 } else {
1403 format!(" ({})", errors.join("; "))
1404 }
1405 ));
1406 }
1407
1408 let mut probes = 0usize;
1413 while probes < MIXTURE_REFINEMENT_MAX_PROBES {
1414 let best_k = fits
1415 .iter()
1416 .min_by(|a, b| {
1417 a.negative_log_evidence
1418 .partial_cmp(&b.negative_log_evidence)
1419 .unwrap_or(std::cmp::Ordering::Equal)
1420 .then(a.k.cmp(&b.k))
1421 })
1422 .map(|f| f.k)
1423 .unwrap_or(1);
1424 let next = [best_k.saturating_sub(1), best_k + 1]
1425 .into_iter()
1426 .find(|&k| k >= 1 && k <= n && !attempted.contains(&k));
1427 let Some(k) = next else {
1428 break; };
1430 try_order(k, &mut fits, &mut errors, &mut attempted);
1431 probes += 1;
1432 }
1433 let ranked = rank_priority_candidates(
1435 fits.into_iter()
1436 .enumerate()
1437 .map(|(idx, row)| {
1438 let score = row.negative_log_evidence;
1439 let tie = row.k; PriorityCandidate::new(row, idx, score, tie)
1441 })
1442 .collect(),
1443 )
1444 .into_iter()
1445 .map(|row| row.item)
1446 .collect::<Vec<_>>();
1447 Ok(MixtureRungResult {
1448 fits: ranked,
1449 winner_index: 0,
1450 })
1451}
1452
1453#[derive(Debug, Clone)]
1464pub struct UnionRungFit {
1465 pub structure: UnionStructure,
1466 pub fit: UnionStructureFit,
1467 pub total_parameters: usize,
1471 pub negative_log_evidence: f64,
1473}
1474
1475#[derive(Debug, Clone)]
1479pub struct UnionRungResult {
1480 pub fits: Vec<UnionRungFit>,
1481 pub winner_index: usize,
1482}
1483
1484impl UnionRungResult {
1485 pub fn winner(&self) -> &UnionRungFit {
1486 &self.fits[self.winner_index]
1487 }
1488}
1489
1490pub fn fit_union_rung(
1500 data: ArrayView2<'_, f64>,
1501 config: GaussianMixtureConfig,
1502) -> Result<UnionRungResult, String> {
1503 let ladder = fit_union_ladder(data, config)?;
1507 let fits: Vec<UnionRungFit> = ladder
1508 .into_iter()
1509 .map(|fit| UnionRungFit {
1510 structure: fit.structure,
1511 total_parameters: fit.total_parameters,
1512 negative_log_evidence: fit.negative_log_evidence,
1513 fit,
1514 })
1515 .collect();
1516 if fits.is_empty() {
1517 return Err("union rung produced no fittable composites".to_string());
1518 }
1519 Ok(UnionRungResult {
1520 fits,
1521 winner_index: 0,
1522 })
1523}
1524
1525pub fn fit_union_candidate(
1529 data: ArrayView2<'_, f64>,
1530 structure: UnionStructure,
1531 config: GaussianMixtureConfig,
1532) -> Result<UnionRungFit, String> {
1533 let fit = fit_union_structure(data, structure, config)?;
1534 Ok(UnionRungFit {
1535 structure: fit.structure,
1536 total_parameters: fit.total_parameters,
1537 negative_log_evidence: fit.negative_log_evidence,
1538 fit,
1539 })
1540}
1541
1542pub type HeldOutDensityProvider<'a> =
1552 Box<dyn Fn(&[usize], &[usize]) -> Result<Vec<f64>, String> + 'a>;
1553
1554pub fn mixture_density_provider<'a>(
1557 data: ArrayView2<'a, f64>,
1558 k: usize,
1559 config: GaussianMixtureConfig,
1560) -> HeldOutDensityProvider<'a> {
1561 let owned = data.to_owned();
1562 Box::new(
1563 move |train: &[usize], eval: &[usize]| -> Result<Vec<f64>, String> {
1564 let train_mat = gather_rows(owned.view(), train);
1565 let fit = fit_gaussian_mixture(train_mat.view(), k.min(train.len().max(1)), config)
1566 .map_err(|error| error.to_string())?;
1567 let eval_mat = gather_rows(owned.view(), eval);
1568 let dens = fit.per_point_log_density(eval_mat.view())?;
1569 Ok(dens.to_vec())
1570 },
1571 )
1572}
1573
1574pub fn union_density_provider<'a>(
1580 data: ArrayView2<'a, f64>,
1581 structure: UnionStructure,
1582 config: GaussianMixtureConfig,
1583) -> HeldOutDensityProvider<'a> {
1584 let owned = data.to_owned();
1585 Box::new(
1586 move |train: &[usize], eval: &[usize]| -> Result<Vec<f64>, String> {
1587 let train_mat = gather_rows(owned.view(), train);
1588 let eval_mat = gather_rows(owned.view(), eval);
1589 let dens =
1590 union_per_point_log_density(train_mat.view(), eval_mat.view(), structure, config)?;
1591 Ok(dens.to_vec())
1592 },
1593 )
1594}
1595
1596fn gather_rows(data: ArrayView2<'_, f64>, idx: &[usize]) -> Array2<f64> {
1597 let d = data.ncols();
1598 let mut out = Array2::<f64>::zeros((idx.len(), d));
1599 for (r, &i) in idx.iter().enumerate() {
1600 for c in 0..d {
1601 out[[r, c]] = data[[i, c]];
1602 }
1603 }
1604 out
1605}
1606
1607pub fn deterministic_cv_folds(n: usize, folds: usize) -> Vec<(Vec<usize>, Vec<usize>)> {
1613 deterministic_cv_folds_seeded(n, folds, STACKING_CV_SEED)
1614}
1615
1616#[inline]
1620fn splitmix64(mut x: u64) -> u64 {
1621 x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
1622 let mut z = x;
1623 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1624 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1625 z ^ (z >> 31)
1626}
1627
1628pub fn deterministic_cv_folds_seeded(
1638 n: usize,
1639 folds: usize,
1640 seed: u64,
1641) -> Vec<(Vec<usize>, Vec<usize>)> {
1642 let folds = folds.clamp(2, n.max(2));
1643 let assign: Vec<usize> = (0..n)
1645 .map(|i| {
1646 (splitmix64(seed ^ splitmix64(i as u64)) % folds as u64) as usize
1650 })
1651 .collect();
1652 let mut out = Vec::with_capacity(folds);
1653 for f in 0..folds {
1654 let mut train = Vec::new();
1655 let mut eval = Vec::new();
1656 for (i, &fold) in assign.iter().enumerate() {
1657 if fold == f {
1658 eval.push(i);
1659 } else {
1660 train.push(i);
1661 }
1662 }
1663 if !eval.is_empty() && !train.is_empty() {
1664 out.push((train, eval));
1665 }
1666 }
1667 out
1668}
1669
1670pub fn build_cv_log_density_table(
1677 n: usize,
1678 folds: usize,
1679 seed: u64,
1680 providers: &[HeldOutDensityProvider<'_>],
1681) -> Result<Array2<f64>, String> {
1682 if providers.is_empty() {
1683 return Err("stacking table requires at least one candidate provider".to_string());
1684 }
1685 let partition = deterministic_cv_folds_seeded(n, folds, seed);
1686 if partition.is_empty() {
1687 return Err("stacking CV partition is empty (n too small for folds)".to_string());
1688 }
1689 let mut table = Array2::<f64>::from_elem((n, providers.len()), f64::NEG_INFINITY);
1690 for (train, eval) in &partition {
1691 for (col, provider) in providers.iter().enumerate() {
1692 let dens = provider(train, eval)?;
1693 if dens.len() != eval.len() {
1694 return Err(format!(
1695 "provider {col} returned {} densities for {} eval rows",
1696 dens.len(),
1697 eval.len()
1698 ));
1699 }
1700 for (slot, &row) in eval.iter().enumerate() {
1701 table[[row, col]] = dens[slot];
1702 }
1703 }
1704 }
1705 Ok(table)
1706}
1707
1708#[derive(Debug, Clone)]
1714pub struct CrossClassRaceVerdict {
1715 pub candidate_names: Vec<String>,
1717 pub is_cross_class: bool,
1719 pub negative_log_evidence: Vec<f64>,
1722 pub stacking: Option<StackingWeights>,
1724 pub winner_index: usize,
1727 pub headline: Headline,
1729 pub insufficient_margin: Option<InsufficientRaceMargin>,
1735}
1736
1737#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1739pub enum Headline {
1740 Evidence,
1742 Stacking,
1744}
1745
1746#[derive(Clone, Copy, Debug, PartialEq)]
1764pub enum EvidenceCertification {
1765 Exact,
1766 Enclosure { gap: f64 },
1767 Coreset { certificate: CoresetCertificate },
1768}
1769
1770impl EvidenceCertification {
1771 pub fn required_margin(&self) -> f64 {
1775 match self {
1776 EvidenceCertification::Exact => 0.0,
1777 EvidenceCertification::Enclosure { gap } => *gap,
1778 EvidenceCertification::Coreset { certificate } => certificate.race_transfer_margin(),
1779 }
1780 }
1781
1782 pub fn race_verdict(&self, race_lead: f64) -> gam_problem::topology_certificates::Verdict {
1794 use gam_problem::topology_certificates::Verdict;
1795 if !(race_lead.is_finite() && race_lead > 0.0) {
1796 return Verdict::Insufficient;
1797 }
1798 match self {
1799 EvidenceCertification::Exact => Verdict::Certified,
1800 EvidenceCertification::Enclosure { gap } => {
1801 let enclosure = crate::logdet_bounds::LogdetEnclosure {
1802 block_diag_logdet: 0.0,
1803 lower: 0.0,
1804 upper: *gap,
1805 rho: 0.0,
1806 p2: 0.0,
1807 p3: None,
1808 };
1809 crate::inference::certificate_impls::enclosure_margin_verdict(&enclosure, race_lead)
1810 }
1811 EvidenceCertification::Coreset { certificate } => {
1812 crate::inference::certificate_impls::coreset_race_verdict(
1813 certificate.certify_margin(race_lead),
1814 )
1815 }
1816 }
1817 }
1818}
1819
1820pub struct CrossClassCandidate<'a> {
1825 pub kind: AutoTopologyKind,
1826 pub negative_log_evidence: f64,
1827 pub certification: EvidenceCertification,
1831 pub density_provider: HeldOutDensityProvider<'a>,
1832}
1833
1834impl<'a> CrossClassCandidate<'a> {
1835 pub fn exact(
1838 kind: AutoTopologyKind,
1839 negative_log_evidence: f64,
1840 density_provider: HeldOutDensityProvider<'a>,
1841 ) -> Self {
1842 Self {
1843 kind,
1844 negative_log_evidence,
1845 certification: EvidenceCertification::Exact,
1846 density_provider,
1847 }
1848 }
1849}
1850
1851#[derive(Clone, Copy, Debug, PartialEq)]
1857pub struct InsufficientRaceMargin {
1858 pub provisional_winner: usize,
1860 pub contender: usize,
1862 pub lead: f64,
1864 pub required_margin: f64,
1867}
1868
1869pub fn adjudicate_cross_class_race(
1883 n: usize,
1884 candidates: Vec<CrossClassCandidate<'_>>,
1885 folds: usize,
1886 seed: u64,
1887 stacking_config: StackingConfig,
1888) -> Result<CrossClassRaceVerdict, String> {
1889 if candidates.is_empty() {
1890 return Err("cross-class race requires at least one candidate".to_string());
1891 }
1892 let names: Vec<String> = candidates.iter().map(|c| c.kind.display_name()).collect();
1893 let evidence: Vec<f64> = candidates.iter().map(|c| c.negative_log_evidence).collect();
1894
1895 let has_discrete = candidates.iter().any(|c| c.kind.is_discrete_class());
1901 let has_smooth = candidates.iter().any(|c| !c.kind.is_discrete_class());
1902 let is_cross_class = has_discrete && has_smooth;
1903
1904 if !is_cross_class {
1905 let certifications: Vec<EvidenceCertification> =
1907 candidates.iter().map(|c| c.certification).collect();
1908 let mut winner_index = 0usize;
1909 let mut best = f64::INFINITY;
1910 for (idx, &nle) in evidence.iter().enumerate() {
1911 if nle.is_finite() && nle < best {
1912 best = nle;
1913 winner_index = idx;
1914 }
1915 }
1916 let mut insufficient_margin: Option<InsufficientRaceMargin> = None;
1925 for (idx, &nle) in evidence.iter().enumerate() {
1926 if idx == winner_index || !nle.is_finite() {
1927 continue;
1928 }
1929 let lead = nle - best;
1930 let required = certifications[winner_index]
1931 .required_margin()
1932 .max(certifications[idx].required_margin());
1933 if required > 0.0 && lead <= required {
1934 let tighter = insufficient_margin.map(|m| lead < m.lead).unwrap_or(true);
1935 if tighter {
1936 insufficient_margin = Some(InsufficientRaceMargin {
1937 provisional_winner: winner_index,
1938 contender: idx,
1939 lead,
1940 required_margin: required,
1941 });
1942 }
1943 }
1944 }
1945 return Ok(CrossClassRaceVerdict {
1946 candidate_names: names,
1947 is_cross_class: false,
1948 negative_log_evidence: evidence,
1949 stacking: None,
1950 winner_index,
1951 headline: Headline::Evidence,
1952 insufficient_margin,
1953 });
1954 }
1955
1956 let providers: Vec<HeldOutDensityProvider<'_>> =
1958 candidates.into_iter().map(|c| c.density_provider).collect();
1959 let table = build_cv_log_density_table(n, folds, seed, &providers)?;
1960 let stacking =
1961 solve_stacking_weights(table.view(), stacking_config).map_err(|error| error.to_string())?;
1962 let mut winner_index = 0usize;
1964 let mut best_w = f64::NEG_INFINITY;
1965 for (idx, &w) in stacking.weights.iter().enumerate() {
1966 if w > best_w {
1967 best_w = w;
1968 winner_index = idx;
1969 }
1970 }
1971 Ok(CrossClassRaceVerdict {
1972 candidate_names: names,
1973 is_cross_class: true,
1974 negative_log_evidence: evidence,
1975 stacking: Some(stacking),
1976 winner_index,
1977 headline: Headline::Stacking,
1978 insufficient_margin: None,
1982 })
1983}
1984
1985#[derive(Debug, Clone)]
1994pub struct ClosureProfilePoint<FitHandle> {
1995 pub gamma: f64,
1996 pub tk_score: f64,
1997 pub score_gradient: f64,
1998 pub score_curvature: f64,
1999 pub support_collapsed: bool,
2002 pub fit_handle: FitHandle,
2003}
2004
2005#[derive(Debug, Clone)]
2007pub struct ClosureProfileFit<FitHandle> {
2008 pub tk_score: f64,
2009 pub score_gradient: f64,
2010 pub score_curvature: f64,
2011 pub support_collapsed: bool,
2012 pub fit_handle: FitHandle,
2013}
2014
2015#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2017pub enum ClosureOptimumKind {
2018 Interior,
2019 IntervalBoundary,
2020 CircleBoundary,
2021}
2022
2023#[derive(Debug, Clone, Copy)]
2025pub struct ClosureStationarityCertificate {
2026 pub kind: ClosureOptimumKind,
2027 pub projected_gradient: f64,
2030 pub tolerance: f64,
2031 pub bracket: gam_math::score_opt::ClosedInterval,
2034 pub derivative_enclosure: gam_math::score_opt::DerivativeEnclosure,
2036}
2037
2038#[derive(Debug, Clone)]
2048pub struct ClosureSelection<FitHandle> {
2049 pub ci: gam_geometry::ClosureProfileCi,
2050 pub representative: ClosureProfilePoint<FitHandle>,
2051 pub stationarity: ClosureStationarityCertificate,
2052 pub route_to_mixture_rung: bool,
2056}
2057
2058fn closure_profile_ci_side<EvaluateScore>(
2059 evaluate_score: &EvaluateScore,
2060 gamma_hat: f64,
2061 target: f64,
2062 bound: f64,
2063 stationary_abscissae: &[f64],
2064 resolution: f64,
2065) -> Result<(f64, bool), String>
2066where
2067 EvaluateScore: Fn(f64) -> Result<f64, String>,
2068{
2069 let toward_lower = bound < gamma_hat;
2070 let mut probes: Vec<f64> = stationary_abscissae
2071 .iter()
2072 .copied()
2073 .filter(|&gamma| {
2074 if toward_lower {
2075 gamma < gamma_hat && gamma > bound
2076 } else {
2077 gamma > gamma_hat && gamma < bound
2078 }
2079 })
2080 .collect();
2081 probes.sort_by(f64::total_cmp);
2082 if toward_lower {
2083 probes.reverse();
2084 }
2085 probes.push(bound);
2086
2087 let mut inside = gamma_hat;
2088 for probe in probes {
2089 let value = evaluate_score(probe)?;
2090 if !value.is_finite() {
2091 return Err(format!(
2092 "closure profile CI produced non-finite evidence at γ={probe}"
2093 ));
2094 }
2095 let comparison_roundoff = f64::EPSILON * (1.0 + value.abs() + target.abs());
2096 if (value - target).abs() <= comparison_roundoff {
2097 return Ok((probe, probe == bound));
2098 }
2099 if value > target {
2100 let mut outside = probe;
2106 while (outside - inside).abs() > resolution {
2107 let midpoint = outside + 0.5 * (inside - outside);
2108 if midpoint == outside || midpoint == inside {
2109 break;
2110 }
2111 let midpoint_value = evaluate_score(midpoint)?;
2112 if !midpoint_value.is_finite() {
2113 return Err(format!(
2114 "closure profile CI produced non-finite evidence at γ={midpoint}"
2115 ));
2116 }
2117 if midpoint_value <= target {
2118 inside = midpoint;
2119 } else {
2120 outside = midpoint;
2121 }
2122 }
2123 return Ok((outside + 0.5 * (inside - outside), false));
2124 }
2125 inside = probe;
2126 }
2127 Ok((bound, true))
2128}
2129
2130pub fn profile_closure_within_smooth_class<FitHandle, FitAtGamma, EncloseDerivatives>(
2147 fit_at_gamma: FitAtGamma,
2148 enclose_derivatives: EncloseDerivatives,
2149 level: f64,
2150) -> Result<ClosureSelection<FitHandle>, String>
2151where
2152 FitAtGamma: Fn(f64) -> Result<ClosureProfileFit<FitHandle>, String>,
2153 EncloseDerivatives: Fn(f64, f64) -> Result<gam_math::score_opt::DerivativeEnclosure, String>,
2154{
2155 let gamma_tolerance = f64::EPSILON.sqrt();
2156 let evaluate = |gamma: f64| -> Result<ClosureProfilePoint<FitHandle>, String> {
2157 let fit = fit_at_gamma(gamma)?;
2158 let ClosureProfileFit {
2159 tk_score,
2160 score_gradient,
2161 score_curvature,
2162 support_collapsed,
2163 fit_handle,
2164 } = fit;
2165 if !(tk_score.is_finite() && score_gradient.is_finite() && score_curvature.is_finite()) {
2166 return Err(format!(
2167 "closure profile produced a non-finite score jet at γ={gamma}"
2168 ));
2169 }
2170 Ok(ClosureProfilePoint {
2171 gamma,
2172 tk_score,
2173 score_gradient,
2174 score_curvature,
2175 support_collapsed,
2176 fit_handle,
2177 })
2178 };
2179
2180 let mut score_oracle = |gamma: f64| {
2181 let point = evaluate(gamma)?;
2182 Ok::<_, String>(gam_math::score_opt::ScoreJet {
2183 value: -point.tk_score,
2184 derivative: -point.score_gradient,
2185 curvature: -point.score_curvature,
2186 })
2187 };
2188 let mut score_enclosure = |lo: f64, hi: f64| {
2189 let tk = enclose_derivatives(lo, hi)?;
2190 Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2191 derivative: gam_math::score_opt::ClosedInterval::outward(
2192 -tk.derivative.hi,
2193 -tk.derivative.lo,
2194 ),
2195 curvature: gam_math::score_opt::ClosedInterval::outward(
2196 -tk.curvature.hi,
2197 -tk.curvature.lo,
2198 ),
2199 })
2200 };
2201 let search = gam_math::score_opt::maximize_score_1d(
2202 0.0,
2203 1.0,
2204 gamma_tolerance,
2205 &mut score_oracle,
2206 &mut score_enclosure,
2207 )
2208 .map_err(|error| format!("closure profile: {error}"))?;
2209 let representative = evaluate(search.optimum.x)?;
2210 let gradient_scale = 1.0
2211 + search.lower_boundary.derivative.abs()
2212 + search.upper_boundary.derivative.abs()
2213 + representative.score_curvature.abs();
2214 let stationarity_tolerance = f64::EPSILON.sqrt() * gradient_scale;
2215 let (kind, projected_gradient) = match search.location {
2216 gam_math::score_opt::ScoreOptimumLocation::LowerBoundary => (
2217 ClosureOptimumKind::IntervalBoundary,
2218 (-representative.score_gradient).max(0.0),
2219 ),
2220 gam_math::score_opt::ScoreOptimumLocation::UpperBoundary => (
2221 ClosureOptimumKind::CircleBoundary,
2222 representative.score_gradient.max(0.0),
2223 ),
2224 gam_math::score_opt::ScoreOptimumLocation::Stationary(_) => (
2225 ClosureOptimumKind::Interior,
2226 representative.score_gradient.abs(),
2227 ),
2228 };
2229 if projected_gradient > stationarity_tolerance
2230 || (kind == ClosureOptimumKind::Interior && representative.score_curvature <= 0.0)
2231 {
2232 return Err(format!(
2233 "closure profile did not certify its continuous optimum: γ={}, projected \
2234 gradient={}, curvature={}, tolerance={}",
2235 representative.gamma,
2236 projected_gradient,
2237 representative.score_curvature,
2238 stationarity_tolerance
2239 ));
2240 }
2241 let bracket = match search.location {
2242 gam_math::score_opt::ScoreOptimumLocation::LowerBoundary
2243 | gam_math::score_opt::ScoreOptimumLocation::UpperBoundary => {
2244 gam_math::score_opt::ClosedInterval::point(representative.gamma)
2245 }
2246 gam_math::score_opt::ScoreOptimumLocation::Stationary(index) => {
2247 search
2248 .stationary_points
2249 .get(index)
2250 .ok_or_else(|| {
2251 "closure profile optimizer returned an invalid stationary index".to_string()
2252 })?
2253 .bracket
2254 }
2255 };
2256 let derivative_enclosure = enclose_derivatives(bracket.lo, bracket.hi)?;
2257 let stationarity = ClosureStationarityCertificate {
2258 kind,
2259 projected_gradient,
2260 tolerance: stationarity_tolerance,
2261 bracket,
2262 derivative_enclosure,
2263 };
2264
2265 if !(level.is_finite() && level > 0.0 && level < 1.0) {
2266 return Err("closure profile CI level must lie in (0, 1)".to_string());
2267 }
2268 let chi_squared = ChiSquared::new(1.0)
2269 .map_err(|error| format!("closure profile CI distribution: {error}"))?;
2270 let target = representative.tk_score + 0.5 * chi_squared.inverse_cdf(level);
2271 let stationary_abscissae: Vec<f64> = search
2272 .stationary_points
2273 .iter()
2274 .map(|stationary| stationary.sample.x)
2275 .collect();
2276 let evaluate_score = |gamma| evaluate(gamma).map(|point| point.tk_score);
2277 let (ci_lo, lo_at_bound) = if representative.gamma == 0.0 {
2278 (0.0, true)
2279 } else {
2280 closure_profile_ci_side(
2281 &evaluate_score,
2282 representative.gamma,
2283 target,
2284 0.0,
2285 &stationary_abscissae,
2286 gamma_tolerance,
2287 )?
2288 };
2289 let (ci_hi, hi_at_bound) = if representative.gamma == 1.0 {
2290 (1.0, true)
2291 } else {
2292 closure_profile_ci_side(
2293 &evaluate_score,
2294 representative.gamma,
2295 target,
2296 1.0,
2297 &stationary_abscissae,
2298 gamma_tolerance,
2299 )?
2300 };
2301 let singular_boundary = representative.support_collapsed;
2302 let ci = gam_geometry::ClosureProfileCi {
2303 gamma_hat: representative.gamma,
2304 ci_lo,
2305 ci_hi,
2306 ci_includes_circle: hi_at_bound,
2307 ci_includes_interval: lo_at_bound,
2308 singular_boundary,
2309 };
2310
2311 Ok(ClosureSelection {
2312 ci,
2313 representative,
2314 stationarity,
2315 route_to_mixture_rung: singular_boundary,
2316 })
2317}
2318
2319#[cfg(test)]
2320mod tests {
2321 use super::*;
2322 use rayon::iter::{IntoParallelIterator, ParallelIterator};
2323
2324 #[derive(Clone)]
2325 struct SyntheticRaceCandidate {
2326 seed: u64,
2327 len: usize,
2328 }
2329
2330 fn synthetic_fit(candidate: SyntheticRaceCandidate) -> Vec<u64> {
2331 (0..candidate.len)
2332 .into_par_iter()
2333 .map(|i| {
2334 let x = candidate.seed ^ (i as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15);
2335 x.rotate_left((i % 31) as u32)
2336 .wrapping_mul(0xbf58_476d_1ce4_e5b9)
2337 })
2338 .collect()
2339 }
2340
2341 #[test]
2342 fn topology_race_parallel_matches_sequential_synthetic_candidates() {
2343 let candidates = vec![
2344 SyntheticRaceCandidate { seed: 11, len: 64 },
2345 SyntheticRaceCandidate { seed: 29, len: 64 },
2346 SyntheticRaceCandidate { seed: 47, len: 64 },
2347 ];
2348 let sequential = candidates
2349 .iter()
2350 .cloned()
2351 .map(synthetic_fit)
2352 .collect::<Vec<_>>();
2353
2354 let parallel =
2355 run_topology_race_parallel_with_budget(candidates, synthetic_fit, 8).unwrap();
2356 assert_eq!(parallel.len(), 3);
2357 assert_eq!(
2358 parallel
2359 .iter()
2360 .map(|row| row.candidate_index)
2361 .collect::<Vec<_>>(),
2362 vec![0, 1, 2]
2363 );
2364 assert!(parallel.iter().all(|row| row.per_fit_threads == 1));
2365 let wall_times = parallel.iter().map(|row| row.wall_time).collect::<Vec<_>>();
2366 assert_eq!(wall_times.len(), 3);
2367 assert_eq!(
2368 parallel
2369 .into_iter()
2370 .map(|row| row.result)
2371 .collect::<Vec<_>>(),
2372 sequential
2373 );
2374 }
2375
2376 fn trivial_provider<'a>() -> HeldOutDensityProvider<'a> {
2377 Box::new(|_train: &[usize], eval: &[usize]| Ok(vec![0.0; eval.len()]))
2378 }
2379
2380 #[test]
2385 fn same_class_race_respects_enclosure_decision_margin() {
2386 let near = vec![
2389 CrossClassCandidate {
2390 kind: AutoTopologyKind::Circle,
2391 negative_log_evidence: 100.0,
2392 certification: EvidenceCertification::Enclosure { gap: 1.0 },
2393 density_provider: trivial_provider(),
2394 },
2395 CrossClassCandidate {
2396 kind: AutoTopologyKind::Euclidean,
2397 negative_log_evidence: 100.5,
2398 certification: EvidenceCertification::Enclosure { gap: 1.0 },
2399 density_provider: trivial_provider(),
2400 },
2401 ];
2402 let verdict = adjudicate_cross_class_race(
2403 8,
2404 near,
2405 STACKING_CV_FOLDS,
2406 STACKING_CV_SEED,
2407 StackingConfig::default(),
2408 )
2409 .expect("same-class race");
2410 assert!(!verdict.is_cross_class);
2411 assert_eq!(verdict.winner_index, 0);
2412 let escalation = verdict
2413 .insufficient_margin
2414 .expect("lead inside the enclosure gap must be flagged provisional");
2415 assert_eq!(escalation.provisional_winner, 0);
2416 assert_eq!(escalation.contender, 1);
2417 assert!((escalation.lead - 0.5).abs() < 1e-12);
2418 assert!((escalation.required_margin - 1.0).abs() < 1e-12);
2419
2420 let far = vec![
2422 CrossClassCandidate {
2423 kind: AutoTopologyKind::Circle,
2424 negative_log_evidence: 100.0,
2425 certification: EvidenceCertification::Enclosure { gap: 1.0 },
2426 density_provider: trivial_provider(),
2427 },
2428 CrossClassCandidate {
2429 kind: AutoTopologyKind::Euclidean,
2430 negative_log_evidence: 105.0,
2431 certification: EvidenceCertification::Enclosure { gap: 1.0 },
2432 density_provider: trivial_provider(),
2433 },
2434 ];
2435 let verdict_far = adjudicate_cross_class_race(
2436 8,
2437 far,
2438 STACKING_CV_FOLDS,
2439 STACKING_CV_SEED,
2440 StackingConfig::default(),
2441 )
2442 .expect("same-class race");
2443 assert_eq!(verdict_far.winner_index, 0);
2444 assert!(
2445 verdict_far.insufficient_margin.is_none(),
2446 "a lead clearing the enclosure gap must transfer the verdict"
2447 );
2448 }
2449
2450 #[test]
2453 fn same_class_race_respects_coreset_transfer_margin() {
2454 let cert = CoresetCertificate::new(0.05, 0.1, 32, 1000).expect("certificate");
2455 let required = cert.race_transfer_margin();
2456 let lead = 0.5 * required;
2458 let candidates = vec![
2459 CrossClassCandidate {
2460 kind: AutoTopologyKind::Circle,
2461 negative_log_evidence: 10.0,
2462 certification: EvidenceCertification::Coreset { certificate: cert },
2463 density_provider: trivial_provider(),
2464 },
2465 CrossClassCandidate {
2466 kind: AutoTopologyKind::Euclidean,
2467 negative_log_evidence: 10.0 + lead,
2468 certification: EvidenceCertification::Coreset { certificate: cert },
2469 density_provider: trivial_provider(),
2470 },
2471 ];
2472 let verdict = adjudicate_cross_class_race(
2473 8,
2474 candidates,
2475 STACKING_CV_FOLDS,
2476 STACKING_CV_SEED,
2477 StackingConfig::default(),
2478 )
2479 .expect("same-class race");
2480 let escalation = verdict
2481 .insufficient_margin
2482 .expect("lead inside the coreset transfer margin must be flagged");
2483 assert!((escalation.required_margin - required).abs() < 1e-9);
2484 }
2485
2486 #[test]
2494 fn cv_folds_are_seed_reproducible_and_seed_varying() {
2495 const N: usize = 40;
2496 const FOLDS: usize = 5;
2497
2498 fn fold_of_sample(n: usize, partition: &[(Vec<usize>, Vec<usize>)]) -> Vec<Option<usize>> {
2501 let mut assign = vec![None; n];
2502 for (fold, (_train, eval)) in partition.iter().enumerate() {
2503 for &i in eval {
2504 assign[i] = Some(fold);
2505 }
2506 }
2507 assign
2508 }
2509
2510 let a1 = deterministic_cv_folds_seeded(N, FOLDS, 11);
2512 let a2 = deterministic_cv_folds_seeded(N, FOLDS, 11);
2513 assert_eq!(
2514 fold_of_sample(N, &a1),
2515 fold_of_sample(N, &a2),
2516 "same seed must reproduce the identical CV folding"
2517 );
2518
2519 let b = deterministic_cv_folds_seeded(N, FOLDS, 12);
2522 assert_ne!(
2523 fold_of_sample(N, &a1),
2524 fold_of_sample(N, &b),
2525 "different seeds must produce different fold assignments (seed must \
2526 not be a no-op)"
2527 );
2528
2529 assert_eq!(
2532 fold_of_sample(N, &deterministic_cv_folds(N, FOLDS)),
2533 fold_of_sample(
2534 N,
2535 &deterministic_cv_folds_seeded(N, FOLDS, STACKING_CV_SEED)
2536 ),
2537 "deterministic_cv_folds must equal the default-seeded folding"
2538 );
2539 }
2540
2541 #[test]
2546 fn race_verdict_maps_onto_unified_ladder() {
2547 use gam_problem::topology_certificates::Verdict;
2548 assert_eq!(
2549 EvidenceCertification::Exact.race_verdict(1e-6),
2550 Verdict::Certified
2551 );
2552 assert_eq!(
2554 EvidenceCertification::Exact.race_verdict(0.0),
2555 Verdict::Insufficient
2556 );
2557 let enc = EvidenceCertification::Enclosure { gap: 0.2 };
2558 assert_eq!(enc.race_verdict(0.5), Verdict::Certified);
2559 assert_eq!(enc.race_verdict(0.1), Verdict::Insufficient);
2560 let cert = CoresetCertificate::new(0.05, 0.1, 32, 1000).expect("certificate");
2561 let required = cert.race_transfer_margin();
2562 let coreset = EvidenceCertification::Coreset { certificate: cert };
2563 assert_eq!(coreset.race_verdict(0.5 * required), Verdict::Insufficient);
2564 assert_eq!(
2565 coreset.race_verdict(2.0 * required + 1.0),
2566 Verdict::Certified
2567 );
2568 }
2569
2570 #[test]
2571 fn closure_profiler_recovers_interior_minimum_and_ci() {
2572 let selection = profile_closure_within_smooth_class(
2577 |gamma| {
2578 Ok::<_, String>(ClosureProfileFit {
2579 tk_score: 100.0 + 80.0 * (gamma - 0.7).powi(2),
2580 score_gradient: 160.0 * (gamma - 0.7),
2581 score_curvature: 160.0,
2582 support_collapsed: false,
2583 fit_handle: gamma,
2584 })
2585 },
2586 |lo, hi| {
2587 Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2588 derivative: gam_math::score_opt::ClosedInterval::outward(
2589 160.0 * (lo - 0.7),
2590 160.0 * (hi - 0.7),
2591 ),
2592 curvature: gam_math::score_opt::ClosedInterval::outward(160.0, 160.0),
2593 })
2594 },
2595 0.95,
2596 )
2597 .expect("closure profile");
2598 assert!(
2599 (selection.ci.gamma_hat - 0.7).abs() < 0.06,
2600 "γ̂ {}",
2601 selection.ci.gamma_hat
2602 );
2603 assert!(!selection.ci.ci_includes_circle);
2604 assert!(!selection.ci.ci_includes_interval);
2605 assert!(!selection.route_to_mixture_rung);
2606 assert_eq!(selection.stationarity.kind, ClosureOptimumKind::Interior);
2607 assert!(selection.stationarity.projected_gradient <= selection.stationarity.tolerance);
2608 assert!((selection.representative.gamma - selection.ci.gamma_hat).abs() < 1e-12);
2610 }
2611
2612 #[test]
2613 fn closure_profiler_routes_collapse_to_mixture_rung() {
2614 let selection = profile_closure_within_smooth_class(
2617 |gamma| {
2618 Ok::<_, String>(ClosureProfileFit {
2619 tk_score: 10.0 + 25.0 * gamma,
2620 score_gradient: 25.0,
2621 score_curvature: 0.0,
2622 support_collapsed: gamma == 0.0,
2623 fit_handle: gamma,
2624 })
2625 },
2626 |_lo, _hi| {
2627 Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2628 derivative: gam_math::score_opt::ClosedInterval::outward(25.0, 25.0),
2629 curvature: gam_math::score_opt::ClosedInterval::outward(0.0, 0.0),
2630 })
2631 },
2632 0.95,
2633 )
2634 .expect("closure profile");
2635 assert!(selection.ci.gamma_hat.abs() < 1e-9);
2636 assert!(selection.route_to_mixture_rung);
2637 assert!(selection.ci.ci_includes_interval);
2638 assert_eq!(
2639 selection.stationarity.kind,
2640 ClosureOptimumKind::IntervalBoundary
2641 );
2642 }
2643
2644 #[test]
2645 fn closure_profiler_does_not_infer_collapse_from_gamma_zero() {
2646 let selection = profile_closure_within_smooth_class(
2647 |gamma| {
2648 Ok::<_, String>(ClosureProfileFit {
2649 tk_score: 4.0 + gamma,
2650 score_gradient: 1.0,
2651 score_curvature: 0.0,
2652 support_collapsed: false,
2653 fit_handle: gamma,
2654 })
2655 },
2656 |_lo, _hi| {
2657 Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2658 derivative: gam_math::score_opt::ClosedInterval::outward(1.0, 1.0),
2659 curvature: gam_math::score_opt::ClosedInterval::outward(0.0, 0.0),
2660 })
2661 },
2662 0.95,
2663 )
2664 .expect("regular interval-boundary profile");
2665 assert_eq!(
2666 selection.stationarity.kind,
2667 ClosureOptimumKind::IntervalBoundary
2668 );
2669 assert!(!selection.ci.singular_boundary);
2670 assert!(!selection.route_to_mixture_rung);
2671 }
2672
2673 #[test]
2674 fn closure_profiler_selects_a_non_lattice_optimum_and_continuous_ci() {
2675 let planted = 0.713_271_828_f64;
2676 let calls = std::sync::atomic::AtomicUsize::new(0);
2677 let selection = profile_closure_within_smooth_class(
2678 |gamma| {
2679 calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2680 let displacement = gamma - planted;
2681 Ok::<_, String>(ClosureProfileFit {
2682 tk_score: 7.0 + 32.0 * displacement * displacement,
2683 score_gradient: 64.0 * displacement,
2684 score_curvature: 64.0,
2685 support_collapsed: false,
2686 fit_handle: gamma,
2687 })
2688 },
2689 |lo, hi| {
2690 Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2691 derivative: gam_math::score_opt::ClosedInterval::outward(
2692 64.0 * (lo - planted),
2693 64.0 * (hi - planted),
2694 ),
2695 curvature: gam_math::score_opt::ClosedInterval::outward(64.0, 64.0),
2696 })
2697 },
2698 0.95,
2699 )
2700 .expect("continuous closure profile");
2701 assert!((selection.representative.gamma - planted).abs() < 1.0e-7);
2702 assert!(selection.ci.ci_lo < planted && selection.ci.ci_hi > planted);
2703 assert_ne!(calls.load(std::sync::atomic::Ordering::Relaxed), 17);
2707 }
2708
2709 #[test]
2710 fn topology_race_thread_plan_bounds_nested_rayon_threads() {
2711 let plan = TopologyRaceThreadPlan::for_budget(3, 8);
2712 assert_eq!(plan.concurrent_fits, 3);
2713 assert!(
2714 plan.coordinator_threads + plan.concurrent_fits * plan.per_fit_threads <= 8,
2715 "plan must bound coordinator plus per-fit Rayon workers"
2716 );
2717
2718 let small = TopologyRaceThreadPlan::for_budget(3, 2);
2719 assert_eq!(small.concurrent_fits, 1);
2720 assert!(small.coordinator_threads + small.per_fit_threads <= 2);
2721 }
2722
2723 #[test]
2724 fn topology_selector_retains_failed_candidate_records() {
2725 let selector = TopologyAutoSelector::new(Some(vec![
2726 AutoTopologyKind::Circle,
2727 AutoTopologyKind::Torus,
2728 ]));
2729 let result = select_topology_with_fit(&selector, |kind| match kind {
2730 AutoTopologyKind::Circle => Err("inner REML stationarity failed".to_string()),
2731 AutoTopologyKind::Torus => Ok(TopologyAutoFitEvidence {
2732 topology_name: "torus".to_string(),
2733 raw_reml: 3.0,
2734 null_dim: 0.0,
2735 null_space_logdet: None,
2736 effective_dim: 2.0,
2737 n_obs: 40,
2738 fit_handle: (),
2739 }),
2740 _ => unreachable!(),
2741 })
2742 .expect("one converged candidate is selectable");
2743 assert_eq!(result.winner().unwrap().topology_name, "torus");
2744 assert_eq!(result.failed.len(), 1);
2745 assert_eq!(result.failed[0].topology_name, "circle");
2746 assert_eq!(result.failed[0].stage, TopologyCandidateFailureStage::Fit);
2747 assert!(result.failed[0].message.contains("stationarity"));
2748 }
2749
2750 fn lifecycle_evidence(
2751 name: &str,
2752 raw_reml: f64,
2753 laml: Option<f64>,
2754 deviance: Option<f64>,
2755 effective_dim: f64,
2756 ) -> TopologyCandidateOutcome {
2757 TopologyCandidateOutcome::Fitted(TopologyCandidateEvidence {
2758 name: name.to_string(),
2759 raw_reml,
2760 laml,
2761 deviance,
2762 null_dim: Some(0.0),
2763 null_space_logdet: None,
2764 effective_dim,
2765 basis_size: 4,
2766 n_obs: 20,
2767 })
2768 }
2769
2770 #[test]
2771 fn typed_lifecycle_owns_score_scaling_and_deterministic_winner() {
2772 let result = select_topology_candidate_lifecycle(
2773 vec![
2774 lifecycle_evidence("larger_raw", 5.0, Some(5.0), Some(6.0), 10.0),
2775 lifecycle_evidence("smaller_raw", 3.0, Some(3.0), Some(4.0), 2.0),
2776 ],
2777 TopologySelectionScoreKind::Reml,
2778 TopologySelectionScoreScale::PerEffectiveDim,
2779 )
2780 .expect("typed lifecycle");
2781 assert_eq!(result.winner_index, Some(0));
2782 assert_eq!(result.ranked[0].name, "larger_raw");
2783 assert!((result.ranked[0].score - 0.5).abs() < 1.0e-12);
2784 assert_eq!(result.ranked[1].name, "smaller_raw");
2785 assert!((result.ranked[1].score - 1.5).abs() < 1.0e-12);
2786 }
2787
2788 #[test]
2789 fn typed_lifecycle_converts_bad_evidence_without_losing_other_failures() {
2790 let result = select_topology_candidate_lifecycle(
2791 vec![
2792 TopologyCandidateOutcome::Failed(TopologyCandidateFailure {
2793 name: "assembly_bad".to_string(),
2794 stage: TopologyCandidateFailureStage::Assembly,
2795 error_type: "ValueError".to_string(),
2796 message: "dimension mismatch".to_string(),
2797 evidence_at_failure: None,
2798 }),
2799 lifecycle_evidence("evidence_bad", f64::NAN, None, None, 2.0),
2800 lifecycle_evidence("winner", 2.0, None, None, 2.0),
2801 ],
2802 TopologySelectionScoreKind::Reml,
2803 TopologySelectionScoreScale::Raw,
2804 )
2805 .expect("candidate-local evidence failure");
2806 assert_eq!(result.ranked.len(), 1);
2807 assert_eq!(result.ranked[0].name, "winner");
2808 assert_eq!(result.failed.len(), 2);
2809 assert_eq!(
2810 result.failed[0].stage,
2811 TopologyCandidateFailureStage::Assembly
2812 );
2813 assert_eq!(
2814 result.failed[1].stage,
2815 TopologyCandidateFailureStage::Evidence
2816 );
2817 assert!(result.failed[1].message.contains("non-finite REML"));
2818 }
2819
2820 #[test]
2821 fn typed_lifecycle_rejects_duplicate_terminal_outcomes() {
2822 let error = select_topology_candidate_lifecycle(
2823 vec![
2824 lifecycle_evidence("circle", 1.0, None, None, 1.0),
2825 lifecycle_evidence("circle", 2.0, None, None, 1.0),
2826 ],
2827 TopologySelectionScoreKind::Reml,
2828 TopologySelectionScoreScale::Raw,
2829 )
2830 .expect_err("duplicate candidate must be structural error");
2831 assert!(error.contains("duplicate topology candidate"));
2832 }
2833
2834 #[test]
2840 fn fuse_cc_family_collapses_euclidean_and_sphere() {
2841 let input = vec![
2842 AutoTopologyKind::Circle,
2843 AutoTopologyKind::Euclidean,
2844 AutoTopologyKind::Torus,
2845 AutoTopologyKind::Sphere,
2846 ];
2847 let fused = AutoTopologyKind::fuse_constant_curvature_family(&input);
2848 assert_eq!(
2849 fused,
2850 vec![
2851 AutoTopologyKind::Circle,
2852 AutoTopologyKind::ConstantCurvature, AutoTopologyKind::Torus,
2854 ],
2856 "fused candidates: {fused:?}"
2857 );
2858 }
2859
2860 #[test]
2862 fn fuse_cc_family_leaves_single_form_intact() {
2863 let euclidean_only = vec![AutoTopologyKind::Euclidean, AutoTopologyKind::Circle];
2864 let fused = AutoTopologyKind::fuse_constant_curvature_family(&euclidean_only);
2865 assert_eq!(fused, euclidean_only, "single fixed form must not be fused");
2866
2867 let sphere_only = vec![AutoTopologyKind::Sphere];
2868 let fused2 = AutoTopologyKind::fuse_constant_curvature_family(&sphere_only);
2869 assert_eq!(fused2, sphere_only);
2870 }
2871
2872 #[test]
2875 fn fuse_cc_family_explicit_cc_absorbs_fixed_forms() {
2876 let input = vec![
2877 AutoTopologyKind::ConstantCurvature,
2878 AutoTopologyKind::Euclidean,
2879 AutoTopologyKind::Circle,
2880 ];
2881 let fused = AutoTopologyKind::fuse_constant_curvature_family(&input);
2882 assert_eq!(
2883 fused,
2884 vec![
2885 AutoTopologyKind::ConstantCurvature,
2886 AutoTopologyKind::Circle
2887 ],
2888 "explicit CC must absorb the fixed Euclidean form"
2889 );
2890 }
2891
2892 #[test]
2894 fn fuse_cc_family_is_idempotent() {
2895 let input = vec![
2896 AutoTopologyKind::Circle,
2897 AutoTopologyKind::ConstantCurvature,
2898 AutoTopologyKind::Torus,
2899 ];
2900 let once = AutoTopologyKind::fuse_constant_curvature_family(&input);
2901 let twice = AutoTopologyKind::fuse_constant_curvature_family(&once);
2902 assert_eq!(once, twice, "fuse must be idempotent");
2903 assert_eq!(once, input, "already-fused list must be unchanged");
2904 }
2905
2906 #[test]
2908 fn fuse_cc_family_noop_for_non_cc_list() {
2909 let input = vec![
2910 AutoTopologyKind::Circle,
2911 AutoTopologyKind::Torus,
2912 AutoTopologyKind::Cylinder,
2913 ];
2914 let fused = AutoTopologyKind::fuse_constant_curvature_family(&input);
2915 assert_eq!(fused, input);
2916 }
2917}