Skip to main content

gam_models/inference/
model_extension.rs

1//! Deployment-time model surgery: extending a fitted model with a new
2//! random-effect group level without a refit.
3//!
4//! This capability used to live entirely inside the PyO3 boundary crate, which
5//! made it reachable from Python only. SPEC rule 9 (CLI / Python / Rust
6//! parity) requires one source of truth, so the typed request and the whole
7//! mutation live here next to [`FittedModel`]; the FFI layer is now a thin
8//! JSON adapter over [`FittedModel::extend_with_group`].
9
10use 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/// A request to extend a fitted model with one or more new group levels.
21///
22/// The field set is the on-wire contract the Python `extend_with_group` API
23/// already spoke; it is plain serde over core types, so the CLI and Rust
24/// library callers can build it directly instead of round-tripping JSON.
25#[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    /// Extend this model in place with the requested random-effect levels.
61    ///
62    /// On success the model has passed both save-time gates
63    /// (`validate_for_persistence` and `validate_numeric_finiteness`), so any
64    /// caller may persist or predict with it directly. On failure the model is
65    /// left partially mutated and must be discarded — callers that need the
66    /// original should clone before calling.
67    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            // The unseen-level default prior is the fitted random-effect
225            // variance component `σ_b² = φ̂ / λ` (mgcv's `λ = φ̂ / σ_b²`
226            // convention), NOT the scale-free `1 / λ`. `φ̂` is the residual
227            // dispersion that scales every predict-time covariance: `1` for
228            // fixed-scale families (Poisson/Binomial — where `φ̂/λ` collapses
229            // to the old `1/λ`), but `σ̂²` for Gaussian and the estimated
230            // dispersion for Gamma/Tweedie/NB. Omitting `φ̂` made the prior
231            // (and any deployment interval built from it) wrong by `1/φ̂` and,
232            // for an estimated scale, not response-scale equivariant. See #674.
233            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    // The saved geometry carries the coefficient gauge, and `UnifiedFitResult`
436    // validation requires the gauge's raw block widths to equal the saved
437    // per-block β widths. Growing `blocks[block_idx].beta` above without
438    // growing the gauge alongside it is exactly the +1 disagreement that
439    // refused nine Python deployment tests with
440    //   "geometry coefficient gauge raw block 0 has width W, expected saved
441    //    beta width W+1"
442    // (5→6, 42→43, 82→83 — always the one appended level). A new unseen
443    // random-effect level is a FREE raw coordinate: it took part in no
444    // identifiability constraint of the fit, so it enters the gauge as an
445    // identity row carrying its own reduced coordinate.
446    //
447    // That reduced coordinate's index is also the only correct insertion point
448    // for the two REDUCED-coordinate objects below. `penalized_hessian` on both
449    // `geometry` and `inference` is validated against `gauge.reduced_total()`
450    // whenever a geometry is present, so inserting at the RAW `index` is only
451    // accidentally right on an identity gauge and is out of bounds as soon as
452    // any block is genuinely reduced.
453    let (grown_gauge, reduced_index) = match fit.geometry.as_ref() {
454        Some(geometry) => {
455            if geometry.constrained_posterior.is_some() {
456                // `constrained_posterior` is the other active-frame object, and
457                // its truncation identity is stated in the pre-extension
458                // coordinates. Widening the frame underneath it would leave a
459                // posterior whose truncation refers to a coordinate system that
460                // no longer exists, so refuse instead.
461                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    // No-refit posterior algebra for a deployment-only block:
483    //
484    // The fitted posterior precision for the original coefficients is H_old.
485    // Extending with a new random-effect coefficient b and no likelihood
486    // refit contributes only its Gaussian prior,
487    //
488    //   -log p(b) = 1/2 (b - mu)' (lambda_new S_new) (b - mu) + const.
489    //
490    // Since no old likelihood rows or old penalties are recomputed, the joint
491    // precision is blockdiag(H_old, lambda_new S_new).  Therefore the
492    // conditional covariance is blockdiag(V_old, S_new^{-1}/lambda_new).  The
493    // current API extends one iid random-effect coordinate at a time, so
494    // S_new = [1] and `variance` is exactly 1/lambda_new, or the caller's
495    // supplied scalar prior covariance.
496    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        // Boundary adapter: `penalized_hessian` is the `UnscaledPrecision`
506        // newtype; unwrap for the `insert_symmetric_array2` helper and wrap
507        // the result back on assignment.
508        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            // `beta_covariance` is the `PhiScaledCovariance` newtype.
516            *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}