1use crate::inference::model::{
11 ColumnKindTag, FittedModel, FittedModelPayload, PredictModelClass, SavedDeploymentExtension,
12 SchemaColumn,
13};
14use gam_solve::estimate::{BlockRole, UnifiedFitResult};
15use gam_terms::smooth::TermCollectionSpec;
16use ndarray::{Array1, Array2};
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19
20#[derive(Clone, Debug, Default, Deserialize, Serialize)]
26#[serde(deny_unknown_fields)]
27pub struct ExtendGroupRequest {
28 #[serde(default)]
29 pub kind: Option<String>,
30 #[serde(default)]
31 pub name: Option<String>,
32 #[serde(default)]
33 pub term: Option<String>,
34 #[serde(default)]
35 pub column: Option<String>,
36 #[serde(default)]
37 pub level: Option<serde_json::Value>,
38 #[serde(default)]
39 pub levels: Option<Vec<serde_json::Value>>,
40 #[serde(default)]
41 pub metadata: Option<serde_json::Value>,
42 #[serde(default)]
43 pub prior: Option<serde_json::Value>,
44}
45
46#[derive(Default, Deserialize)]
47#[serde(deny_unknown_fields)]
48struct ExtensionPrior {
49 #[serde(default)]
50 mean: Option<f64>,
51 #[serde(default)]
52 mu: Option<f64>,
53 #[serde(default)]
54 variance: Option<f64>,
55 #[serde(default)]
56 precision: Option<f64>,
57}
58
59impl FittedModel {
60 pub fn extend_with_group(&mut self, request: ExtendGroupRequest) -> Result<(), String> {
68 if !matches!(self.predict_model_class(), PredictModelClass::Standard) {
69 return Err(format!(
70 "extend_with_group currently supports standard GAM models only; got '{}'",
71 self.predict_model_class().name()
72 ));
73 }
74 if self.has_link_wiggle() {
75 return Err("extend_with_group does not support link-wiggle models".to_string());
76 }
77 let ExtendGroupRequest {
78 kind,
79 name,
80 term,
81 column,
82 level,
83 levels,
84 metadata,
85 prior,
86 } = request;
87 let kind = kind
88 .as_deref()
89 .unwrap_or("random-effect-level")
90 .replace('_', "-");
91 if kind != "random-effect-level" {
92 return Err(format!(
93 "extend_with_group supports kind='random-effect-level'; got '{kind}'"
94 ));
95 }
96 let mut levels = levels.unwrap_or_default();
97 if let Some(level) = level {
98 levels.push(level);
99 }
100 if levels.is_empty() {
101 return Err("extend_with_group requires level or levels".to_string());
102 }
103 let term = match term.or(column) {
104 Some(term) => term,
105 None => {
106 let payload = self.payload();
107 let spec = payload.resolved_termspec.as_ref().ok_or_else(|| {
108 "extend_with_group requires saved resolved_termspec; refit".to_string()
109 })?;
110 if spec.random_effect_terms.len() == 1 {
111 spec.random_effect_terms[0].name.clone()
112 } else {
113 return Err(
114 "extend_with_group requires term when the model has zero or multiple group terms"
115 .to_string(),
116 );
117 }
118 }
119 };
120
121 for level in levels {
122 extend_model_with_random_effect_level(
123 self,
124 term.as_str(),
125 name.as_deref(),
126 level,
127 metadata.clone(),
128 prior.clone(),
129 )?;
130 }
131 self.validate_for_persistence()?;
132 self.validate_numeric_finiteness()?;
133 Ok(())
134 }
135}
136
137fn extend_model_with_random_effect_level(
138 model: &mut FittedModel,
139 term_name: &str,
140 requested_name: Option<&str>,
141 level: serde_json::Value,
142 metadata: Option<serde_json::Value>,
143 prior: Option<serde_json::Value>,
144) -> Result<(), String> {
145 let payload: &mut FittedModelPayload = &mut *model;
146 let (term_idx, feature_col, penalty_index) = {
147 let spec = payload.resolved_termspec.as_ref().ok_or_else(|| {
148 "extend_with_group requires saved resolved_termspec; refit".to_string()
149 })?;
150 let term_idx = spec
151 .random_effect_terms
152 .iter()
153 .position(|term| term.name == term_name)
154 .ok_or_else(|| format!("extend_with_group unknown random-effect term '{term_name}'"))?;
155 (
156 term_idx,
157 spec.random_effect_terms[term_idx].feature_col,
158 random_effect_penalty_index(spec, term_idx),
159 )
160 };
161 let schema = payload
162 .data_schema
163 .as_mut()
164 .ok_or_else(|| "extend_with_group requires saved data_schema; refit".to_string())?;
165 let schema_col = schema.columns.get_mut(feature_col).ok_or_else(|| {
166 format!(
167 "extend_with_group term '{term_name}' feature column {feature_col} out of saved schema bounds"
168 )
169 })?;
170 let (level_bits, encoded_value) = level_bits_for_extension(schema_col, &level)?;
171 {
172 let spec = payload.resolved_termspec.as_ref().ok_or_else(|| {
173 "extend_with_group requires saved resolved_termspec; refit".to_string()
174 })?;
175 let levels = spec.random_effect_terms[term_idx]
176 .frozen_levels
177 .as_ref()
178 .ok_or_else(|| {
179 format!(
180 "extend_with_group term '{term_name}' is not frozen; refit with persisted metadata"
181 )
182 })?;
183 if levels.contains(&level_bits) {
184 return Err(format!(
185 "extend_with_group level {} already exists for random-effect term '{term_name}'",
186 compact_json(&level)
187 ));
188 }
189 }
190 if payload.deployment_extensions.iter().any(|extension| {
191 extension.kind == "random-effect-level"
192 && extension.term == term_name
193 && extension.level_bits == level_bits
194 }) {
195 return Err(format!(
196 "extend_with_group level {} is already deployed for random-effect term '{term_name}'",
197 compact_json(&level)
198 ));
199 }
200 let coefficient_index = payload
201 .fit_result
202 .as_ref()
203 .ok_or_else(|| "extend_with_group requires saved fit_result; refit".to_string())?
204 .beta
205 .len();
206 let (coefficient_mean, supplied_variance) = extension_prior_parameters(prior.as_ref())?;
207 let coefficient_variance = match supplied_variance {
208 Some(variance) => variance,
209 None => {
210 let fit = payload
211 .fit_result
212 .as_ref()
213 .ok_or_else(|| "extend_with_group requires saved fit_result; refit".to_string())?;
214 let lambda = fit
215 .lambdas
216 .get(penalty_index)
217 .copied()
218 .filter(|lambda| lambda.is_finite() && *lambda > 0.0)
219 .ok_or_else(|| {
220 format!(
221 "extend_with_group term '{term_name}' has no finite positive prior lambda"
222 )
223 })?;
224 let phi = fit
234 .dispersion_phi()
235 .map_err(|err| format!("cannot resolve unseen-level prior dispersion: {err}"))?;
236 if !(phi.is_finite() && phi > 0.0) {
237 return Err(format!(
238 "extend_with_group term '{term_name}' has a non-finite or non-positive \
239 dispersion (φ̂ = {phi}); cannot form the default prior variance"
240 ));
241 }
242 phi / lambda
243 }
244 };
245 extend_training_feature_range(
246 payload.training_feature_ranges.as_mut(),
247 feature_col,
248 encoded_value,
249 );
250 insert_coefficient_into_saved_fit(
251 payload.fit_result.as_mut(),
252 coefficient_index,
253 coefficient_mean,
254 coefficient_variance,
255 )?;
256 insert_coefficient_into_saved_fit(
257 payload.unified.as_mut(),
258 coefficient_index,
259 coefficient_mean,
260 coefficient_variance,
261 )?;
262
263 let extension_name = requested_name
264 .map(str::to_string)
265 .unwrap_or_else(|| format!("{term_name}:{}", compact_json(&level)));
266 if let Some(metadata_value) = metadata.clone() {
267 let group_metadata = payload.group_metadata.get_or_insert_with(BTreeMap::new);
268 group_metadata.insert(extension_name.clone(), metadata_value.clone());
269 }
270 payload
271 .deployment_extensions
272 .push(SavedDeploymentExtension {
273 name: extension_name,
274 kind: "random-effect-level".to_string(),
275 term: term_name.to_string(),
276 level,
277 level_bits,
278 coefficient_index,
279 coefficient_mean,
280 coefficient_variance,
281 metadata,
282 prior,
283 });
284 Ok(())
285}
286
287fn level_bits_for_extension(
288 schema_col: &mut SchemaColumn,
289 level: &serde_json::Value,
290) -> Result<(u64, f64), String> {
291 match schema_col.kind {
292 ColumnKindTag::Categorical => {
293 let label = match level {
294 serde_json::Value::String(s) => s.clone(),
295 other => compact_json(other),
296 };
297 if schema_col.levels.iter().any(|existing| existing == &label) {
298 return Err(format!(
299 "extend_with_group categorical level '{label}' already exists in column '{}'",
300 schema_col.name
301 ));
302 }
303 let encoded = schema_col.levels.len() as f64;
304 schema_col.levels.push(label);
305 Ok((encoded.to_bits(), encoded))
306 }
307 ColumnKindTag::Continuous | ColumnKindTag::Binary => {
308 let value = json_level_to_f64(level)?;
309 Ok((value.to_bits(), value))
310 }
311 }
312}
313
314fn json_level_to_f64(value: &serde_json::Value) -> Result<f64, String> {
315 let out = match value {
316 serde_json::Value::Number(n) => n
317 .as_f64()
318 .ok_or_else(|| format!("extend_with_group level {n} is not representable as f64"))?,
319 serde_json::Value::String(s) => s
320 .parse::<f64>()
321 .map_err(|_| format!("extend_with_group level '{s}' is not numeric"))?,
322 other => {
323 return Err(format!(
324 "extend_with_group numeric random-effect levels must be numbers or numeric strings; got {}",
325 compact_json(other)
326 ));
327 }
328 };
329 if !out.is_finite() {
330 return Err(format!(
331 "extend_with_group random-effect level must be finite; got {out}"
332 ));
333 }
334 Ok(out)
335}
336
337fn compact_json(value: &serde_json::Value) -> String {
338 serde_json::to_string(value).unwrap_or_else(|error| format!("<unserializable: {error}>"))
339}
340
341fn random_effect_penalty_index(spec: &TermCollectionSpec, term_idx: usize) -> usize {
342 usize::from(spec.linear_terms.iter().any(|term| term.double_penalty)) + term_idx
343}
344
345fn extension_prior_parameters(
346 prior: Option<&serde_json::Value>,
347) -> Result<(f64, Option<f64>), String> {
348 let Some(value) = prior else {
349 return Ok((0.0, None));
350 };
351 if value.is_null() {
352 return Ok((0.0, None));
353 }
354 let parsed: ExtensionPrior = serde_json::from_value(value.clone())
355 .map_err(|err| format!("failed to parse extend_with_group prior: {err}"))?;
356 let mean = parsed.mean.or(parsed.mu).unwrap_or(0.0);
357 if !mean.is_finite() {
358 return Err(format!(
359 "extend_with_group prior mean must be finite; got {mean}"
360 ));
361 }
362 let variance = match (parsed.variance, parsed.precision) {
363 (Some(variance), _) => {
364 if !(variance.is_finite() && variance > 0.0) {
365 return Err(format!(
366 "extend_with_group prior variance must be finite and positive; got {variance}"
367 ));
368 }
369 Some(variance)
370 }
371 (None, Some(precision)) => {
372 if !(precision.is_finite() && precision > 0.0) {
373 return Err(format!(
374 "extend_with_group prior precision must be finite and positive; got {precision}"
375 ));
376 }
377 Some(1.0 / precision)
378 }
379 (None, None) => None,
380 };
381 Ok((mean, variance))
382}
383
384fn extend_training_feature_range(
385 ranges: Option<&mut Vec<(f64, f64)>>,
386 feature_col: usize,
387 value: f64,
388) {
389 if let Some(ranges) = ranges
390 && let Some((lo, hi)) = ranges.get_mut(feature_col)
391 {
392 if value.is_finite() {
393 *lo = (*lo).min(value);
394 *hi = (*hi).max(value);
395 }
396 }
397}
398
399fn insert_coefficient_into_saved_fit(
400 fit: Option<&mut UnifiedFitResult>,
401 index: usize,
402 value: f64,
403 variance: f64,
404) -> Result<(), String> {
405 let Some(fit) = fit else {
406 return Ok(());
407 };
408 if !(variance.is_finite() && variance > 0.0) {
409 return Err(format!(
410 "extend_with_group coefficient variance must be finite and positive; got {variance}"
411 ));
412 }
413 if index > fit.beta.len() {
414 return Err(format!(
415 "extend_with_group coefficient index {index} exceeds fit coefficient length {}",
416 fit.beta.len()
417 ));
418 }
419 fit.beta = insert_array1(&fit.beta, index, value);
420 let block_idx = fit
421 .blocks
422 .iter()
423 .position(|block| block.role == BlockRole::Mean)
424 .unwrap_or(0);
425 if block_idx >= fit.blocks.len() {
426 return Err("extend_with_group saved fit has no coefficient blocks".to_string());
427 }
428 if index > fit.blocks[block_idx].beta.len() {
429 return Err(format!(
430 "extend_with_group coefficient index {index} exceeds mean block length {}",
431 fit.blocks[block_idx].beta.len()
432 ));
433 }
434 fit.blocks[block_idx].beta = insert_array1(&fit.blocks[block_idx].beta, index, value);
435 let (grown_gauge, reduced_index) = match fit.geometry.as_ref() {
454 Some(geometry) => {
455 if geometry.constrained_posterior.is_some() {
456 return Err(
462 "extend_with_group cannot extend a fit carrying an inequality-truncated \
463 posterior geometry: the truncation identity is stated in the pre-extension \
464 active coordinates. Refit with the new level present."
465 .to_string(),
466 );
467 }
468 let gauge = &geometry.coefficient_gauge;
469 let raw_end = gauge.block_starts_raw[block_idx + 1];
470 if index != raw_end {
471 return Err(format!(
472 "extend_with_group appends coefficient {index} but the saved gauge places \
473 block {block_idx}'s raw coordinates at ..{raw_end}; the appended level would \
474 not land in the block whose β was grown"
475 ));
476 }
477 let (grown, reduced_index) = gauge.append_free_coordinate_to_block(block_idx)?;
478 (Some(grown), reduced_index)
479 }
480 None => (None, index),
481 };
482 if let Some(cov) = fit.covariance_conditional.as_mut() {
497 *cov = insert_symmetric_array2(cov, index, variance)?;
498 }
499 if let Some(cov) = fit.covariance_corrected.as_mut() {
500 *cov = insert_symmetric_array2(cov, index, variance)?;
501 }
502 let variance_diag = variance;
503 let precision_diag = 1.0 / variance_diag;
504 if let Some(inference) = fit.inference.as_mut() {
505 inference.penalized_hessian = insert_symmetric_array2(
509 inference.penalized_hessian.as_array(),
510 reduced_index,
511 precision_diag,
512 )?
513 .into();
514 if let Some(cov) = inference.beta_covariance.as_mut() {
515 *cov = insert_symmetric_array2(cov.as_array(), index, variance_diag)?.into();
517 }
518 if let Some(se) = inference.beta_standard_errors.as_mut() {
519 *se = insert_array1(se, index, variance_diag.sqrt());
520 }
521 if let Some(cov) = inference.beta_covariance_corrected.as_mut() {
522 *cov = insert_symmetric_array2(cov, index, variance_diag)?;
523 }
524 if let Some(se) = inference.beta_standard_errors_corrected.as_mut() {
525 *se = insert_array1(se, index, variance_diag.sqrt());
526 }
527 if let Some(cov) = inference.beta_covariance_frequentist.as_mut() {
528 *cov = insert_symmetric_array2(cov, index, 0.0)?;
529 }
530 if let Some(influence) = inference.coefficient_influence.as_mut() {
531 *influence = insert_symmetric_array2(influence, index, 0.0)?;
532 }
533 if let Some(correction) = inference.smoothing_correction.as_mut() {
534 *correction = insert_symmetric_array2(correction, index, 0.0)?;
535 }
536 if let Some(qs) = inference.reparam_qs.as_mut() {
537 *qs = insert_symmetric_array2(qs, index, 1.0)?;
538 }
539 if let Some(bias) = inference.bias_correction_beta.as_mut() {
540 *bias = insert_array1(bias, index, 0.0);
541 }
542 }
543 if let Some(geometry) = fit.geometry.as_mut() {
544 geometry.penalized_hessian = insert_symmetric_array2(
545 geometry.penalized_hessian.as_array(),
546 reduced_index,
547 precision_diag,
548 )?
549 .into();
550 if let Some(gauge) = grown_gauge {
551 geometry.coefficient_gauge = gauge;
552 }
553 }
554 Ok(())
555}
556
557fn insert_array1(values: &Array1<f64>, index: usize, value: f64) -> Array1<f64> {
558 let mut out = Vec::<f64>::with_capacity(values.len() + 1);
559 out.extend(values.iter().take(index).copied());
560 out.push(value);
561 out.extend(values.iter().skip(index).copied());
562 Array1::from_vec(out)
563}
564
565fn insert_symmetric_array2(
566 matrix: &Array2<f64>,
567 index: usize,
568 diagonal: f64,
569) -> Result<Array2<f64>, String> {
570 if matrix.nrows() != matrix.ncols() {
571 return Err(format!(
572 "extend_with_group expected square matrix, got {}x{}",
573 matrix.nrows(),
574 matrix.ncols()
575 ));
576 }
577 if index > matrix.nrows() {
578 return Err(format!(
579 "extend_with_group matrix insert index {index} exceeds dimension {}",
580 matrix.nrows()
581 ));
582 }
583 let n = matrix.nrows();
584 let mut out = Array2::<f64>::zeros((n + 1, n + 1));
585 for old_i in 0..n {
586 let new_i = if old_i < index { old_i } else { old_i + 1 };
587 for old_j in 0..n {
588 let new_j = if old_j < index { old_j } else { old_j + 1 };
589 out[[new_i, new_j]] = matrix[[old_i, old_j]];
590 }
591 }
592 out[[index, index]] = diagonal;
593 Ok(out)
594}