1use crate::basis::{
2 BSplineIdentifiability, CenterStrategy, ConstantCurvatureIdentifiability,
3 MaternIdentifiability, MeasureJetIdentifiability, SpatialIdentifiability,
4 SphericalSplineIdentifiability,
5};
6
7use super::{
8 ByVarKind, FactorSmoothFlavour, SmoothBasisSpec, SmoothTermSpec, TensorBSplineIdentifiability,
9};
10
11use std::collections::BTreeSet;
12
13fn smooth_basis_feature_cols(basis: &SmoothBasisSpec) -> Vec<usize> {
14 match basis {
15 SmoothBasisSpec::ByVariable { inner, by_col, .. }
16 | SmoothBasisSpec::FactorSumToZero { inner, by_col, .. } => {
17 let mut cols = smooth_basis_feature_cols(inner);
18 cols.push(*by_col);
19 cols.sort_unstable();
20 cols.dedup();
21 cols
22 }
23 SmoothBasisSpec::BySmooth { smooth, .. } => smooth_basis_feature_cols(smooth),
24 SmoothBasisSpec::BSpline1D { feature_col, .. } => vec![*feature_col],
25 SmoothBasisSpec::ThinPlate { feature_cols, .. }
26 | SmoothBasisSpec::Sphere { feature_cols, .. }
27 | SmoothBasisSpec::ConstantCurvature { feature_cols, .. }
28 | SmoothBasisSpec::Matern { feature_cols, .. }
29 | SmoothBasisSpec::MeasureJet { feature_cols, .. }
30 | SmoothBasisSpec::Duchon { feature_cols, .. }
31 | SmoothBasisSpec::Pca { feature_cols, .. }
32 | SmoothBasisSpec::TensorBSpline { feature_cols, .. } => feature_cols.clone(),
33 SmoothBasisSpec::FactorSmooth { spec } => {
34 let mut cols = spec.continuous_cols.clone();
35 cols.push(spec.group_col);
36 cols.sort_unstable();
37 cols.dedup();
38 cols
39 }
40 }
41}
42
43pub fn smooth_term_feature_cols(term: &SmoothTermSpec) -> Vec<usize> {
44 smooth_basis_feature_cols(&term.basis)
45}
46
47fn smooth_basis_family_rank(term: &SmoothTermSpec) -> u8 {
48 match &term.basis {
49 SmoothBasisSpec::ByVariable { inner, .. }
50 | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
51 smooth_basis_family_rank(&SmoothTermSpec {
52 frozen_parametric_residualization: None,
53 name: term.name.clone(),
54 basis: (**inner).clone(),
55 shape: term.shape,
56 joint_null_rotation: None,
57 })
58 }
59 SmoothBasisSpec::BSpline1D { .. } => 0,
60 SmoothBasisSpec::TensorBSpline { .. } => 1,
61 SmoothBasisSpec::ThinPlate { .. } => 2,
62 SmoothBasisSpec::Sphere { .. } => 3,
63 SmoothBasisSpec::Matern { .. } => 4,
64 SmoothBasisSpec::Duchon { .. } => 5,
65 SmoothBasisSpec::Pca { .. } => 6,
66 SmoothBasisSpec::ConstantCurvature { .. } => 8,
67 SmoothBasisSpec::MeasureJet { .. } => 9,
68 SmoothBasisSpec::BySmooth { smooth, .. } => smooth_basis_family_rank(&SmoothTermSpec {
69 frozen_parametric_residualization: None,
70 name: term.name.clone(),
71 basis: (**smooth).clone(),
72 shape: term.shape,
73 joint_null_rotation: None,
74 }),
75 SmoothBasisSpec::FactorSmooth { .. } => 7,
76 }
77}
78
79pub fn smooth_has_frozen_identifiability(term: &SmoothTermSpec) -> bool {
80 match &term.basis {
81 SmoothBasisSpec::ByVariable { inner, .. }
82 | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
83 smooth_has_frozen_identifiability(&SmoothTermSpec {
84 frozen_parametric_residualization: None,
85 name: term.name.clone(),
86 basis: (**inner).clone(),
87 shape: term.shape,
88 joint_null_rotation: None,
89 })
90 }
91 SmoothBasisSpec::BSpline1D { spec, .. } => {
92 matches!(
93 spec.identifiability,
94 BSplineIdentifiability::FrozenTransform { .. }
95 )
96 }
97 SmoothBasisSpec::ThinPlate { spec, .. } => matches!(
98 spec.identifiability,
99 SpatialIdentifiability::FrozenTransform { .. }
100 ),
101 SmoothBasisSpec::Sphere { spec, .. } => {
102 matches!(spec.center_strategy, CenterStrategy::UserProvided(_))
103 || matches!(
104 spec.identifiability,
105 SphericalSplineIdentifiability::FrozenTransform { .. }
106 )
107 }
108 SmoothBasisSpec::ConstantCurvature { spec, .. } => {
109 matches!(spec.center_strategy, CenterStrategy::UserProvided(_))
110 || matches!(
111 spec.identifiability,
112 ConstantCurvatureIdentifiability::FrozenTransform { .. }
113 )
114 }
115 SmoothBasisSpec::MeasureJet { spec, .. } => {
116 matches!(spec.center_strategy, CenterStrategy::UserProvided(_))
117 || matches!(
118 spec.identifiability,
119 MeasureJetIdentifiability::FrozenTransform { .. }
120 )
121 }
122 SmoothBasisSpec::Matern { spec, .. } => matches!(
123 spec.identifiability,
124 MaternIdentifiability::FrozenTransform { .. }
125 ),
126 SmoothBasisSpec::BySmooth { by_kind, .. } => match by_kind {
127 ByVarKind::Factor { frozen_levels, .. } => frozen_levels.is_some(),
128 ByVarKind::Numeric { .. } => true,
129 },
130 SmoothBasisSpec::FactorSmooth { spec } => spec.group_frozen_levels.is_some(),
131 SmoothBasisSpec::Duchon { spec, .. } => matches!(
132 spec.identifiability,
133 SpatialIdentifiability::FrozenTransform { .. }
134 ),
135 SmoothBasisSpec::Pca {
136 centered,
137 center_mean,
138 pca_basis_path,
139 ..
140 } => !*centered || center_mean.is_some() || pca_basis_path.is_some(),
141 SmoothBasisSpec::TensorBSpline { spec, .. } => matches!(
142 spec.identifiability,
143 TensorBSplineIdentifiability::FrozenTransform { .. }
144 ),
145 }
146}
147
148fn compare_smooth_ownership_priority(
149 lhs_idx: usize,
150 lhs: &SmoothTermSpec,
151 rhs_idx: usize,
152 rhs: &SmoothTermSpec,
153) -> std::cmp::Ordering {
154 let lhs_cols = smooth_term_feature_cols(lhs);
155 let rhs_cols = smooth_term_feature_cols(rhs);
156 lhs_cols
157 .len()
158 .cmp(&rhs_cols.len())
159 .then_with(|| lhs_cols.cmp(&rhs_cols))
160 .then_with(|| smooth_basis_family_rank(lhs).cmp(&smooth_basis_family_rank(rhs)))
161 .then_with(|| lhs.name.cmp(&rhs.name))
162 .then(lhs_idx.cmp(&rhs_idx))
163}
164
165fn factor_by_level_gate_of(term: &SmoothTermSpec) -> Option<(usize, u64)> {
174 match &term.basis {
175 SmoothBasisSpec::ByVariable {
176 by_col,
177 by: crate::smooth::ByVariableSpec::Level { value_bits, .. },
178 ..
179 } => Some((*by_col, *value_bits)),
180 _ => None,
181 }
182}
183
184fn factor_sum_to_zero_group_col(term: &SmoothTermSpec) -> Option<usize> {
203 match &term.basis {
204 SmoothBasisSpec::FactorSumToZero { by_col, .. } => Some(*by_col),
205 SmoothBasisSpec::FactorSmooth { spec }
206 if matches!(spec.flavour, FactorSmoothFlavour::Sz) =>
207 {
208 Some(spec.group_col)
209 }
210 _ => None,
211 }
212}
213
214fn smooth_is_owned_by_prior_term(owner: &SmoothTermSpec, target: &SmoothTermSpec) -> bool {
215 if let Some(target_gate) = factor_by_level_gate_of(target) {
223 if factor_by_level_gate_of(owner) != Some(target_gate) {
224 return false;
225 }
226 }
227 if let Some(group_col) = factor_sum_to_zero_group_col(target) {
233 let owner_features = smooth_term_feature_cols(owner)
234 .into_iter()
235 .collect::<BTreeSet<_>>();
236 if !owner_features.contains(&group_col) {
237 return false;
238 }
239 }
240 let owner_features = smooth_term_feature_cols(owner)
241 .into_iter()
242 .collect::<BTreeSet<_>>();
243 let target_features = smooth_term_feature_cols(target)
244 .into_iter()
245 .collect::<BTreeSet<_>>();
246 owner_features.is_subset(&target_features)
247}
248
249pub struct SmoothStructureAnalysis {
260 pub ownership_order: Vec<usize>,
263 pub term_feature_cols: Vec<Vec<usize>>,
266 pub term_owners: Vec<Vec<usize>>,
270}
271
272pub fn analyze_smooth_ownership(smoothspecs: &[SmoothTermSpec]) -> SmoothStructureAnalysis {
276 let term_feature_cols: Vec<Vec<usize>> =
277 smoothspecs.iter().map(smooth_term_feature_cols).collect();
278
279 let mut ownership_order: Vec<usize> = (0..smoothspecs.len()).collect();
280 ownership_order.sort_by(|&lhs, &rhs| {
281 compare_smooth_ownership_priority(lhs, &smoothspecs[lhs], rhs, &smoothspecs[rhs])
282 });
283
284 let mut term_owners = vec![Vec::<usize>::new(); smoothspecs.len()];
285 for (pos, &target_idx) in ownership_order.iter().enumerate() {
286 let target = &smoothspecs[target_idx];
287 term_owners[target_idx] = ownership_order[..pos]
288 .iter()
289 .copied()
290 .filter(|&owner_idx| smooth_is_owned_by_prior_term(&smoothspecs[owner_idx], target))
291 .collect();
292 }
293
294 SmoothStructureAnalysis {
295 ownership_order,
296 term_feature_cols,
297 term_owners,
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::basis::{BSplineBasisSpec, BSplineKnotSpec, OneDimensionalBoundary};
305 use crate::smooth::{BySmoothKind, ByVariableSpec, ShapeConstraint};
306
307 fn bspline(feature_col: usize) -> SmoothBasisSpec {
308 SmoothBasisSpec::BSpline1D {
309 feature_col,
310 spec: BSplineBasisSpec {
311 degree: 3,
312 penalty_order: 2,
313 knotspec: BSplineKnotSpec::Generate {
314 data_range: (0.0, 1.0),
315 num_internal_knots: 5,
316 },
317 double_penalty: false,
318 identifiability: BSplineIdentifiability::None,
319 boundary: OneDimensionalBoundary::Open,
320 boundary_conditions: Default::default(),
321 },
322 }
323 }
324
325 fn term(name: &str, basis: SmoothBasisSpec) -> SmoothTermSpec {
326 SmoothTermSpec {
327 frozen_parametric_residualization: None,
328 name: name.to_string(),
329 basis,
330 shape: ShapeConstraint::None,
331 joint_null_rotation: None,
332 }
333 }
334
335 fn level_by_term(
336 name: &str,
337 feature_col: usize,
338 by_col: usize,
339 level_bits: u64,
340 ) -> SmoothTermSpec {
341 term(
342 name,
343 SmoothBasisSpec::ByVariable {
344 inner: Box::new(bspline(feature_col)),
345 by_col,
346 kind: BySmoothKind::Level { level_bits },
347 by: ByVariableSpec::Level {
348 value_bits: level_bits,
349 label: name.to_string(),
350 },
351 },
352 )
353 }
354
355 #[test]
356 fn ungated_smooth_does_not_own_factor_by_level_smooth() {
357 let specs = vec![term("s(x)", bspline(0)), level_by_term("s(x):B", 0, 1, 42)];
358
359 let analysis = analyze_smooth_ownership(&specs);
360
361 assert_eq!(
362 analysis.term_owners[1],
363 Vec::<usize>::new(),
364 "ungated s(x) must not own the row-gated by-factor deviation smooth"
365 );
366 }
367
368 #[test]
369 fn same_factor_by_level_gate_keeps_normal_subset_ownership() {
370 let specs = vec![
371 level_by_term("s(x):B", 0, 2, 42),
372 term(
373 "te(x,z):B",
374 SmoothBasisSpec::ByVariable {
375 inner: Box::new(SmoothBasisSpec::TensorBSpline {
376 feature_cols: vec![0, 1],
377 spec: Default::default(),
378 }),
379 by_col: 2,
380 kind: BySmoothKind::Level { level_bits: 42 },
381 by: ByVariableSpec::Level {
382 value_bits: 42,
383 label: "B".to_string(),
384 },
385 },
386 ),
387 ];
388
389 let analysis = analyze_smooth_ownership(&specs);
390
391 assert_eq!(
392 analysis.term_owners[1],
393 vec![0],
394 "matching by-level gates may still use the ordinary nested smooth ownership rule"
395 );
396 }
397}