Skip to main content

gam_solve/
warm_start_artifact.rs

1//! Cross-fit warm-start artifact: a descriptor-indexed, function-space
2//! snapshot of a converged fit, designed so a *related* later fit (a
3//! leave-one-subject-out fold, a re-fit on a different row population, a
4//! different reduced width) can warm-start from it even though the exact
5//! response-keyed inner cache (`persistent_warm_start.rs`) misses.
6//!
7//! The artifact is keyed by *structural identity*, not by data bytes. Two
8//! fits of the same term family (same role, same variables, same basis kind
9//! and the same STRUCTURAL basis parameters — degree, #centers, nullspace
10//! order, …) map to the same [`TermIdentityKey`] even when their realized
11//! `centers` / `input_scale` / `length_scale` differ across folds. That is
12//! precisely what lets the smoothing parameter ρ transfer survive a fold:
13//! "same term, different rows" matches; "3 PCs vs 10 PCs" or "different
14//! #centers" deliberately does NOT.
15//!
16//! Correctness is free. A warm start only sets the *starting iterate*; the
17//! outer REML/BFGS loop and the inner constrained Newton solve still run to
18//! their KKT certificate, so the converged answer is identical to a cold
19//! start within tolerance. Every field that flows back into the solver is
20//! finite-guarded at consume time; any anomaly falls back to cold.
21
22use gam_runtime::warm_start::key::{Fingerprint, Fingerprinter};
23use serde::{Deserialize, Serialize};
24
25/// On-disk schema version for [`FitArtifact`]. Bump when the serialized
26/// layout changes in a way that makes prior payloads unsafe to consume.
27pub const FIT_ARTIFACT_SCHEMA: u32 = 1;
28
29/// Saturation magnitude past which a copied ρ coordinate is considered
30/// pinned at the outer optimizer's box and is NOT transferred. Mirrors the
31/// persist-side gate in `families/custom_family/persistent_warm_start.rs` and the
32/// `[CACHE] hit-clamp` policy in `solver/outer_strategy.rs`.
33pub(crate) const RHO_SATURATION: f64 = 9.0;
34
35/// Structural role a term plays in the (possibly multi-channel) model.
36///
37/// Derived from the block name / channel at capture time. The role is part
38/// of the term identity so a "mean" smooth never transfers ρ to a
39/// "slope" smooth of the same variables.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
41pub enum TermRole {
42    /// Location / mean channel (the default for a single-channel family).
43    Mean,
44    /// Log-scale / dispersion / slope channel.
45    Slope,
46    /// Any other channel (multinomial categories, frailty, …).
47    Generic,
48}
49
50impl TermRole {
51    /// Stable discriminant byte for hashing.
52    fn discriminant(self) -> u8 {
53        match self {
54            TermRole::Mean => 0,
55            TermRole::Slope => 1,
56            TermRole::Generic => 2,
57        }
58    }
59
60    /// Heuristic role from a block / channel name. Names are produced by the
61    /// family construction layer (e.g. `"<scale>"`, `"slope"`, `"mean"`);
62    /// the classification is structural and deliberately coarse.
63    pub fn from_block_name(name: &str) -> TermRole {
64        let lower = name.to_ascii_lowercase();
65        if lower.contains("slope")
66            || lower.contains("scale")
67            || lower.contains("sigma")
68            || lower.contains("dispersion")
69            || lower.contains("disp")
70        {
71            TermRole::Slope
72        } else if lower.contains("mean") || lower.contains("loc") || lower.contains("marginal") {
73            TermRole::Mean
74        } else {
75            TermRole::Generic
76        }
77    }
78}
79
80/// Stable structural identity of one term, used to match a parent term to a
81/// new-fit term across folds / row populations.
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
83pub struct TermIdentityKey(pub Fingerprint);
84
85/// Build a term identity at the *block-spec* layer (`fit_custom_family` and
86/// friends), where the full `BasisMetadata` / variable names are no longer
87/// reachable — the design has already been assembled into a
88/// `gam_problem::ParameterBlockSpec`.
89///
90/// The block `name` (e.g. `"s(x)"`, `"<scale>"`) is produced by the formula /
91/// construction layer and is **fold-invariant**: it encodes the variables and
92/// basis kind and does not change when rows are dropped for an LOSO fold. The
93/// penalty *structure* (count, precision labels, nullspace dimensions) is also
94/// fold-invariant in SHAPE — only the matrix values change across folds, and
95/// we hash only the structure, never the values. So this identity matches
96/// "same model, different rows" while splitting on a genuine structural change
97/// (a different #penalties, a different label set, a different basis size).
98///
99/// `reduced_width` is the realized per-block coefficient dimension
100/// (`spec.design.ncols()`) — the basis column count *after* the
101/// identifiability reduction, which is the load-bearing dimension of the
102/// block's β. It is fold-invariant within one model (LOSO drops rows, never
103/// columns) but DIFFERS across models whose spatial basis collapses to a lower
104/// effective support (e.g. a duchon marginal that realizes p=21 on one disease
105/// and p=45 on another). Folding it into the identity is what makes a p=37 fit
106/// refuse to match a p=85 artifact: without it, two models with the same block
107/// name / penalty-label / nullspace SHAPE but different realized β-width hash to
108/// the SAME [`TermIdentityKey`] (and hence the same [`FitDescriptor`] key),
109/// producing the spurious "cached inner beta has length 85, but blocks require
110/// length 37" lookups. With it, only fits whose per-block β actually live in the
111/// same-dimension coordinate system match — so the gauge β-projection is always
112/// well-posed and same-width folds transfer ρ AND β, while different-width
113/// models never collide.
114///
115/// NOTE (architect-assumption mismatch): the original design routed identity
116/// through `SmoothTerm.metadata`, but at this layer that metadata has already
117/// been compiled away. The block name + penalty structure + realized reduced
118/// width is the honest, fold-invariant identity available here.
119pub fn term_identity_from_block(
120    role: TermRole,
121    block_name: &str,
122    precision_labels: &[Option<String>],
123    nullspace_dims: &[usize],
124    reduced_width: usize,
125) -> TermIdentityKey {
126    let mut fp = Fingerprinter::new();
127    fp.absorb_tag(b"fit-artifact-block-identity-v2");
128    fp.absorb_u64(b"role", u64::from(role.discriminant()));
129    fp.absorb_str(b"block_name", block_name);
130    fp.absorb_u64(b"n_penalties", precision_labels.len() as u64);
131    for label in precision_labels {
132        match label {
133            Some(l) => fp.absorb_str(b"label", l),
134            None => fp.absorb_tag(b"label-none"),
135        }
136    }
137    fp.absorb_u64(b"n_nullspace", nullspace_dims.len() as u64);
138    for d in nullspace_dims {
139        fp.absorb_u64(b"nullspace_dim", *d as u64);
140    }
141    fp.absorb_u64(b"reduced_width", reduced_width as u64);
142    TermIdentityKey(fp.finalize())
143}
144
145/// Signature of the response (family + dimensionality) a fit targeted.
146/// Carried for diagnostics; deliberately NOT part of the descriptor key so
147/// an LOSO fold matches a full-data parent (only the structural term set
148/// keys the descriptor).
149#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
150pub struct ResponseSig {
151    pub family_kind: String,
152    pub n_response_channels: usize,
153}
154
155/// Tag describing which rows a fit saw. Carried for diagnostics only; the
156/// descriptor key excludes it so different row populations (folds) match.
157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158pub struct RowPopulationTag {
159    pub n_rows: usize,
160    /// Optional caller-supplied label (fold id, disease, …).
161    pub label: Option<String>,
162}
163
164/// Identity descriptor of a whole fit: which family, which structural terms,
165/// what response, optionally which rows. The descriptor *key*
166/// ([`FitDescriptor::descriptor_key`]) hashes only the family kind and the
167/// SORTED term identities — it excludes row population and response bytes —
168/// so an LOSO fold of the same model matches a prior full-data artifact.
169#[derive(Clone, Debug, Serialize, Deserialize)]
170pub struct FitDescriptor {
171    pub family_kind: String,
172    pub term_identities: Vec<TermIdentityKey>,
173    pub response_signature: ResponseSig,
174    pub row_population: Option<RowPopulationTag>,
175}
176
177impl FitDescriptor {
178    /// Stable descriptor key = hash(family_kind ⊕ sorted term identities),
179    /// EXCLUDING row population and response bytes. This is the keyspace an
180    /// LOSO fold and its full-data parent share.
181    pub fn descriptor_key(&self) -> Fingerprint {
182        let mut fp = Fingerprinter::new();
183        fp.absorb_tag(b"fit-artifact-descriptor-v1");
184        fp.absorb_str(b"family_kind", &self.family_kind);
185        // Sort the term identities so block ORDER does not split the key:
186        // the same model assembled in a different block order is the same
187        // descriptor.
188        let mut keys: Vec<[u8; 32]> = self
189            .term_identities
190            .iter()
191            .map(|k| *k.0.as_bytes())
192            .collect();
193        keys.sort_unstable();
194        fp.absorb_u64(b"n_terms", keys.len() as u64);
195        for k in &keys {
196            fp.absorb_bytes(b"term", k);
197        }
198        fp.finalize()
199    }
200}
201
202/// Per-term captured state. Stores RAW per-term β (lifted from the converged
203/// reduced θ via the fit's [`crate::gauge::Gauge`] at capture time —
204/// the identifiability transform T is fit-specific and meaningless in another
205/// fit, so we persist the gauge-free raw coefficients) plus the term's ρ
206/// slice for transfer.
207#[derive(Clone, Debug, Serialize, Deserialize)]
208pub struct TermArtifact {
209    pub identity: TermIdentityKey,
210    pub role: TermRole,
211    /// Serializable structural subset of the term's basis metadata.
212    /// `BasisMetadata` itself is not `Serialize` (it carries large
213    /// data-derived arrays), so we persist only the fields needed to
214    /// re-derive identity and reason about the basis at consume time.
215    pub basis_meta: SerializableBasisMeta,
216    /// Joint-null absorption rotation captured at fit time, if any. Stored as
217    /// a flat row-major matrix so the function-space β projection (Phase 2)
218    /// can replay it; `None` when the term carried no rotation.
219    pub joint_null_rotation: Option<SerializableMatrix>,
220    /// RAW per-term coefficients (post-gauge-lift, pre-identifiability),
221    /// concatenated in the term's raw column order.
222    pub raw_beta: Vec<f64>,
223    /// Converged ρ (log smoothing parameters) for this term's penalties.
224    pub rho_for_term: Vec<f64>,
225}
226
227impl TermArtifact {
228    /// True iff every persisted numeric field is finite (the consume-side
229    /// finite-guard precondition).
230    pub fn is_finite(&self) -> bool {
231        self.raw_beta.iter().all(|v| v.is_finite())
232            && self.rho_for_term.iter().all(|v| v.is_finite())
233            && self
234                .joint_null_rotation
235                .as_ref()
236                .is_none_or(|m| m.data.iter().all(|v| v.is_finite()))
237    }
238}
239
240/// A serializable row-major dense matrix snapshot.
241#[derive(Clone, Debug, Serialize, Deserialize)]
242pub struct SerializableMatrix {
243    pub nrows: usize,
244    pub ncols: usize,
245    pub data: Vec<f64>,
246}
247
248/// Serializable structural subset of a term's basis metadata. Captures the
249/// basis-kind discriminant and the structural parameters used for identity
250/// and for diagnostics. Data-derived arrays (centers, basis matrices) are
251/// intentionally dropped.
252#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
253pub struct SerializableBasisMeta {
254    pub kind: String,
255    pub degree: Option<u64>,
256    pub num_knots: Option<u64>,
257    pub n_centers: Option<u64>,
258    pub nullspace_order: Option<u64>,
259    pub matern_nu: Option<u64>,
260    pub periodic: bool,
261}
262
263/// Whole-fit summary numbers carried for selection / logging.
264#[derive(Clone, Debug, Serialize, Deserialize)]
265pub struct GlobalFitSummary {
266    pub outer_objective: f64,
267    pub converged: bool,
268    pub n_rows: usize,
269}
270
271/// Provenance of a per-term transfer, for logging and tests.
272#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
273pub enum TransferProvenance {
274    /// β was function-projected from the parent (Phase 2).
275    Projected,
276    /// Only ρ was transferred; β stayed cold (Phase 1).
277    RhoOnly,
278    /// Nothing transferred; both β and ρ are at their cold defaults.
279    Cold,
280}
281
282/// The full descriptor-indexed warm-start artifact.
283#[derive(Clone, Debug, Serialize, Deserialize)]
284pub struct FitArtifact {
285    pub schema: u32,
286    pub created_unix_secs: u64,
287    pub descriptor: FitDescriptor,
288    pub terms: Vec<TermArtifact>,
289    pub global: GlobalFitSummary,
290}
291
292impl FitArtifact {
293    /// True iff the artifact is structurally usable as warm-start material:
294    /// the schema matches, the global summary is finite, and every term's
295    /// numeric payload is finite. A failing artifact must be ignored (cold
296    /// fallback), never error a fit.
297    pub fn is_usable(&self) -> bool {
298        self.schema == FIT_ARTIFACT_SCHEMA
299            && self.global.outer_objective.is_finite()
300            && self.terms.iter().all(TermArtifact::is_finite)
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use ndarray::Array2;
308
309    /// Build a block-layer term identity (the surviving, fold-invariant
310    /// identity API). One unlabeled penalty with the given nullspace dim and a
311    /// fixed realized reduced width.
312    fn block_id(role: TermRole, block_name: &str) -> TermIdentityKey {
313        term_identity_from_block(role, block_name, &[None], &[1], 10)
314    }
315
316    /// A minimal serializable basis-meta stub, as produced at the block-spec
317    /// capture layer.
318    fn basis_meta_stub(n_centers: u64) -> SerializableBasisMeta {
319        SerializableBasisMeta {
320            kind: "block-spec".to_string(),
321            degree: None,
322            num_knots: None,
323            n_centers: Some(n_centers),
324            nullspace_order: None,
325            matern_nu: None,
326            periodic: false,
327        }
328    }
329
330    #[test]
331    fn block_identity_splits_on_block_name() {
332        let ka = block_id(TermRole::Mean, "s(x)");
333        let kb = block_id(TermRole::Mean, "s(z)");
334        assert_ne!(ka, kb, "different block name must split identity");
335    }
336
337    #[test]
338    fn block_identity_splits_on_role() {
339        let mean = block_id(TermRole::Mean, "s(x)");
340        let slope = block_id(TermRole::Slope, "s(x)");
341        assert_ne!(mean, slope, "different role must split identity");
342    }
343
344    #[test]
345    fn block_identity_splits_on_penalty_structure() {
346        let one = term_identity_from_block(TermRole::Mean, "s(x)", &[None], &[1], 10);
347        let two = term_identity_from_block(TermRole::Mean, "s(x)", &[None, None], &[1], 10);
348        assert_ne!(one, two, "different #penalties must split identity");
349    }
350
351    #[test]
352    fn block_identity_splits_on_reduced_width() {
353        // The biobank LOSO collision: two models with identical block name /
354        // penalty / nullspace SHAPE but a different realized per-block β width
355        // (p=45 marginal vs the collapsed p=21) MUST hash to distinct
356        // identities, so a p=37 fit never matches a p=85 artifact.
357        let wide = term_identity_from_block(TermRole::Mean, "s(x)", &[None], &[1], 45);
358        let narrow = term_identity_from_block(TermRole::Mean, "s(x)", &[None], &[1], 21);
359        assert_ne!(
360            wide, narrow,
361            "different realized reduced width must split identity"
362        );
363    }
364
365    #[test]
366    fn block_identity_matches_across_folds_at_equal_width() {
367        // The marquee LOSO win: same model, same realized width, different rows
368        // -> identical identity, so ρ and the gauge β-projection both transfer.
369        let fold_a = term_identity_from_block(TermRole::Mean, "s(x)", &[None], &[1], 45);
370        let fold_b = term_identity_from_block(TermRole::Mean, "s(x)", &[None], &[1], 45);
371        assert_eq!(
372            fold_a, fold_b,
373            "same model at equal width must share identity across folds"
374        );
375    }
376
377    #[test]
378    fn descriptor_key_excludes_rows_and_response() {
379        let id = block_id(TermRole::Mean, "s(x)");
380        let full = FitDescriptor {
381            family_kind: "gaussian".to_string(),
382            term_identities: vec![id],
383            response_signature: ResponseSig {
384                family_kind: "gaussian".to_string(),
385                n_response_channels: 1,
386            },
387            row_population: Some(RowPopulationTag {
388                n_rows: 1000,
389                label: Some("full".to_string()),
390            }),
391        };
392        let fold = FitDescriptor {
393            family_kind: "gaussian".to_string(),
394            term_identities: vec![id],
395            response_signature: ResponseSig {
396                family_kind: "gaussian".to_string(),
397                n_response_channels: 1,
398            },
399            row_population: Some(RowPopulationTag {
400                n_rows: 900, // an LOSO fold dropped 100 rows
401                label: Some("fold-3".to_string()),
402            }),
403        };
404        assert_eq!(
405            full.descriptor_key(),
406            fold.descriptor_key(),
407            "LOSO fold must share its full-data parent's descriptor key"
408        );
409    }
410
411    #[test]
412    fn descriptor_key_invariant_to_term_order() {
413        let a = block_id(TermRole::Mean, "s(x)");
414        let b = block_id(TermRole::Mean, "s(z)");
415        let sig = ResponseSig {
416            family_kind: "gaussian".to_string(),
417            n_response_channels: 1,
418        };
419        let d1 = FitDescriptor {
420            family_kind: "gaussian".to_string(),
421            term_identities: vec![a, b],
422            response_signature: sig.clone(),
423            row_population: None,
424        };
425        let d2 = FitDescriptor {
426            family_kind: "gaussian".to_string(),
427            term_identities: vec![b, a],
428            response_signature: sig,
429            row_population: None,
430        };
431        assert_eq!(d1.descriptor_key(), d2.descriptor_key());
432    }
433
434    #[test]
435    fn artifact_usable_guard_rejects_nonfinite() {
436        let id = block_id(TermRole::Mean, "s(x)");
437        let mut artifact = FitArtifact {
438            schema: FIT_ARTIFACT_SCHEMA,
439            created_unix_secs: 0,
440            descriptor: FitDescriptor {
441                family_kind: "gaussian".to_string(),
442                term_identities: vec![id],
443                response_signature: ResponseSig {
444                    family_kind: "gaussian".to_string(),
445                    n_response_channels: 1,
446                },
447                row_population: None,
448            },
449            terms: vec![TermArtifact {
450                identity: id,
451                role: TermRole::Mean,
452                basis_meta: basis_meta_stub(4),
453                joint_null_rotation: None,
454                raw_beta: vec![0.1, 0.2, 0.3, 0.4],
455                rho_for_term: vec![1.0],
456            }],
457            global: GlobalFitSummary {
458                outer_objective: -123.4,
459                converged: true,
460                n_rows: 100,
461            },
462        };
463        assert!(artifact.is_usable());
464        artifact.terms[0].raw_beta[2] = f64::NAN;
465        assert!(
466            !artifact.is_usable(),
467            "non-finite β must fail the usable guard"
468        );
469
470        artifact.terms[0].raw_beta[2] = 0.3;
471        artifact.global.outer_objective = f64::INFINITY;
472        assert!(
473            !artifact.is_usable(),
474            "non-finite objective must fail the usable guard"
475        );
476    }
477
478    #[test]
479    fn serializable_basis_meta_roundtrips() {
480        let meta = basis_meta_stub(7);
481        let bytes = serde_json::to_vec(&meta).expect("serialize");
482        let back: SerializableBasisMeta = serde_json::from_slice(&bytes).expect("deserialize");
483        assert_eq!(meta, back);
484        assert_eq!(back.n_centers, Some(7));
485        assert_eq!(back.kind, "block-spec");
486    }
487
488    #[test]
489    fn serializable_matrix_can_carry_rotation() {
490        let q = Array2::from_shape_vec((2, 2), vec![1.0, 0.0, 0.0, 1.0]).unwrap();
491        let m = SerializableMatrix {
492            nrows: q.nrows(),
493            ncols: q.ncols(),
494            data: q.iter().copied().collect(),
495        };
496        let bytes = serde_json::to_vec(&m).expect("serialize");
497        let back: SerializableMatrix = serde_json::from_slice(&bytes).expect("deserialize");
498        assert_eq!(back.nrows, 2);
499        assert_eq!(back.data, vec![1.0, 0.0, 0.0, 1.0]);
500    }
501}