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}