use crate::evidence::{
GaussianMixtureConfig, StackingConfig, StackingWeights, TopologyScoreScale, UnionStructure,
UnionStructureFit, fit_gaussian_mixture, fit_union_ladder, fit_union_structure,
solve_stacking_weights, union_per_point_log_density,
};
use crate::priority_selection::{PriorityCandidate, rank_priority_candidates};
use crate::row_sampling_measure::CoresetCertificate;
use ndarray::{Array2, ArrayView2};
use statrs::distribution::{ChiSquared, ContinuousCDF};
use std::sync::Mutex;
use std::time::{Duration, Instant};
const TK_LOG_2PI: f64 = 1.8378770664093453_f64;
pub const MIXTURE_K_LADDER: &[usize] = &[1, 2, 3, 5, 7, 9];
pub const STACKING_CV_FOLDS: usize = 5;
pub const STACKING_CV_SEED: u64 = 11;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AutoTopologyKind {
Euclidean,
Circle,
Sphere,
Torus,
Cylinder,
ProjectivePlane,
KleinBottle,
Mobius,
DuchonSheet,
ConstantCurvature,
Mixture {
k: usize,
},
RingOfClusters {
k: usize,
},
Union {
structure: UnionStructure,
},
}
impl AutoTopologyKind {
pub const fn family_tag(self) -> &'static str {
match self {
AutoTopologyKind::Euclidean => "euclidean",
AutoTopologyKind::Circle => "circle",
AutoTopologyKind::Sphere => "sphere",
AutoTopologyKind::Torus => "torus",
AutoTopologyKind::Cylinder => "cylinder",
AutoTopologyKind::ProjectivePlane => "projective_plane",
AutoTopologyKind::KleinBottle => "klein_bottle",
AutoTopologyKind::Mobius => "mobius",
AutoTopologyKind::DuchonSheet => "duchon_sheet",
AutoTopologyKind::ConstantCurvature => "constant_curvature",
AutoTopologyKind::Mixture { .. } => "mixture",
AutoTopologyKind::RingOfClusters { .. } => "ring_clusters",
AutoTopologyKind::Union { structure } => structure.as_str(),
}
}
pub fn display_name(self) -> String {
match self {
AutoTopologyKind::Mixture { k } => format!("mixture_k{k}"),
AutoTopologyKind::RingOfClusters { k } => format!("ring_clusters_k{k}"),
other => other.family_tag().to_string(),
}
}
pub const fn is_discrete_mixture(self) -> bool {
matches!(self, AutoTopologyKind::Mixture { .. })
}
pub const fn is_ring_of_clusters(self) -> bool {
matches!(self, AutoTopologyKind::RingOfClusters { .. })
}
pub const fn is_structured_union(self) -> bool {
matches!(self, AutoTopologyKind::Union { .. })
}
pub const fn is_discrete_class(self) -> bool {
self.is_discrete_mixture() || self.is_ring_of_clusters() || self.is_structured_union()
}
pub fn parse(value: &str) -> Result<Self, String> {
if let Some(rest) = value.strip_prefix("ring_clusters_k") {
if rest.is_empty() || !rest.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!(
"ring-of-clusters candidate must use ring_clusters_k{{n}}; got {value:?}"
));
}
let k: usize = rest
.parse()
.map_err(|_| format!("ring-of-clusters order is out of range; got {value:?}"))?;
if k < 3 {
return Err("ring-of-clusters order k must be >= 3".to_string());
}
if rest != k.to_string() {
return Err(format!(
"ring-of-clusters candidate must use canonical ring_clusters_k{{n}} without leading zeroes; got {value:?}"
));
}
return Ok(AutoTopologyKind::RingOfClusters { k });
}
if value.starts_with("ring_clusters") {
return Err(format!(
"ring-of-clusters candidate must use ring_clusters_k{{n}}; got {value:?}"
));
}
if let Some(rest) = value.strip_prefix("mixture_k") {
if rest.is_empty() || !rest.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!(
"mixture candidate must use mixture_k{{n}}; got {value:?}"
));
}
let k: usize = rest
.parse()
.map_err(|_| format!("mixture order is out of range; got {value:?}"))?;
if k == 0 {
return Err("mixture order k must be >= 1".to_string());
}
if rest != k.to_string() {
return Err(format!(
"mixture candidate must use canonical mixture_k{{n}} without leading zeroes; got {value:?}"
));
}
return Ok(AutoTopologyKind::Mixture { k });
}
if value.starts_with("mixture") {
return Err(format!(
"mixture candidate must use mixture_k{{n}}; got {value:?}"
));
}
if let Some(structure) = parse_union_name(value) {
return Ok(AutoTopologyKind::Union { structure });
}
match value {
"euclidean" => Ok(AutoTopologyKind::Euclidean),
"circle" => Ok(AutoTopologyKind::Circle),
"sphere" => Ok(AutoTopologyKind::Sphere),
"torus" => Ok(AutoTopologyKind::Torus),
"cylinder" => Ok(AutoTopologyKind::Cylinder),
"projective_plane" => Ok(AutoTopologyKind::ProjectivePlane),
"klein_bottle" => Ok(AutoTopologyKind::KleinBottle),
"mobius" => Ok(AutoTopologyKind::Mobius),
"duchon_sheet" => Ok(AutoTopologyKind::DuchonSheet),
"constant_curvature" => Ok(AutoTopologyKind::ConstantCurvature),
_ => Err(format!(
"topology candidate must be an exact canonical name: euclidean, circle, sphere, torus, cylinder, projective_plane, klein_bottle, mobius, duchon_sheet, constant_curvature, mixture_k{{n}}, ring_clusters_k{{n}}, union_circle+circle, union_circle+cluster, or union_line+cluster; got {value:?}"
)),
}
}
pub fn all() -> Vec<Self> {
vec![
AutoTopologyKind::Euclidean,
AutoTopologyKind::Circle,
AutoTopologyKind::Sphere,
AutoTopologyKind::Torus,
AutoTopologyKind::Cylinder,
AutoTopologyKind::ProjectivePlane,
AutoTopologyKind::KleinBottle,
]
}
pub const fn is_fixed_constant_curvature_form(self) -> bool {
matches!(self, AutoTopologyKind::Euclidean | AutoTopologyKind::Sphere)
}
pub fn fuse_constant_curvature_family(candidates: &[Self]) -> Vec<Self> {
let already_has_cc = candidates
.iter()
.any(|c| matches!(c, AutoTopologyKind::ConstantCurvature));
let fixed_form_count = candidates
.iter()
.filter(|c| c.is_fixed_constant_curvature_form())
.count();
let should_fuse = fixed_form_count >= 2 || (already_has_cc && fixed_form_count >= 1);
if !should_fuse {
return candidates.to_vec();
}
let mut out = Vec::with_capacity(candidates.len());
let mut emitted_cc = false;
for &c in candidates {
if c.is_fixed_constant_curvature_form() {
if !already_has_cc && !emitted_cc {
out.push(AutoTopologyKind::ConstantCurvature);
emitted_cc = true;
}
continue;
}
if matches!(c, AutoTopologyKind::ConstantCurvature) {
if emitted_cc {
continue; }
emitted_cc = true;
}
out.push(c);
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PredictiveCandidateKind {
Fixed(AutoTopologyKind),
MixtureClass,
RingOfClustersClass,
}
impl PredictiveCandidateKind {
pub fn display_name(self) -> String {
match self {
PredictiveCandidateKind::Fixed(kind) => kind.display_name(),
PredictiveCandidateKind::MixtureClass => "mixture_class".to_string(),
PredictiveCandidateKind::RingOfClustersClass => "ring_clusters_class".to_string(),
}
}
pub const fn family_tag(self) -> &'static str {
match self {
PredictiveCandidateKind::Fixed(kind) => kind.family_tag(),
PredictiveCandidateKind::MixtureClass => "mixture",
PredictiveCandidateKind::RingOfClustersClass => "ring_clusters",
}
}
pub const fn is_discrete_class(self) -> bool {
match self {
PredictiveCandidateKind::Fixed(kind) => kind.is_discrete_class(),
PredictiveCandidateKind::MixtureClass
| PredictiveCandidateKind::RingOfClustersClass => true,
}
}
pub const fn requires_predictive_stacking(self) -> bool {
matches!(
self,
PredictiveCandidateKind::MixtureClass | PredictiveCandidateKind::RingOfClustersClass
)
}
pub const fn is_circular(self) -> bool {
matches!(
self,
PredictiveCandidateKind::Fixed(AutoTopologyKind::Circle)
| PredictiveCandidateKind::Fixed(AutoTopologyKind::RingOfClusters { .. })
| PredictiveCandidateKind::RingOfClustersClass
)
}
}
pub fn parse_union_name(value: &str) -> Option<UnionStructure> {
match value {
"union_circle+circle" => Some(UnionStructure::CircleCircle),
"union_circle+cluster" => Some(UnionStructure::CirclePointCluster),
"union_line+cluster" => Some(UnionStructure::LineCluster),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct TopologyAutoSelector {
pub candidates: Vec<AutoTopologyKind>,
pub score_scale: TopologyScoreScale,
}
impl TopologyAutoSelector {
pub fn new(candidates: Option<Vec<AutoTopologyKind>>) -> Self {
Self {
candidates: candidates.unwrap_or_else(AutoTopologyKind::all),
score_scale: TopologyScoreScale::PerEffectiveDim,
}
}
}
#[derive(Debug, Clone)]
pub struct TopologyAutoFitEvidence<FitHandle> {
pub topology_name: String,
pub raw_reml: f64,
pub null_dim: f64,
pub null_space_logdet: Option<f64>,
pub effective_dim: f64,
pub n_obs: usize,
pub fit_handle: FitHandle,
}
#[derive(Debug, Clone)]
pub struct TopologyAutoRankedFit<FitHandle> {
pub topology_name: String,
pub tk_score: f64,
pub raw_reml: f64,
pub effective_dim: f64,
pub n_obs: usize,
pub fit_handle: FitHandle,
}
#[derive(Debug, Clone)]
pub struct TopologyAutoSelectorResult<FitHandle> {
pub ranked: Vec<TopologyAutoRankedFit<FitHandle>>,
pub winner_index: usize,
pub failed: Vec<TopologyAutoFailedCandidate>,
}
impl<FitHandle> TopologyAutoSelectorResult<FitHandle> {
pub fn winner(&self) -> Option<&TopologyAutoRankedFit<FitHandle>> {
self.ranked.get(self.winner_index)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TopologyCandidateFailureStage {
Assembly,
Fit,
Evidence,
}
impl TopologyCandidateFailureStage {
pub const fn as_str(self) -> &'static str {
match self {
Self::Assembly => "assembly",
Self::Fit => "fit",
Self::Evidence => "evidence",
}
}
}
#[derive(Debug, Clone)]
pub struct TopologyAutoFailedCandidate {
pub candidate: AutoTopologyKind,
pub topology_name: String,
pub stage: TopologyCandidateFailureStage,
pub message: String,
pub evidence_at_failure: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TopologySelectionScoreKind {
Reml,
Laml,
Bic,
Tk,
}
impl TopologySelectionScoreKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Reml => "reml",
Self::Laml => "laml",
Self::Bic => "bic",
Self::Tk => "tk",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TopologySelectionScoreScale {
Raw,
PerObservation,
PerEffectiveDim,
}
impl TopologySelectionScoreScale {
pub const fn as_str(self) -> &'static str {
match self {
Self::Raw => "raw",
Self::PerObservation => "per_observation",
Self::PerEffectiveDim => "per_effective_dim",
}
}
}
#[derive(Debug, Clone)]
pub struct TopologyCandidateEvidence {
pub name: String,
pub raw_reml: f64,
pub laml: Option<f64>,
pub deviance: Option<f64>,
pub null_dim: Option<f64>,
pub null_space_logdet: Option<f64>,
pub effective_dim: f64,
pub basis_size: usize,
pub n_obs: usize,
}
#[derive(Debug, Clone)]
pub struct TopologyCandidateFailure {
pub name: String,
pub stage: TopologyCandidateFailureStage,
pub error_type: String,
pub message: String,
pub evidence_at_failure: Option<f64>,
}
#[derive(Debug, Clone)]
pub enum TopologyCandidateOutcome {
Fitted(TopologyCandidateEvidence),
Failed(TopologyCandidateFailure),
}
impl TopologyCandidateOutcome {
fn name(&self) -> &str {
match self {
Self::Fitted(evidence) => &evidence.name,
Self::Failed(failure) => &failure.name,
}
}
}
#[derive(Debug, Clone)]
pub struct TopologyCandidateRanked {
pub name: String,
pub score: f64,
pub raw_reml: f64,
pub effective_dim: f64,
pub basis_size: usize,
pub n_obs: usize,
}
#[derive(Debug, Clone)]
pub struct TopologyCandidateSelectionResult {
pub ranked: Vec<TopologyCandidateRanked>,
pub winner_index: Option<usize>,
pub failed: Vec<TopologyCandidateFailure>,
pub warnings: Vec<String>,
}
fn failed_topology_summary(failed: &[TopologyAutoFailedCandidate]) -> String {
failed
.iter()
.map(|failure| {
format!(
"{} [{}]: {}",
failure.topology_name,
failure.stage.as_str(),
failure.message
)
})
.collect::<Vec<_>>()
.join("; ")
}
#[derive(Debug, Clone)]
pub struct TopologyRaceParallelCandidate<FitResult> {
pub candidate_index: usize,
pub per_fit_threads: usize,
pub wall_time: Duration,
pub result: FitResult,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TopologyRaceThreadPlan {
per_fit_threads: usize,
concurrent_fits: usize,
}
impl TopologyRaceThreadPlan {
fn for_budget(candidate_count: usize, max_total_threads: usize) -> Self {
let max_total_threads = max_total_threads.max(1);
if candidate_count <= 1 {
return Self {
per_fit_threads: max_total_threads,
concurrent_fits: candidate_count,
};
}
let concurrent_fits = if max_total_threads >= 4 {
candidate_count.min(max_total_threads / 2).max(1)
} else {
1
};
let remaining = max_total_threads.saturating_sub(concurrent_fits);
let per_fit_threads = if remaining == 0 {
1
} else {
(remaining / concurrent_fits).max(1)
};
Self {
per_fit_threads,
concurrent_fits,
}
}
}
pub fn run_topology_race_parallel<Candidate, FitResult, FitOne>(
candidates: Vec<Candidate>,
fit_one: FitOne,
) -> Result<Vec<TopologyRaceParallelCandidate<FitResult>>, String>
where
Candidate: Send,
FitResult: Send,
FitOne: Fn(Candidate) -> FitResult + Sync,
{
let max_total_threads = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
run_topology_race_parallel_with_budget(candidates, fit_one, max_total_threads)
}
fn run_topology_race_parallel_with_budget<Candidate, FitResult, FitOne>(
candidates: Vec<Candidate>,
fit_one: FitOne,
max_total_threads: usize,
) -> Result<Vec<TopologyRaceParallelCandidate<FitResult>>, String>
where
Candidate: Send,
FitResult: Send,
FitOne: Fn(Candidate) -> FitResult + Sync,
{
let candidate_count = candidates.len();
if candidate_count == 0 {
return Ok(Vec::new());
}
let plan = TopologyRaceThreadPlan::for_budget(candidate_count, max_total_threads);
let mut candidates: Vec<Option<Candidate>> = candidates.into_iter().map(Some).collect();
let slots: Vec<Mutex<Option<TopologyRaceParallelCandidate<FitResult>>>> =
(0..candidate_count).map(|_| Mutex::new(None)).collect();
let pool_error: Mutex<Option<String>> = Mutex::new(None);
if plan.concurrent_fits <= 1 {
for idx in 0..candidate_count {
let candidate = candidates[idx]
.take()
.expect("topology race candidate must be present");
run_one_topology_race_candidate(
idx,
candidate,
&fit_one,
plan.per_fit_threads,
&slots[idx],
&pool_error,
);
if let Some(err) = pool_error.lock().expect("pool_error mutex poisoned").take() {
return Err(err);
}
}
} else {
let mut batch_start = 0usize;
while batch_start < candidate_count {
let batch_end = (batch_start + plan.concurrent_fits).min(candidate_count);
std::thread::scope(|scope| {
for idx in batch_start..batch_end {
let candidate = candidates[idx]
.take()
.expect("topology race candidate must be present");
let slot = &slots[idx];
let pool_error = &pool_error;
let fit_one = &fit_one;
scope.spawn(move || {
run_one_topology_race_candidate(
idx,
candidate,
fit_one,
plan.per_fit_threads,
slot,
pool_error,
);
});
}
});
if let Some(err) = pool_error.lock().expect("pool_error mutex poisoned").take() {
return Err(err);
}
batch_start = batch_end;
}
}
let mut out = Vec::with_capacity(candidate_count);
for (idx, slot) in slots.into_iter().enumerate() {
let row = slot
.into_inner()
.expect("topology race result mutex poisoned")
.ok_or_else(|| format!("topology race candidate {idx} did not produce a result"))?;
out.push(row);
}
Ok(out)
}
fn topology_race_panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(message) = payload.downcast_ref::<&'static str>() {
(*message).to_string()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"non-string panic payload".to_string()
}
}
fn run_one_topology_race_candidate<Candidate, FitResult, FitOne>(
candidate_index: usize,
candidate: Candidate,
fit_one: &FitOne,
per_fit_threads: usize,
slot: &Mutex<Option<TopologyRaceParallelCandidate<FitResult>>>,
pool_error: &Mutex<Option<String>>,
) where
Candidate: Send,
FitResult: Send,
FitOne: Fn(Candidate) -> FitResult + Sync,
{
let started = Instant::now();
let outcome = std::thread::scope(|scope| {
scope
.spawn(move || gam_linalg::faer_ndarray::with_faer_sequential(|| fit_one(candidate)))
.join()
});
let wall_time = started.elapsed();
match outcome {
Ok(result) => {
*slot.lock().expect("topology race result mutex poisoned") =
Some(TopologyRaceParallelCandidate {
candidate_index,
per_fit_threads,
wall_time,
result,
});
}
Err(payload) => {
*pool_error.lock().expect("pool_error mutex poisoned") = Some(format!(
"topology race candidate {candidate_index} panicked: {}",
topology_race_panic_message(payload)
));
}
}
}
pub fn select_topology_with_fit<FitHandle, FitErr>(
selector: &TopologyAutoSelector,
mut fit_one: impl FnMut(AutoTopologyKind) -> Result<TopologyAutoFitEvidence<FitHandle>, FitErr>,
) -> Result<TopologyAutoSelectorResult<FitHandle>, String>
where
FitErr: ToString,
{
let fused = AutoTopologyKind::fuse_constant_curvature_family(&selector.candidates);
let mut ranked = Vec::with_capacity(fused.len());
let mut failed = Vec::new();
for candidate in &fused {
match fit_one(*candidate) {
Ok(evidence) => {
let tk_score = match tk_normalized_score(
evidence.raw_reml,
evidence.null_dim,
evidence.null_space_logdet,
evidence.effective_dim,
evidence.n_obs,
selector.score_scale,
) {
Ok(score) => score,
Err(message) => {
failed.push(TopologyAutoFailedCandidate {
candidate: *candidate,
topology_name: evidence.topology_name,
stage: TopologyCandidateFailureStage::Evidence,
message,
evidence_at_failure: evidence
.raw_reml
.is_finite()
.then_some(evidence.raw_reml),
});
continue;
}
};
ranked.push(TopologyAutoRankedFit {
topology_name: evidence.topology_name,
tk_score,
raw_reml: evidence.raw_reml,
effective_dim: evidence.effective_dim,
n_obs: evidence.n_obs,
fit_handle: evidence.fit_handle,
});
}
Err(err) => failed.push(TopologyAutoFailedCandidate {
candidate: *candidate,
topology_name: candidate.display_name(),
stage: TopologyCandidateFailureStage::Fit,
message: err.to_string(),
evidence_at_failure: None,
}),
}
}
if ranked.is_empty() {
return Err(format!(
"TopologyAutoSelector found no fittable topology candidates{}",
if failed.is_empty() {
String::new()
} else {
format!(" ({})", failed_topology_summary(&failed))
}
));
}
ranked = rank_priority_candidates(
ranked
.into_iter()
.enumerate()
.map(|(idx, row)| {
let score = row.tk_score;
PriorityCandidate::new(row, idx, score, 0)
})
.collect(),
)
.into_iter()
.map(|row| row.item)
.collect();
Ok(TopologyAutoSelectorResult {
ranked,
winner_index: 0,
failed,
})
}
pub fn select_topology_with_fit_parallel<FitHandle, FitErr>(
selector: &TopologyAutoSelector,
fit_one: impl Fn(AutoTopologyKind) -> Result<TopologyAutoFitEvidence<FitHandle>, FitErr> + Sync,
) -> Result<TopologyAutoSelectorResult<FitHandle>, String>
where
FitHandle: Send,
FitErr: ToString + Send,
{
let candidates: Vec<AutoTopologyKind> =
AutoTopologyKind::fuse_constant_curvature_family(&selector.candidates);
let race = run_topology_race_parallel(candidates, |candidate| {
(candidate, fit_one(candidate))
})?;
let mut ranked = Vec::with_capacity(race.len());
let mut failed = Vec::new();
for entry in race {
let (candidate, fit_result) = entry.result;
match fit_result {
Ok(evidence) => {
let tk_score = match tk_normalized_score(
evidence.raw_reml,
evidence.null_dim,
evidence.null_space_logdet,
evidence.effective_dim,
evidence.n_obs,
selector.score_scale,
) {
Ok(score) => score,
Err(message) => {
failed.push(TopologyAutoFailedCandidate {
candidate,
topology_name: evidence.topology_name,
stage: TopologyCandidateFailureStage::Evidence,
message,
evidence_at_failure: evidence
.raw_reml
.is_finite()
.then_some(evidence.raw_reml),
});
continue;
}
};
ranked.push(TopologyAutoRankedFit {
topology_name: evidence.topology_name,
tk_score,
raw_reml: evidence.raw_reml,
effective_dim: evidence.effective_dim,
n_obs: evidence.n_obs,
fit_handle: evidence.fit_handle,
});
}
Err(err) => failed.push(TopologyAutoFailedCandidate {
candidate,
topology_name: candidate.display_name(),
stage: TopologyCandidateFailureStage::Fit,
message: err.to_string(),
evidence_at_failure: None,
}),
}
}
if ranked.is_empty() {
return Err(format!(
"TopologyAutoSelector found no fittable topology candidates{}",
if failed.is_empty() {
String::new()
} else {
format!(" ({})", failed_topology_summary(&failed))
}
));
}
ranked = rank_priority_candidates(
ranked
.into_iter()
.enumerate()
.map(|(idx, row)| {
let score = row.tk_score;
PriorityCandidate::new(row, idx, score, 0)
})
.collect(),
)
.into_iter()
.map(|row| row.item)
.collect();
Ok(TopologyAutoSelectorResult {
ranked,
winner_index: 0,
failed,
})
}
pub fn tk_normalized_score(
raw_reml: f64,
null_dim: f64,
null_space_logdet: Option<f64>,
effective_dim: f64,
n_obs: usize,
score_scale: TopologyScoreScale,
) -> Result<f64, String> {
let tk = raw_reml + topology_tk_normalizer(Some(null_dim), null_space_logdet)?;
match score_scale {
TopologyScoreScale::PerObservation => {
if n_obs == 0 {
Err("TopologyAutoSelector requires n_obs > 0".to_string())
} else {
Ok(tk / n_obs as f64)
}
}
TopologyScoreScale::PerEffectiveDim => {
if !(effective_dim.is_finite() && effective_dim > 0.0) {
Err("TopologyAutoSelector requires finite positive effective_dim".to_string())
} else {
Ok(tk / effective_dim)
}
}
}
}
fn topology_tk_normalizer(
null_dim: Option<f64>,
null_space_logdet: Option<f64>,
) -> Result<f64, String> {
let null_dim = null_dim.ok_or_else(|| {
"topology evidence requires null-dimension metadata for TK normalization".to_string()
})?;
if !null_dim.is_finite() || null_dim < -1.0e-9 {
return Err("topology evidence null dimension must be finite and non-negative".to_string());
}
if null_dim.max(0.0) == 0.0 {
return Ok(0.0);
}
let logdet = null_space_logdet.ok_or_else(|| {
"topology evidence TK normalizer requires null-space Hessian logdet".to_string()
})?;
if !logdet.is_finite() {
return Err("topology evidence null-space Hessian logdet must be finite".to_string());
}
Ok(-0.5 * null_dim.max(0.0) * TK_LOG_2PI + 0.5 * logdet)
}
fn topology_candidate_raw_score(
evidence: &TopologyCandidateEvidence,
score_kind: TopologySelectionScoreKind,
) -> Result<f64, String> {
if !evidence.effective_dim.is_finite() {
return Err(format!(
"candidate {:?} has non-finite effective_dim {:?}",
evidence.name, evidence.effective_dim
));
}
if evidence.n_obs == 0 {
return Err(format!("candidate {:?} requires n_obs > 0", evidence.name));
}
if !evidence.raw_reml.is_finite() {
return Err(format!(
"candidate {:?} has non-finite REML evidence {:?}",
evidence.name, evidence.raw_reml
));
}
match score_kind {
TopologySelectionScoreKind::Reml => Ok(evidence.raw_reml),
TopologySelectionScoreKind::Tk => Ok(evidence.raw_reml
+ topology_tk_normalizer(evidence.null_dim, evidence.null_space_logdet)?),
TopologySelectionScoreKind::Laml => {
let laml = evidence.laml.ok_or_else(|| {
format!(
"candidate {:?} is missing LAML evidence metadata",
evidence.name
)
})?;
if !laml.is_finite() {
return Err(format!(
"candidate {:?} has non-finite LAML evidence {laml:?}",
evidence.name
));
}
Ok(laml + topology_tk_normalizer(evidence.null_dim, evidence.null_space_logdet)?)
}
TopologySelectionScoreKind::Bic => {
let deviance = evidence.deviance.ok_or_else(|| {
format!(
"candidate {:?} is missing deviance metadata required for BIC",
evidence.name
)
})?;
bic_score(deviance, evidence.n_obs, evidence.basis_size)
}
}
}
fn scale_topology_candidate_score(
score: f64,
scale: TopologySelectionScoreScale,
evidence: &TopologyCandidateEvidence,
) -> Result<f64, String> {
if !score.is_finite() {
return Err(format!(
"candidate {:?} has non-finite selected evidence {score:?}",
evidence.name
));
}
match scale {
TopologySelectionScoreScale::Raw => Ok(score),
TopologySelectionScoreScale::PerObservation => {
if evidence.n_obs == 0 {
Err(format!(
"candidate {:?} requires n_obs > 0 for per-observation scoring",
evidence.name
))
} else {
Ok(score / evidence.n_obs as f64)
}
}
TopologySelectionScoreScale::PerEffectiveDim => {
if !(evidence.effective_dim.is_finite() && evidence.effective_dim > 0.0) {
Err(format!(
"candidate {:?} requires finite positive effective_dim for per-effective-dimension scoring; got {:?}",
evidence.name, evidence.effective_dim
))
} else {
Ok(score / evidence.effective_dim)
}
}
}
}
fn topology_candidate_score(
evidence: &TopologyCandidateEvidence,
score_kind: TopologySelectionScoreKind,
score_scale: TopologySelectionScoreScale,
) -> Result<f64, String> {
let raw = topology_candidate_raw_score(evidence, score_kind)?;
scale_topology_candidate_score(raw, score_scale, evidence)
}
pub fn select_topology_candidate_lifecycle(
outcomes: Vec<TopologyCandidateOutcome>,
score_kind: TopologySelectionScoreKind,
score_scale: TopologySelectionScoreScale,
) -> Result<TopologyCandidateSelectionResult, String> {
if outcomes.is_empty() {
return Err("topology selection requires at least one candidate outcome".to_string());
}
let mut names = std::collections::BTreeSet::new();
for outcome in &outcomes {
let name = outcome.name();
if name.is_empty() {
return Err("topology candidate names cannot be empty".to_string());
}
if !names.insert(name.to_string()) {
return Err(format!("duplicate topology candidate {name:?}"));
}
}
let mut evidence_survivors = Vec::new();
let mut ranked = Vec::new();
let mut failed = Vec::new();
for (candidate_index, outcome) in outcomes.into_iter().enumerate() {
match outcome {
TopologyCandidateOutcome::Failed(failure) => failed.push(failure),
TopologyCandidateOutcome::Fitted(evidence) => {
match topology_candidate_score(&evidence, score_kind, score_scale) {
Ok(score) => {
ranked.push(PriorityCandidate::new(
TopologyCandidateRanked {
name: evidence.name.clone(),
score,
raw_reml: evidence.raw_reml,
effective_dim: evidence.effective_dim,
basis_size: evidence.basis_size,
n_obs: evidence.n_obs,
},
candidate_index,
score,
0,
));
evidence_survivors.push(evidence);
}
Err(message) => failed.push(TopologyCandidateFailure {
name: evidence.name,
stage: TopologyCandidateFailureStage::Evidence,
error_type: "gam_solve::topology_selector::EvidenceValidationError"
.to_string(),
message,
evidence_at_failure: evidence
.raw_reml
.is_finite()
.then_some(evidence.raw_reml),
}),
}
}
}
}
let ranked: Vec<TopologyCandidateRanked> = rank_priority_candidates(ranked)
.into_iter()
.map(|candidate| candidate.item)
.collect();
let warnings = topology_score_disagreement_warnings(&evidence_survivors, score_scale);
Ok(TopologyCandidateSelectionResult {
winner_index: (!ranked.is_empty()).then_some(0),
ranked,
failed,
warnings,
})
}
fn topology_score_disagreement_warnings(
evidence: &[TopologyCandidateEvidence],
score_scale: TopologySelectionScoreScale,
) -> Vec<String> {
let mut orders = Vec::new();
for kind in [
TopologySelectionScoreKind::Reml,
TopologySelectionScoreKind::Laml,
TopologySelectionScoreKind::Bic,
] {
let scored: Result<Vec<_>, _> = evidence
.iter()
.enumerate()
.map(|(index, row)| {
topology_candidate_score(row, kind, score_scale)
.map(|score| PriorityCandidate::new(row.name.clone(), index, score, 0))
})
.collect();
let Ok(scored) = scored else {
continue;
};
let order: Vec<String> = rank_priority_candidates(scored)
.into_iter()
.map(|row| row.item)
.collect();
orders.push((kind, order));
}
if orders.len() < 2 || orders.windows(2).all(|pair| pair[0].1 == pair[1].1) {
return Vec::new();
}
let detail = orders
.iter()
.map(|(kind, order)| format!("{}: {}", kind.as_str(), order.join(", ")))
.collect::<Vec<_>>()
.join("; ");
if score_scale == TopologySelectionScoreScale::Raw {
vec![format!(
"Topology score rankings differ across score kinds ({detail}). BIC and REML can disagree when candidate basis sizes differ wildly."
)]
} else {
vec![format!(
"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.",
score_scale.as_str()
)]
}
}
pub fn bic_score(deviance: f64, n_obs: usize, basis_size: usize) -> Result<f64, String> {
if n_obs <= 1 {
return Err("BIC scoring requires at least two observations".to_string());
}
if !deviance.is_finite() {
return Err("BIC scoring requires finite deviance".to_string());
}
Ok(deviance + (n_obs as f64).ln() * basis_size as f64)
}
#[derive(Debug, Clone)]
pub struct MixtureRungFit {
pub k: usize,
pub fit: crate::evidence::GaussianMixtureFit,
pub num_parameters: usize,
pub bic: f64,
}
#[derive(Debug, Clone)]
pub struct MixtureRungResult {
pub fits: Vec<MixtureRungFit>,
pub winner_index: usize,
}
impl MixtureRungResult {
pub fn winner(&self) -> &MixtureRungFit {
&self.fits[self.winner_index]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdaptiveRungKind {
GaussianMixture,
RingOfClusters,
}
impl AdaptiveRungKind {
const fn display_name(self) -> &'static str {
match self {
Self::GaussianMixture => "Gaussian-mixture",
Self::RingOfClusters => "ring-of-clusters",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdaptiveRungFailureStage {
Fit,
Evidence,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdaptiveRungOrderFailure {
pub k: usize,
pub stage: AdaptiveRungFailureStage,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdaptiveRungError {
InvalidInput {
kind: AdaptiveRungKind,
message: String,
},
OrderFailures {
kind: AdaptiveRungKind,
failures: Vec<AdaptiveRungOrderFailure>,
},
RefinementBudgetExhausted {
kind: AdaptiveRungKind,
best_k: usize,
completed_probes: usize,
},
}
impl std::fmt::Display for AdaptiveRungError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidInput { kind, message } => {
write!(formatter, "invalid {} rung: {message}", kind.display_name())
}
Self::OrderFailures { kind, failures } => {
write!(
formatter,
"{} rung refused because {} eligible order(s) failed",
kind.display_name(),
failures.len()
)?;
for failure in failures {
write!(
formatter,
"; k={} {:?}: {}",
failure.k, failure.stage, failure.message
)?;
}
Ok(())
}
Self::RefinementBudgetExhausted {
kind,
best_k,
completed_probes,
} => write!(
formatter,
"{} rung exhausted its {completed_probes}-probe refinement budget before bracketing k={best_k}",
kind.display_name()
),
}
}
}
impl std::error::Error for AdaptiveRungError {}
fn eligible_adaptive_orders(
kind: AdaptiveRungKind,
ladder: &[usize],
minimum_order: usize,
n: usize,
) -> Result<Vec<usize>, AdaptiveRungError> {
if ladder.is_empty() {
return Err(AdaptiveRungError::InvalidInput {
kind,
message: "order ladder must not be empty".to_string(),
});
}
let mut seen = std::collections::BTreeSet::new();
let mut orders = Vec::with_capacity(ladder.len());
for &k in ladder {
if k < minimum_order || k > n {
return Err(AdaptiveRungError::InvalidInput {
kind,
message: format!(
"requested order k={k} is outside this class on n={n} rows; require {minimum_order} <= k <= n"
),
});
}
if !seen.insert(k) {
return Err(AdaptiveRungError::InvalidInput {
kind,
message: format!("order ladder contains duplicate k={k}"),
});
}
orders.push(k);
}
Ok(orders)
}
pub const MIXTURE_REFINEMENT_MAX_PROBES: usize = 16;
pub fn fit_mixture_rung(
data: ArrayView2<'_, f64>,
ladder: &[usize],
config: GaussianMixtureConfig,
) -> Result<MixtureRungResult, AdaptiveRungError> {
fit_mixture_rung_with_minimum_order(data, ladder, 1, config)
}
pub fn fit_free_cluster_rung(
data: ArrayView2<'_, f64>,
ladder: &[usize],
config: GaussianMixtureConfig,
) -> Result<MixtureRungResult, AdaptiveRungError> {
fit_mixture_rung_with_minimum_order(data, ladder, 2, config)
}
fn fit_mixture_rung_with_minimum_order(
data: ArrayView2<'_, f64>,
ladder: &[usize],
minimum_order: usize,
config: GaussianMixtureConfig,
) -> Result<MixtureRungResult, AdaptiveRungError> {
let kind = AdaptiveRungKind::GaussianMixture;
let n = data.nrows();
let coarse_orders = eligible_adaptive_orders(kind, ladder, minimum_order, n)?;
let mut fits: Vec<MixtureRungFit> = Vec::new();
let mut attempted: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
let try_order = |k: usize,
fits: &mut Vec<MixtureRungFit>,
attempted: &mut std::collections::BTreeSet<usize>|
-> Option<AdaptiveRungOrderFailure> {
assert!(k >= minimum_order && k <= n && !attempted.contains(&k));
attempted.insert(k);
match fit_gaussian_mixture(data, k, config) {
Ok(fit) => {
let num_parameters = fit.num_free_parameters();
let bic = fit.bic();
if bic.is_finite() {
fits.push(MixtureRungFit {
k,
fit,
num_parameters,
bic,
});
None
} else {
Some(AdaptiveRungOrderFailure {
k,
stage: AdaptiveRungFailureStage::Evidence,
message: "BIC is not finite".to_string(),
})
}
}
Err(error) => Some(AdaptiveRungOrderFailure {
k,
stage: AdaptiveRungFailureStage::Fit,
message: error.to_string(),
}),
}
};
let mut failures = Vec::new();
for k in coarse_orders {
if let Some(failure) = try_order(k, &mut fits, &mut attempted) {
failures.push(failure);
}
}
if !failures.is_empty() {
return Err(AdaptiveRungError::OrderFailures { kind, failures });
}
let mut probes = 0usize;
loop {
let Some(best_k) = fits
.iter()
.min_by(|a, b| a.bic.total_cmp(&b.bic).then(a.k.cmp(&b.k)))
.map(|f| f.k)
else {
return Err(AdaptiveRungError::InvalidInput {
kind,
message: "no eligible order produced a fit".to_string(),
});
};
let next = [best_k.checked_sub(1), best_k.checked_add(1)]
.into_iter()
.flatten()
.find(|&k| k >= minimum_order && k <= n && !attempted.contains(&k));
let Some(k) = next else {
break; };
if probes == MIXTURE_REFINEMENT_MAX_PROBES {
return Err(AdaptiveRungError::RefinementBudgetExhausted {
kind,
best_k,
completed_probes: probes,
});
}
if let Some(failure) = try_order(k, &mut fits, &mut attempted) {
return Err(AdaptiveRungError::OrderFailures {
kind,
failures: vec![failure],
});
}
probes += 1;
}
let ranked = rank_priority_candidates(
fits.into_iter()
.enumerate()
.map(|(idx, row)| {
let score = row.bic;
let tie = row.k; PriorityCandidate::new(row, idx, score, tie)
})
.collect(),
)
.into_iter()
.map(|row| row.item)
.collect::<Vec<_>>();
Ok(MixtureRungResult {
fits: ranked,
winner_index: 0,
})
}
#[derive(Debug, Clone)]
pub struct RingOfClustersRungFit {
pub k: usize,
pub fit: crate::evidence::RingGaussianMixtureFit,
pub num_parameters: usize,
pub bic: f64,
}
#[derive(Debug, Clone)]
pub struct RingOfClustersRungResult {
pub fits: Vec<RingOfClustersRungFit>,
pub winner_index: usize,
}
impl RingOfClustersRungResult {
pub fn winner(&self) -> &RingOfClustersRungFit {
&self.fits[self.winner_index]
}
}
pub fn fit_ring_of_clusters_rung(
data: ArrayView2<'_, f64>,
ladder: &[usize],
config: GaussianMixtureConfig,
) -> Result<RingOfClustersRungResult, AdaptiveRungError> {
let kind = AdaptiveRungKind::RingOfClusters;
let n = data.nrows();
let coarse_orders = eligible_adaptive_orders(kind, ladder, 3, n)?;
let mut fits = Vec::<RingOfClustersRungFit>::new();
let mut attempted = std::collections::BTreeSet::<usize>::new();
let try_order = |k: usize,
fits: &mut Vec<RingOfClustersRungFit>,
attempted: &mut std::collections::BTreeSet<usize>|
-> Option<AdaptiveRungOrderFailure> {
assert!(k >= 3 && k <= n && !attempted.contains(&k));
attempted.insert(k);
match crate::evidence::fit_ring_gaussian_mixture(data, k, config) {
Ok(fit) => {
let bic = fit.bic();
if bic.is_finite() {
fits.push(RingOfClustersRungFit {
k,
num_parameters: fit.num_free_parameters(),
bic,
fit,
});
None
} else {
Some(AdaptiveRungOrderFailure {
k,
stage: AdaptiveRungFailureStage::Evidence,
message: "BIC is not finite".to_string(),
})
}
}
Err(message) => Some(AdaptiveRungOrderFailure {
k,
stage: AdaptiveRungFailureStage::Fit,
message,
}),
}
};
let mut failures = Vec::new();
for k in coarse_orders {
if let Some(failure) = try_order(k, &mut fits, &mut attempted) {
failures.push(failure);
}
}
if !failures.is_empty() {
return Err(AdaptiveRungError::OrderFailures { kind, failures });
}
let mut probes = 0usize;
loop {
let Some(best_k) = fits
.iter()
.min_by(|left, right| left.bic.total_cmp(&right.bic).then(left.k.cmp(&right.k)))
.map(|fit| fit.k)
else {
return Err(AdaptiveRungError::InvalidInput {
kind,
message: "no eligible order produced a fit".to_string(),
});
};
let next = [best_k.checked_sub(1), best_k.checked_add(1)]
.into_iter()
.flatten()
.find(|&k| k >= 3 && k <= n && !attempted.contains(&k));
let Some(k) = next else {
break;
};
if probes == MIXTURE_REFINEMENT_MAX_PROBES {
return Err(AdaptiveRungError::RefinementBudgetExhausted {
kind,
best_k,
completed_probes: probes,
});
}
if let Some(failure) = try_order(k, &mut fits, &mut attempted) {
return Err(AdaptiveRungError::OrderFailures {
kind,
failures: vec![failure],
});
}
probes += 1;
}
let ranked = rank_priority_candidates(
fits.into_iter()
.enumerate()
.map(|(index, fit)| {
let score = fit.bic;
let tie = fit.k;
PriorityCandidate::new(fit, index, score, tie)
})
.collect(),
)
.into_iter()
.map(|candidate| candidate.item)
.collect();
Ok(RingOfClustersRungResult {
fits: ranked,
winner_index: 0,
})
}
#[derive(Debug, Clone)]
pub struct UnionRungFit {
pub structure: UnionStructure,
pub fit: UnionStructureFit,
pub total_parameters: usize,
pub bic: f64,
}
#[derive(Debug, Clone)]
pub struct UnionRungResult {
pub fits: Vec<UnionRungFit>,
pub winner_index: usize,
}
impl UnionRungResult {
pub fn winner(&self) -> &UnionRungFit {
&self.fits[self.winner_index]
}
}
pub fn fit_union_rung(
data: ArrayView2<'_, f64>,
config: GaussianMixtureConfig,
) -> Result<UnionRungResult, String> {
let ladder = fit_union_ladder(data, config)?;
let fits: Vec<UnionRungFit> = ladder
.into_iter()
.map(|fit| UnionRungFit {
structure: fit.structure,
total_parameters: fit.total_parameters,
bic: fit.bic,
fit,
})
.collect();
if fits.is_empty() {
return Err("union rung produced no fittable composites".to_string());
}
Ok(UnionRungResult {
fits,
winner_index: 0,
})
}
pub fn fit_union_candidate(
data: ArrayView2<'_, f64>,
structure: UnionStructure,
config: GaussianMixtureConfig,
) -> Result<UnionRungFit, String> {
let fit = fit_union_structure(data, structure, config)?;
Ok(UnionRungFit {
structure: fit.structure,
total_parameters: fit.total_parameters,
bic: fit.bic,
fit,
})
}
pub type HeldOutDensityProvider<'a> =
Box<dyn Fn(&[usize], &[usize]) -> Result<Vec<f64>, String> + 'a>;
pub fn mixture_density_provider<'a>(
data: ArrayView2<'a, f64>,
k: usize,
config: GaussianMixtureConfig,
) -> HeldOutDensityProvider<'a> {
let owned = data.to_owned();
Box::new(
move |train: &[usize], eval: &[usize]| -> Result<Vec<f64>, String> {
if k == 0 || k > train.len() {
return Err(format!(
"fixed-order mixture k={k} requires at least {k} training rows; fold has {}",
train.len()
));
}
let train_mat = gather_rows(owned.view(), train, "mixture training")?;
let fit = fit_gaussian_mixture(train_mat.view(), k, config)
.map_err(|error| error.to_string())?;
let eval_mat = gather_rows(owned.view(), eval, "mixture evaluation")?;
let dens = fit.per_point_log_density(eval_mat.view())?;
Ok(dens.to_vec())
},
)
}
pub fn ring_of_clusters_density_provider<'a>(
data: ArrayView2<'a, f64>,
k: usize,
config: GaussianMixtureConfig,
) -> HeldOutDensityProvider<'a> {
let owned = data.to_owned();
Box::new(
move |train: &[usize], eval: &[usize]| -> Result<Vec<f64>, String> {
if k < 3 || k > train.len() {
return Err(format!(
"fixed-order ring-of-clusters k={k} requires k >= 3 and at least {k} training rows; fold has {}",
train.len()
));
}
let train_mat = gather_rows(owned.view(), train, "ring-of-clusters training")?;
let fit = crate::evidence::fit_ring_gaussian_mixture(train_mat.view(), k, config)?;
let eval_mat = gather_rows(owned.view(), eval, "ring-of-clusters evaluation")?;
Ok(fit.per_point_log_density(eval_mat.view())?.to_vec())
},
)
}
pub fn union_density_provider<'a>(
data: ArrayView2<'a, f64>,
structure: UnionStructure,
config: GaussianMixtureConfig,
) -> HeldOutDensityProvider<'a> {
let owned = data.to_owned();
Box::new(
move |train: &[usize], eval: &[usize]| -> Result<Vec<f64>, String> {
let train_mat = gather_rows(owned.view(), train, "union training")?;
let eval_mat = gather_rows(owned.view(), eval, "union evaluation")?;
let dens =
union_per_point_log_density(train_mat.view(), eval_mat.view(), structure, config)?;
Ok(dens.to_vec())
},
)
}
fn gather_rows(
data: ArrayView2<'_, f64>,
idx: &[usize],
context: &str,
) -> Result<Array2<f64>, String> {
let d = data.ncols();
let mut out = Array2::<f64>::zeros((idx.len(), d));
for (r, &i) in idx.iter().enumerate() {
if i >= data.nrows() {
return Err(format!(
"{context} row index {i} is out of bounds for {} rows",
data.nrows()
));
}
for c in 0..d {
out[[r, c]] = data[[i, c]];
}
}
Ok(out)
}
pub fn deterministic_cv_folds(n: usize, folds: usize) -> Vec<(Vec<usize>, Vec<usize>)> {
deterministic_cv_folds_seeded(n, folds, STACKING_CV_SEED)
}
#[inline]
fn splitmix64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
pub fn deterministic_cv_folds_seeded(
n: usize,
folds: usize,
seed: u64,
) -> Vec<(Vec<usize>, Vec<usize>)> {
if n < 2 || folds < 2 || folds > n {
return Vec::new();
}
let mut order = (0..n).collect::<Vec<_>>();
order.sort_unstable_by_key(|&row| (splitmix64(seed ^ splitmix64(row as u64)), row));
let mut assign = vec![0usize; n];
for (rank, row) in order.into_iter().enumerate() {
assign[row] = rank % folds;
}
let mut out = Vec::with_capacity(folds);
for f in 0..folds {
let mut train = Vec::new();
let mut eval = Vec::new();
for (i, &fold) in assign.iter().enumerate() {
if fold == f {
eval.push(i);
} else {
train.push(i);
}
}
out.push((train, eval));
}
out
}
pub fn build_cv_log_density_table(
n: usize,
folds: usize,
seed: u64,
providers: &[HeldOutDensityProvider<'_>],
) -> Result<Array2<f64>, String> {
if providers.is_empty() {
return Err("stacking table requires at least one candidate provider".to_string());
}
if n < 2 || folds < 2 || folds > n {
return Err(format!(
"stacking CV requires 2 <= folds <= n with n >= 2; got folds={folds}, n={n}"
));
}
let partition = deterministic_cv_folds_seeded(n, folds, seed);
if partition.is_empty() {
return Err("stacking CV partition is empty (n too small for folds)".to_string());
}
let mut table = Array2::<f64>::from_elem((n, providers.len()), f64::NEG_INFINITY);
for (train, eval) in &partition {
for (col, provider) in providers.iter().enumerate() {
let dens = provider(train, eval)?;
if dens.len() != eval.len() {
return Err(format!(
"provider {col} returned {} densities for {} eval rows",
dens.len(),
eval.len()
));
}
for (slot, &row) in eval.iter().enumerate() {
table[[row, col]] = dens[slot];
}
}
}
Ok(table)
}
#[derive(Debug, Clone)]
pub struct PredictiveRaceVerdict {
pub candidate_names: Vec<String>,
pub is_cross_class: bool,
pub negative_log_evidence: Vec<f64>,
pub stacking: Option<StackingWeights>,
pub winner_index: usize,
pub headline: Headline,
pub insufficient_margin: Option<InsufficientRaceMargin>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Headline {
Evidence,
Stacking,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum EvidenceCertification {
Exact,
Enclosure { gap: f64 },
Coreset { certificate: CoresetCertificate },
}
impl EvidenceCertification {
pub fn required_margin(&self) -> f64 {
match self {
EvidenceCertification::Exact => 0.0,
EvidenceCertification::Enclosure { gap } => *gap,
EvidenceCertification::Coreset { certificate } => certificate.race_transfer_margin(),
}
}
pub fn race_verdict(&self, race_lead: f64) -> gam_problem::topology_certificates::Verdict {
use gam_problem::topology_certificates::Verdict;
if !(race_lead.is_finite() && race_lead > 0.0) {
return Verdict::Insufficient;
}
match self {
EvidenceCertification::Exact => Verdict::Certified,
EvidenceCertification::Enclosure { gap } => {
let enclosure = crate::logdet_bounds::LogdetEnclosure {
block_diag_logdet: 0.0,
lower: 0.0,
upper: *gap,
rho: 0.0,
p2: 0.0,
p3: None,
};
crate::inference::certificate_impls::enclosure_margin_verdict(&enclosure, race_lead)
}
EvidenceCertification::Coreset { certificate } => {
crate::inference::certificate_impls::coreset_race_verdict(
certificate.certify_margin(race_lead),
)
}
}
}
}
pub struct PredictiveRaceCandidate<'a> {
pub kind: PredictiveCandidateKind,
pub negative_log_evidence: f64,
pub certification: EvidenceCertification,
pub density_provider: HeldOutDensityProvider<'a>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InsufficientRaceMargin {
pub provisional_winner: usize,
pub contender: usize,
pub lead: f64,
pub required_margin: f64,
}
pub fn adjudicate_predictive_race(
n: usize,
candidates: Vec<PredictiveRaceCandidate<'_>>,
folds: usize,
seed: u64,
stacking_config: StackingConfig,
) -> Result<PredictiveRaceVerdict, String> {
if candidates.is_empty() {
return Err("predictive race requires at least one candidate".to_string());
}
for (index, candidate) in candidates.iter().enumerate() {
if !candidate.negative_log_evidence.is_finite() {
return Err(format!(
"predictive race candidate {index} ({}) has non-finite negative-log-evidence {:?}",
candidate.kind.display_name(),
candidate.negative_log_evidence
));
}
let required_margin = candidate.certification.required_margin();
if !required_margin.is_finite() || required_margin < 0.0 {
return Err(format!(
"predictive race candidate {index} ({}) has invalid certification margin {required_margin:?}; margins must be finite and nonnegative",
candidate.kind.display_name()
));
}
}
for index in 0..candidates.len() {
if let Some(first_index) = candidates[..index]
.iter()
.position(|candidate| candidate.kind == candidates[index].kind)
{
return Err(format!(
"predictive race contains duplicate candidate {:?} at indices {first_index} and {index}",
candidates[index].kind.display_name()
));
}
}
let names: Vec<String> = candidates.iter().map(|c| c.kind.display_name()).collect();
let evidence: Vec<f64> = candidates.iter().map(|c| c.negative_log_evidence).collect();
let has_discrete = candidates.iter().any(|c| c.kind.is_discrete_class());
let has_smooth = candidates.iter().any(|c| !c.kind.is_discrete_class());
let is_cross_class = has_discrete && has_smooth;
let has_adaptive_class = candidates
.iter()
.any(|candidate| candidate.kind.requires_predictive_stacking());
let use_stacking = is_cross_class || has_adaptive_class;
if !use_stacking {
let certifications: Vec<EvidenceCertification> =
candidates.iter().map(|c| c.certification).collect();
let winner_index = evidence
.iter()
.enumerate()
.min_by(|left, right| left.1.total_cmp(right.1))
.map(|(index, _)| index)
.ok_or_else(|| "predictive race has no evidence values".to_string())?;
let best = evidence[winner_index];
let mut insufficient_margin: Option<InsufficientRaceMargin> = None;
for (idx, &nle) in evidence.iter().enumerate() {
if idx == winner_index {
continue;
}
let lead = nle - best;
let required = certifications[winner_index]
.required_margin()
.max(certifications[idx].required_margin());
if required > 0.0 && lead <= required {
let tighter = insufficient_margin.map(|m| lead < m.lead).unwrap_or(true);
if tighter {
insufficient_margin = Some(InsufficientRaceMargin {
provisional_winner: winner_index,
contender: idx,
lead,
required_margin: required,
});
}
}
}
return Ok(PredictiveRaceVerdict {
candidate_names: names,
is_cross_class: false,
negative_log_evidence: evidence,
stacking: None,
winner_index,
headline: Headline::Evidence,
insufficient_margin,
});
}
let providers: Vec<HeldOutDensityProvider<'_>> =
candidates.into_iter().map(|c| c.density_provider).collect();
let table = build_cv_log_density_table(n, folds, seed, &providers)?;
let stacking =
solve_stacking_weights(table.view(), stacking_config).map_err(|error| error.to_string())?;
let mut winner_index = 0usize;
let mut best_w = f64::NEG_INFINITY;
for (idx, &w) in stacking.weights.iter().enumerate() {
if w > best_w {
best_w = w;
winner_index = idx;
}
}
Ok(PredictiveRaceVerdict {
candidate_names: names,
is_cross_class,
negative_log_evidence: evidence,
stacking: Some(stacking),
winner_index,
headline: Headline::Stacking,
insufficient_margin: None,
})
}
#[derive(Debug, Clone)]
pub struct ClosureProfilePoint<FitHandle> {
pub gamma: f64,
pub tk_score: f64,
pub score_gradient: f64,
pub score_curvature: f64,
pub support_collapsed: bool,
pub fit_handle: FitHandle,
}
#[derive(Debug, Clone)]
pub struct ClosureProfileFit<FitHandle> {
pub tk_score: f64,
pub score_gradient: f64,
pub score_curvature: f64,
pub support_collapsed: bool,
pub fit_handle: FitHandle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClosureOptimumKind {
Interior,
IntervalBoundary,
CircleBoundary,
}
#[derive(Debug, Clone, Copy)]
pub struct ClosureStationarityCertificate {
pub kind: ClosureOptimumKind,
pub projected_gradient: f64,
pub tolerance: f64,
pub bracket: gam_math::score_opt::ClosedInterval,
pub derivative_enclosure: gam_math::score_opt::DerivativeEnclosure,
}
#[derive(Debug, Clone)]
pub struct ClosureSelection<FitHandle> {
pub ci: gam_geometry::ClosureProfileCi,
pub representative: ClosureProfilePoint<FitHandle>,
pub stationarity: ClosureStationarityCertificate,
pub route_to_mixture_rung: bool,
}
fn closure_profile_ci_side<EvaluateScore>(
evaluate_score: &EvaluateScore,
gamma_hat: f64,
target: f64,
bound: f64,
stationary_abscissae: &[f64],
resolution: f64,
) -> Result<(f64, bool), String>
where
EvaluateScore: Fn(f64) -> Result<f64, String>,
{
let toward_lower = bound < gamma_hat;
let mut probes: Vec<f64> = stationary_abscissae
.iter()
.copied()
.filter(|&gamma| {
if toward_lower {
gamma < gamma_hat && gamma > bound
} else {
gamma > gamma_hat && gamma < bound
}
})
.collect();
probes.sort_by(f64::total_cmp);
if toward_lower {
probes.reverse();
}
probes.push(bound);
let mut inside = gamma_hat;
for probe in probes {
let value = evaluate_score(probe)?;
if !value.is_finite() {
return Err(format!(
"closure profile CI produced non-finite evidence at γ={probe}"
));
}
let comparison_roundoff = f64::EPSILON * (1.0 + value.abs() + target.abs());
if (value - target).abs() <= comparison_roundoff {
return Ok((probe, probe == bound));
}
if value > target {
let mut outside = probe;
while (outside - inside).abs() > resolution {
let midpoint = outside + 0.5 * (inside - outside);
if midpoint == outside || midpoint == inside {
break;
}
let midpoint_value = evaluate_score(midpoint)?;
if !midpoint_value.is_finite() {
return Err(format!(
"closure profile CI produced non-finite evidence at γ={midpoint}"
));
}
if midpoint_value <= target {
inside = midpoint;
} else {
outside = midpoint;
}
}
return Ok((outside + 0.5 * (inside - outside), false));
}
inside = probe;
}
Ok((bound, true))
}
pub fn profile_closure_within_smooth_class<FitHandle, FitAtGamma, EncloseDerivatives>(
fit_at_gamma: FitAtGamma,
enclose_derivatives: EncloseDerivatives,
level: f64,
) -> Result<ClosureSelection<FitHandle>, String>
where
FitAtGamma: Fn(f64) -> Result<ClosureProfileFit<FitHandle>, String>,
EncloseDerivatives: Fn(f64, f64) -> Result<gam_math::score_opt::DerivativeEnclosure, String>,
{
let gamma_tolerance = f64::EPSILON.sqrt();
let evaluate = |gamma: f64| -> Result<ClosureProfilePoint<FitHandle>, String> {
let fit = fit_at_gamma(gamma)?;
let ClosureProfileFit {
tk_score,
score_gradient,
score_curvature,
support_collapsed,
fit_handle,
} = fit;
if !(tk_score.is_finite() && score_gradient.is_finite() && score_curvature.is_finite()) {
return Err(format!(
"closure profile produced a non-finite score jet at γ={gamma}"
));
}
Ok(ClosureProfilePoint {
gamma,
tk_score,
score_gradient,
score_curvature,
support_collapsed,
fit_handle,
})
};
let mut score_oracle = |gamma: f64| {
let point = evaluate(gamma)?;
Ok::<_, String>(gam_math::score_opt::ScoreJet {
value: -point.tk_score,
derivative: -point.score_gradient,
curvature: -point.score_curvature,
})
};
let mut score_enclosure = |lo: f64, hi: f64| {
let tk = enclose_derivatives(lo, hi)?;
Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
derivative: gam_math::score_opt::ClosedInterval::outward(
-tk.derivative.hi,
-tk.derivative.lo,
),
curvature: gam_math::score_opt::ClosedInterval::outward(
-tk.curvature.hi,
-tk.curvature.lo,
),
})
};
let search = gam_math::score_opt::maximize_score_1d(
0.0,
1.0,
gamma_tolerance,
&mut score_oracle,
&mut score_enclosure,
)
.map_err(|error| format!("closure profile: {error}"))?;
let representative = evaluate(search.optimum.x)?;
let gradient_scale = 1.0
+ search.lower_boundary.derivative.abs()
+ search.upper_boundary.derivative.abs()
+ representative.score_curvature.abs();
let stationarity_tolerance = f64::EPSILON.sqrt() * gradient_scale;
let (kind, projected_gradient) = match search.location {
gam_math::score_opt::ScoreOptimumLocation::LowerBoundary => (
ClosureOptimumKind::IntervalBoundary,
(-representative.score_gradient).max(0.0),
),
gam_math::score_opt::ScoreOptimumLocation::UpperBoundary => (
ClosureOptimumKind::CircleBoundary,
representative.score_gradient.max(0.0),
),
gam_math::score_opt::ScoreOptimumLocation::Stationary(_) => (
ClosureOptimumKind::Interior,
representative.score_gradient.abs(),
),
};
if projected_gradient > stationarity_tolerance
|| (kind == ClosureOptimumKind::Interior && representative.score_curvature <= 0.0)
{
return Err(format!(
"closure profile did not certify its continuous optimum: γ={}, projected \
gradient={}, curvature={}, tolerance={}",
representative.gamma,
projected_gradient,
representative.score_curvature,
stationarity_tolerance
));
}
let bracket = match search.location {
gam_math::score_opt::ScoreOptimumLocation::LowerBoundary
| gam_math::score_opt::ScoreOptimumLocation::UpperBoundary => {
gam_math::score_opt::ClosedInterval::point(representative.gamma)
}
gam_math::score_opt::ScoreOptimumLocation::Stationary(index) => {
search
.stationary_points
.get(index)
.ok_or_else(|| {
"closure profile optimizer returned an invalid stationary index".to_string()
})?
.bracket
}
};
let derivative_enclosure = enclose_derivatives(bracket.lo, bracket.hi)?;
let stationarity = ClosureStationarityCertificate {
kind,
projected_gradient,
tolerance: stationarity_tolerance,
bracket,
derivative_enclosure,
};
if !(level.is_finite() && level > 0.0 && level < 1.0) {
return Err("closure profile CI level must lie in (0, 1)".to_string());
}
let chi_squared = ChiSquared::new(1.0)
.map_err(|error| format!("closure profile CI distribution: {error}"))?;
let target = representative.tk_score + 0.5 * chi_squared.inverse_cdf(level);
let stationary_abscissae: Vec<f64> = search
.stationary_points
.iter()
.map(|stationary| stationary.sample.x)
.collect();
let evaluate_score = |gamma| evaluate(gamma).map(|point| point.tk_score);
let (ci_lo, lo_at_bound) = if representative.gamma == 0.0 {
(0.0, true)
} else {
closure_profile_ci_side(
&evaluate_score,
representative.gamma,
target,
0.0,
&stationary_abscissae,
gamma_tolerance,
)?
};
let (ci_hi, hi_at_bound) = if representative.gamma == 1.0 {
(1.0, true)
} else {
closure_profile_ci_side(
&evaluate_score,
representative.gamma,
target,
1.0,
&stationary_abscissae,
gamma_tolerance,
)?
};
let singular_boundary = representative.support_collapsed;
let ci = gam_geometry::ClosureProfileCi {
gamma_hat: representative.gamma,
ci_lo,
ci_hi,
ci_includes_circle: hi_at_bound,
ci_includes_interval: lo_at_bound,
singular_boundary,
};
Ok(ClosureSelection {
ci,
representative,
stationarity,
route_to_mixture_rung: singular_boundary,
})
}
#[cfg(test)]
mod tests {
use super::*;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
#[derive(Clone)]
struct SyntheticRaceCandidate {
seed: u64,
len: usize,
}
fn synthetic_fit(candidate: SyntheticRaceCandidate) -> Vec<u64> {
(0..candidate.len)
.into_par_iter()
.map(|i| {
let x = candidate.seed ^ (i as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15);
x.rotate_left((i % 31) as u32)
.wrapping_mul(0xbf58_476d_1ce4_e5b9)
})
.collect()
}
#[test]
fn fixed_topology_parser_requires_canonical_ordered_density_names() {
let canonical = [
AutoTopologyKind::Euclidean,
AutoTopologyKind::Circle,
AutoTopologyKind::Sphere,
AutoTopologyKind::Torus,
AutoTopologyKind::Cylinder,
AutoTopologyKind::ProjectivePlane,
AutoTopologyKind::KleinBottle,
AutoTopologyKind::Mobius,
AutoTopologyKind::DuchonSheet,
AutoTopologyKind::ConstantCurvature,
AutoTopologyKind::Mixture { k: 7 },
AutoTopologyKind::RingOfClusters { k: 5 },
AutoTopologyKind::Union {
structure: UnionStructure::CircleCircle,
},
AutoTopologyKind::Union {
structure: UnionStructure::CirclePointCluster,
},
AutoTopologyKind::Union {
structure: UnionStructure::LineCluster,
},
];
for kind in canonical {
let displayed = kind.display_name();
assert_eq!(
AutoTopologyKind::parse(&displayed),
Ok(kind),
"display/parse must be a bijection for {displayed:?}"
);
}
for malformed in [
" circle",
"circle ",
"Circle",
"MOBIUS",
"flat",
"euclideanpatch",
"euclidean_patch",
"periodic",
"s1",
"s2",
"duchon",
"duchonsheet",
"duchon-sheet",
"thin_plate",
"thinplate",
"curv",
"curvature",
"mkappa",
"m_kappa",
"constant-curvature",
"mixture",
"mixture7",
"mixture_7",
"mixture-k7",
"mixture_k",
"mixture_k7junk",
"mixture_k07",
"ring_clusters",
"ring_clusters7",
"ring_clusters-k7",
"ring_clusters_k",
"ring_clusters_k7junk",
"ring_clusters_k07",
"union-circle+circle",
"union_circle_circle",
"union__circle_circle",
"union_circle+point+cluster",
"union_circle+pointcluster",
"union_line+point+cluster",
"union_line+pointcluster",
] {
assert!(
AutoTopologyKind::parse(malformed).is_err(),
"{malformed:?} must not alias a fixed candidate or adaptive class"
);
}
}
#[test]
fn predictive_candidate_names_distinguish_fixed_fits_from_adaptive_classes() {
assert_eq!(
PredictiveCandidateKind::Fixed(AutoTopologyKind::Mixture { k: 7 }).display_name(),
"mixture_k7"
);
assert_eq!(
PredictiveCandidateKind::MixtureClass.display_name(),
"mixture_class"
);
assert_eq!(
PredictiveCandidateKind::RingOfClustersClass.display_name(),
"ring_clusters_class"
);
}
#[test]
fn topology_race_parallel_matches_sequential_synthetic_candidates() {
let candidates = vec![
SyntheticRaceCandidate { seed: 11, len: 64 },
SyntheticRaceCandidate { seed: 29, len: 64 },
SyntheticRaceCandidate { seed: 47, len: 64 },
];
let sequential = candidates
.iter()
.cloned()
.map(synthetic_fit)
.collect::<Vec<_>>();
let parallel =
run_topology_race_parallel_with_budget(candidates, synthetic_fit, 8).unwrap();
assert_eq!(parallel.len(), 3);
assert_eq!(
parallel
.iter()
.map(|row| row.candidate_index)
.collect::<Vec<_>>(),
vec![0, 1, 2]
);
assert!(parallel.iter().all(|row| row.per_fit_threads == 1));
let wall_times = parallel.iter().map(|row| row.wall_time).collect::<Vec<_>>();
assert_eq!(wall_times.len(), 3);
assert_eq!(
parallel
.into_iter()
.map(|row| row.result)
.collect::<Vec<_>>(),
sequential
);
}
fn trivial_provider<'a>() -> HeldOutDensityProvider<'a> {
Box::new(|_train: &[usize], eval: &[usize]| Ok(vec![0.0; eval.len()]))
}
#[test]
fn adaptive_rungs_reject_out_of_class_orders_before_fitting() {
let data = Array2::<f64>::zeros((4, 2));
let free_error =
fit_free_cluster_rung(data.view(), &[1, 2], GaussianMixtureConfig::default())
.expect_err("the free-cluster class must own k >= 2 inside the rung");
assert!(matches!(
&free_error,
AdaptiveRungError::InvalidInput {
kind: AdaptiveRungKind::GaussianMixture,
..
}
));
assert!(free_error.to_string().contains("require 2 <= k <= n"));
let ring_error =
fit_ring_of_clusters_rung(data.view(), &[2, 3], GaussianMixtureConfig::default())
.expect_err("the ring-cluster class must own k >= 3 inside the rung");
assert!(matches!(
&ring_error,
AdaptiveRungError::InvalidInput {
kind: AdaptiveRungKind::RingOfClusters,
..
}
));
assert!(ring_error.to_string().contains("require 3 <= k <= n"));
let oversized = fit_mixture_rung(data.view(), &[5], GaussianMixtureConfig::default())
.expect_err("orders above n must not disappear from the requested estimand");
assert!(oversized.to_string().contains("require 1 <= k <= n"));
let duplicate = fit_mixture_rung(data.view(), &[1, 1], GaussianMixtureConfig::default())
.expect_err("duplicate orders must not be silently deduplicated");
assert!(duplicate.to_string().contains("duplicate k=1"));
}
#[test]
fn adaptive_rungs_expose_every_eligible_coarse_fit_failure() {
let data = Array2::<f64>::zeros((4, 2));
let invalid_config = GaussianMixtureConfig {
max_iter: 0,
..GaussianMixtureConfig::default()
};
let mixture_error = fit_mixture_rung(data.view(), &[1, 2], invalid_config)
.expect_err("one surviving order must not hide another order's failed fit");
match mixture_error {
AdaptiveRungError::OrderFailures { kind, failures } => {
assert_eq!(kind, AdaptiveRungKind::GaussianMixture);
assert_eq!(
failures.iter().map(|failure| failure.k).collect::<Vec<_>>(),
vec![1, 2]
);
assert!(
failures
.iter()
.all(|failure| failure.stage == AdaptiveRungFailureStage::Fit)
);
}
other => panic!("expected typed per-order failures, got {other:?}"),
}
let ring_error = fit_ring_of_clusters_rung(data.view(), &[3, 4], invalid_config)
.expect_err("ring orders must also fail closed");
match ring_error {
AdaptiveRungError::OrderFailures { kind, failures } => {
assert_eq!(kind, AdaptiveRungKind::RingOfClusters);
assert_eq!(
failures.iter().map(|failure| failure.k).collect::<Vec<_>>(),
vec![3, 4]
);
}
other => panic!("expected typed per-order failures, got {other:?}"),
}
}
#[test]
fn held_out_mixture_candidates_never_change_their_declared_order() {
let data = Array2::<f64>::zeros((4, 2));
let mixture = mixture_density_provider(data.view(), 3, GaussianMixtureConfig::default());
let error = mixture(&[0, 1], &[2])
.expect_err("a k=3 candidate must not silently become k=2 on a short fold");
assert!(error.contains("fixed-order mixture k=3"), "{error}");
let ring =
ring_of_clusters_density_provider(data.view(), 3, GaussianMixtureConfig::default());
let error = ring(&[0, 1], &[2])
.expect_err("a k=3 ring candidate must not silently change order on a short fold");
assert!(
error.contains("fixed-order ring-of-clusters k=3"),
"{error}"
);
let invalid_index =
mixture_density_provider(data.view(), 1, GaussianMixtureConfig::default());
let error = invalid_index(&[0, 9], &[1])
.expect_err("public density providers must reject row indices instead of panicking");
assert!(error.contains("out of bounds"), "{error}");
}
#[test]
fn same_class_race_respects_enclosure_decision_margin() {
let near = vec![
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Circle),
negative_log_evidence: 100.0,
certification: EvidenceCertification::Enclosure { gap: 1.0 },
density_provider: trivial_provider(),
},
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Euclidean),
negative_log_evidence: 100.5,
certification: EvidenceCertification::Enclosure { gap: 1.0 },
density_provider: trivial_provider(),
},
];
let verdict = adjudicate_predictive_race(
8,
near,
STACKING_CV_FOLDS,
STACKING_CV_SEED,
StackingConfig::default(),
)
.expect("same-class race");
assert!(!verdict.is_cross_class);
assert_eq!(verdict.winner_index, 0);
let escalation = verdict
.insufficient_margin
.expect("lead inside the enclosure gap must be flagged provisional");
assert_eq!(escalation.provisional_winner, 0);
assert_eq!(escalation.contender, 1);
assert!((escalation.lead - 0.5).abs() < 1e-12);
assert!((escalation.required_margin - 1.0).abs() < 1e-12);
let far = vec![
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Circle),
negative_log_evidence: 100.0,
certification: EvidenceCertification::Enclosure { gap: 1.0 },
density_provider: trivial_provider(),
},
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Euclidean),
negative_log_evidence: 105.0,
certification: EvidenceCertification::Enclosure { gap: 1.0 },
density_provider: trivial_provider(),
},
];
let verdict_far = adjudicate_predictive_race(
8,
far,
STACKING_CV_FOLDS,
STACKING_CV_SEED,
StackingConfig::default(),
)
.expect("same-class race");
assert_eq!(verdict_far.winner_index, 0);
assert!(
verdict_far.insufficient_margin.is_none(),
"a lead clearing the enclosure gap must transfer the verdict"
);
}
#[test]
fn same_class_race_respects_coreset_transfer_margin() {
let cert = CoresetCertificate::new(0.05, 0.1, 32, 1000).expect("certificate");
let required = cert.race_transfer_margin();
let lead = 0.5 * required;
let candidates = vec![
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Circle),
negative_log_evidence: 10.0,
certification: EvidenceCertification::Coreset { certificate: cert },
density_provider: trivial_provider(),
},
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Euclidean),
negative_log_evidence: 10.0 + lead,
certification: EvidenceCertification::Coreset { certificate: cert },
density_provider: trivial_provider(),
},
];
let verdict = adjudicate_predictive_race(
8,
candidates,
STACKING_CV_FOLDS,
STACKING_CV_SEED,
StackingConfig::default(),
)
.expect("same-class race");
let escalation = verdict
.insufficient_margin
.expect("lead inside the coreset transfer margin must be flagged");
assert!((escalation.required_margin - required).abs() < 1e-9);
}
#[test]
fn adaptive_discrete_class_race_uses_honest_predictive_stacking() {
let candidates = vec![
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::MixtureClass,
negative_log_evidence: 100.0,
certification: EvidenceCertification::Exact,
density_provider: Box::new(|_, eval| Ok(vec![0.0; eval.len()])),
},
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::RingOfClustersClass,
negative_log_evidence: 0.0,
certification: EvidenceCertification::Exact,
density_provider: Box::new(|_, eval| Ok(vec![-20.0; eval.len()])),
},
];
let verdict = adjudicate_predictive_race(
10,
candidates,
5,
STACKING_CV_SEED,
StackingConfig::default(),
)
.expect("adaptive discrete-class race");
assert!(!verdict.is_cross_class);
assert_eq!(verdict.headline, Headline::Stacking);
assert!(verdict.stacking.is_some());
assert_eq!(verdict.winner_index, 0);
assert_eq!(
verdict.candidate_names,
vec![
"mixture_class".to_string(),
"ring_clusters_class".to_string()
]
);
}
#[test]
fn predictive_race_rejects_duplicate_columns() {
let duplicate = vec![
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Circle),
negative_log_evidence: 1.0,
certification: EvidenceCertification::Exact,
density_provider: trivial_provider(),
},
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Circle),
negative_log_evidence: 2.0,
certification: EvidenceCertification::Exact,
density_provider: trivial_provider(),
},
];
let error = adjudicate_predictive_race(
8,
duplicate,
4,
STACKING_CV_SEED,
StackingConfig::default(),
)
.expect_err("duplicate predictive columns make stacking non-identifiable");
assert!(error.contains("duplicate candidate \"circle\""), "{error}");
}
#[test]
fn predictive_race_rejects_nonfinite_evidence_before_adjudication() {
for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let candidates = vec![
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Circle),
negative_log_evidence: 1.0,
certification: EvidenceCertification::Exact,
density_provider: trivial_provider(),
},
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Euclidean),
negative_log_evidence: invalid,
certification: EvidenceCertification::Exact,
density_provider: trivial_provider(),
},
];
let error = adjudicate_predictive_race(
8,
candidates,
4,
STACKING_CV_SEED,
StackingConfig::default(),
)
.expect_err("a non-finite candidate must not be skipped in favor of index zero");
assert!(error.contains("candidate 1"), "{error}");
assert!(
error.contains("non-finite negative-log-evidence"),
"{error}"
);
}
}
#[test]
fn predictive_race_rejects_invalid_certification_margins() {
for invalid in [f64::NAN, f64::INFINITY, -1.0] {
let candidates = vec![
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Circle),
negative_log_evidence: 1.0,
certification: EvidenceCertification::Enclosure { gap: invalid },
density_provider: trivial_provider(),
},
PredictiveRaceCandidate {
kind: PredictiveCandidateKind::Fixed(AutoTopologyKind::Euclidean),
negative_log_evidence: 2.0,
certification: EvidenceCertification::Exact,
density_provider: trivial_provider(),
},
];
let error = adjudicate_predictive_race(
8,
candidates,
4,
STACKING_CV_SEED,
StackingConfig::default(),
)
.expect_err("invalid uncertainty cannot define a race margin");
assert!(error.contains("candidate 0"), "{error}");
assert!(error.contains("finite and nonnegative"), "{error}");
}
}
#[test]
fn cv_folds_are_seed_reproducible_and_seed_varying() {
const N: usize = 40;
const FOLDS: usize = 5;
fn fold_of_sample(n: usize, partition: &[(Vec<usize>, Vec<usize>)]) -> Vec<Option<usize>> {
let mut assign = vec![None; n];
for (fold, (_train, eval)) in partition.iter().enumerate() {
for &i in eval {
assign[i] = Some(fold);
}
}
assign
}
let a1 = deterministic_cv_folds_seeded(N, FOLDS, 11);
let a2 = deterministic_cv_folds_seeded(N, FOLDS, 11);
assert_eq!(
fold_of_sample(N, &a1),
fold_of_sample(N, &a2),
"same seed must reproduce the identical CV folding"
);
let b = deterministic_cv_folds_seeded(N, FOLDS, 12);
assert_ne!(
fold_of_sample(N, &a1),
fold_of_sample(N, &b),
"different seeds must produce different fold assignments (seed must \
not be a no-op)"
);
assert_eq!(
fold_of_sample(N, &deterministic_cv_folds(N, FOLDS)),
fold_of_sample(
N,
&deterministic_cv_folds_seeded(N, FOLDS, STACKING_CV_SEED)
),
"deterministic_cv_folds must equal the default-seeded folding"
);
let balanced = deterministic_cv_folds_seeded(43, FOLDS, 17);
assert_eq!(balanced.len(), FOLDS);
let eval_sizes = balanced
.iter()
.map(|(_, eval)| eval.len())
.collect::<Vec<_>>();
assert_eq!(eval_sizes.iter().sum::<usize>(), 43);
assert_eq!(eval_sizes.iter().copied().min(), Some(8));
assert_eq!(eval_sizes.iter().copied().max(), Some(9));
assert!(deterministic_cv_folds_seeded(5, 1, 11).is_empty());
assert!(deterministic_cv_folds_seeded(5, 6, 11).is_empty());
let providers = vec![trivial_provider()];
let error = build_cv_log_density_table(5, 1, 11, &providers)
.expect_err("selection must reject rather than silently clamp invalid fold counts");
assert!(error.contains("2 <= folds <= n"), "{error}");
}
#[test]
fn race_verdict_maps_onto_unified_ladder() {
use gam_problem::topology_certificates::Verdict;
assert_eq!(
EvidenceCertification::Exact.race_verdict(1e-6),
Verdict::Certified
);
assert_eq!(
EvidenceCertification::Exact.race_verdict(0.0),
Verdict::Insufficient
);
let enc = EvidenceCertification::Enclosure { gap: 0.2 };
assert_eq!(enc.race_verdict(0.5), Verdict::Certified);
assert_eq!(enc.race_verdict(0.1), Verdict::Insufficient);
let cert = CoresetCertificate::new(0.05, 0.1, 32, 1000).expect("certificate");
let required = cert.race_transfer_margin();
let coreset = EvidenceCertification::Coreset { certificate: cert };
assert_eq!(coreset.race_verdict(0.5 * required), Verdict::Insufficient);
assert_eq!(
coreset.race_verdict(2.0 * required + 1.0),
Verdict::Certified
);
}
#[test]
fn closure_profiler_recovers_interior_minimum_and_ci() {
let selection = profile_closure_within_smooth_class(
|gamma| {
Ok::<_, String>(ClosureProfileFit {
tk_score: 100.0 + 80.0 * (gamma - 0.7).powi(2),
score_gradient: 160.0 * (gamma - 0.7),
score_curvature: 160.0,
support_collapsed: false,
fit_handle: gamma,
})
},
|lo, hi| {
Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
derivative: gam_math::score_opt::ClosedInterval::outward(
160.0 * (lo - 0.7),
160.0 * (hi - 0.7),
),
curvature: gam_math::score_opt::ClosedInterval::outward(160.0, 160.0),
})
},
0.95,
)
.expect("closure profile");
assert!(
(selection.ci.gamma_hat - 0.7).abs() < 0.06,
"γ̂ {}",
selection.ci.gamma_hat
);
assert!(!selection.ci.ci_includes_circle);
assert!(!selection.ci.ci_includes_interval);
assert!(!selection.route_to_mixture_rung);
assert_eq!(selection.stationarity.kind, ClosureOptimumKind::Interior);
assert!(selection.stationarity.projected_gradient <= selection.stationarity.tolerance);
assert!((selection.representative.gamma - selection.ci.gamma_hat).abs() < 1e-12);
}
#[test]
fn closure_profiler_routes_collapse_to_mixture_rung() {
let selection = profile_closure_within_smooth_class(
|gamma| {
Ok::<_, String>(ClosureProfileFit {
tk_score: 10.0 + 25.0 * gamma,
score_gradient: 25.0,
score_curvature: 0.0,
support_collapsed: gamma == 0.0,
fit_handle: gamma,
})
},
|_lo, _hi| {
Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
derivative: gam_math::score_opt::ClosedInterval::outward(25.0, 25.0),
curvature: gam_math::score_opt::ClosedInterval::outward(0.0, 0.0),
})
},
0.95,
)
.expect("closure profile");
assert!(selection.ci.gamma_hat.abs() < 1e-9);
assert!(selection.route_to_mixture_rung);
assert!(selection.ci.ci_includes_interval);
assert_eq!(
selection.stationarity.kind,
ClosureOptimumKind::IntervalBoundary
);
}
#[test]
fn closure_profiler_does_not_infer_collapse_from_gamma_zero() {
let selection = profile_closure_within_smooth_class(
|gamma| {
Ok::<_, String>(ClosureProfileFit {
tk_score: 4.0 + gamma,
score_gradient: 1.0,
score_curvature: 0.0,
support_collapsed: false,
fit_handle: gamma,
})
},
|_lo, _hi| {
Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
derivative: gam_math::score_opt::ClosedInterval::outward(1.0, 1.0),
curvature: gam_math::score_opt::ClosedInterval::outward(0.0, 0.0),
})
},
0.95,
)
.expect("regular interval-boundary profile");
assert_eq!(
selection.stationarity.kind,
ClosureOptimumKind::IntervalBoundary
);
assert!(!selection.ci.singular_boundary);
assert!(!selection.route_to_mixture_rung);
}
#[test]
fn closure_profiler_selects_a_non_lattice_optimum_and_continuous_ci() {
let planted = 0.713_271_828_f64;
let calls = std::sync::atomic::AtomicUsize::new(0);
let selection = profile_closure_within_smooth_class(
|gamma| {
calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let displacement = gamma - planted;
Ok::<_, String>(ClosureProfileFit {
tk_score: 7.0 + 32.0 * displacement * displacement,
score_gradient: 64.0 * displacement,
score_curvature: 64.0,
support_collapsed: false,
fit_handle: gamma,
})
},
|lo, hi| {
Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
derivative: gam_math::score_opt::ClosedInterval::outward(
64.0 * (lo - planted),
64.0 * (hi - planted),
),
curvature: gam_math::score_opt::ClosedInterval::outward(64.0, 64.0),
})
},
0.95,
)
.expect("continuous closure profile");
assert!((selection.representative.gamma - planted).abs() < 1.0e-7);
assert!(selection.ci.ci_lo < planted && selection.ci.ci_hi > planted);
assert_ne!(calls.load(std::sync::atomic::Ordering::Relaxed), 17);
}
#[test]
fn topology_race_thread_plan_bounds_nested_rayon_threads() {
let plan = TopologyRaceThreadPlan::for_budget(3, 8);
assert_eq!(plan.concurrent_fits, 3);
assert!(
plan.concurrent_fits + plan.concurrent_fits * plan.per_fit_threads <= 8,
"plan must bound the one-slot-per-candidate reservation plus per-fit Rayon workers"
);
let small = TopologyRaceThreadPlan::for_budget(3, 2);
assert_eq!(small.concurrent_fits, 1);
assert!(small.concurrent_fits + small.per_fit_threads <= 2);
}
#[test]
fn topology_selector_retains_failed_candidate_records() {
let selector = TopologyAutoSelector::new(Some(vec![
AutoTopologyKind::Circle,
AutoTopologyKind::Torus,
]));
let result = select_topology_with_fit(&selector, |kind| match kind {
AutoTopologyKind::Circle => Err("inner REML stationarity failed".to_string()),
AutoTopologyKind::Torus => Ok(TopologyAutoFitEvidence {
topology_name: "torus".to_string(),
raw_reml: 3.0,
null_dim: 0.0,
null_space_logdet: None,
effective_dim: 2.0,
n_obs: 40,
fit_handle: (),
}),
_ => unreachable!(),
})
.expect("one converged candidate is selectable");
assert_eq!(result.winner().unwrap().topology_name, "torus");
assert_eq!(result.failed.len(), 1);
assert_eq!(result.failed[0].topology_name, "circle");
assert_eq!(result.failed[0].stage, TopologyCandidateFailureStage::Fit);
assert!(result.failed[0].message.contains("stationarity"));
}
fn lifecycle_evidence(
name: &str,
raw_reml: f64,
laml: Option<f64>,
deviance: Option<f64>,
effective_dim: f64,
) -> TopologyCandidateOutcome {
TopologyCandidateOutcome::Fitted(TopologyCandidateEvidence {
name: name.to_string(),
raw_reml,
laml,
deviance,
null_dim: Some(0.0),
null_space_logdet: None,
effective_dim,
basis_size: 4,
n_obs: 20,
})
}
#[test]
fn typed_lifecycle_owns_score_scaling_and_deterministic_winner() {
let result = select_topology_candidate_lifecycle(
vec![
lifecycle_evidence("larger_raw", 5.0, Some(5.0), Some(6.0), 10.0),
lifecycle_evidence("smaller_raw", 3.0, Some(3.0), Some(4.0), 2.0),
],
TopologySelectionScoreKind::Reml,
TopologySelectionScoreScale::PerEffectiveDim,
)
.expect("typed lifecycle");
assert_eq!(result.winner_index, Some(0));
assert_eq!(result.ranked[0].name, "larger_raw");
assert!((result.ranked[0].score - 0.5).abs() < 1.0e-12);
assert_eq!(result.ranked[1].name, "smaller_raw");
assert!((result.ranked[1].score - 1.5).abs() < 1.0e-12);
}
#[test]
fn typed_lifecycle_converts_bad_evidence_without_losing_other_failures() {
let result = select_topology_candidate_lifecycle(
vec![
TopologyCandidateOutcome::Failed(TopologyCandidateFailure {
name: "assembly_bad".to_string(),
stage: TopologyCandidateFailureStage::Assembly,
error_type: "ValueError".to_string(),
message: "dimension mismatch".to_string(),
evidence_at_failure: None,
}),
lifecycle_evidence("evidence_bad", f64::NAN, None, None, 2.0),
lifecycle_evidence("winner", 2.0, None, None, 2.0),
],
TopologySelectionScoreKind::Reml,
TopologySelectionScoreScale::Raw,
)
.expect("candidate-local evidence failure");
assert_eq!(result.ranked.len(), 1);
assert_eq!(result.ranked[0].name, "winner");
assert_eq!(result.failed.len(), 2);
assert_eq!(
result.failed[0].stage,
TopologyCandidateFailureStage::Assembly
);
assert_eq!(
result.failed[1].stage,
TopologyCandidateFailureStage::Evidence
);
assert!(result.failed[1].message.contains("non-finite REML"));
}
#[test]
fn typed_lifecycle_rejects_duplicate_terminal_outcomes() {
let error = select_topology_candidate_lifecycle(
vec![
lifecycle_evidence("circle", 1.0, None, None, 1.0),
lifecycle_evidence("circle", 2.0, None, None, 1.0),
],
TopologySelectionScoreKind::Reml,
TopologySelectionScoreScale::Raw,
)
.expect_err("duplicate candidate must be structural error");
assert!(error.contains("duplicate topology candidate"));
}
#[test]
fn fuse_cc_family_collapses_euclidean_and_sphere() {
let input = vec![
AutoTopologyKind::Circle,
AutoTopologyKind::Euclidean,
AutoTopologyKind::Torus,
AutoTopologyKind::Sphere,
];
let fused = AutoTopologyKind::fuse_constant_curvature_family(&input);
assert_eq!(
fused,
vec![
AutoTopologyKind::Circle,
AutoTopologyKind::ConstantCurvature, AutoTopologyKind::Torus,
],
"fused candidates: {fused:?}"
);
}
#[test]
fn fuse_cc_family_leaves_single_form_intact() {
let euclidean_only = vec![AutoTopologyKind::Euclidean, AutoTopologyKind::Circle];
let fused = AutoTopologyKind::fuse_constant_curvature_family(&euclidean_only);
assert_eq!(fused, euclidean_only, "single fixed form must not be fused");
let sphere_only = vec![AutoTopologyKind::Sphere];
let fused2 = AutoTopologyKind::fuse_constant_curvature_family(&sphere_only);
assert_eq!(fused2, sphere_only);
}
#[test]
fn fuse_cc_family_explicit_cc_absorbs_fixed_forms() {
let input = vec![
AutoTopologyKind::ConstantCurvature,
AutoTopologyKind::Euclidean,
AutoTopologyKind::Circle,
];
let fused = AutoTopologyKind::fuse_constant_curvature_family(&input);
assert_eq!(
fused,
vec![
AutoTopologyKind::ConstantCurvature,
AutoTopologyKind::Circle
],
"explicit CC must absorb the fixed Euclidean form"
);
}
#[test]
fn fuse_cc_family_is_idempotent() {
let input = vec![
AutoTopologyKind::Circle,
AutoTopologyKind::ConstantCurvature,
AutoTopologyKind::Torus,
];
let once = AutoTopologyKind::fuse_constant_curvature_family(&input);
let twice = AutoTopologyKind::fuse_constant_curvature_family(&once);
assert_eq!(once, twice, "fuse must be idempotent");
assert_eq!(once, input, "already-fused list must be unchanged");
}
#[test]
fn fuse_cc_family_noop_for_non_cc_list() {
let input = vec![
AutoTopologyKind::Circle,
AutoTopologyKind::Torus,
AutoTopologyKind::Cylinder,
];
let fused = AutoTopologyKind::fuse_constant_curvature_family(&input);
assert_eq!(fused, input);
}
}