1use gam_runtime::warm_start::{
2 ConfiguredWarmStartStore, EntryKind, Fingerprinter, StoreError, StoreOptions,
3};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use std::time::Duration;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9const CACHE_VERSION: u32 = 2;
12const MAX_ENTRY_BYTES: u64 = 16 * 1024 * 1024;
13const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
14const CACHE_TTL_SECS: u64 = 60 * 60 * 24 * 365 * 10;
15
16pub fn cache_schema_tag() -> String {
26 "schema3-unified-fingerprinter-v3".to_string()
46}
47
48#[derive(Clone, Debug, Serialize, Deserialize)]
49pub struct PersistentWarmStartRecord {
50 pub version: u32,
51 pub key: String,
52 pub package_version: String,
53 pub created_unix_secs: u64,
54 pub updated_unix_secs: u64,
55 pub n_rows: usize,
56 pub n_cols: usize,
57 pub rho: Vec<f64>,
58 pub beta: Vec<f64>,
59 pub prev_rho: Option<Vec<f64>>,
60 pub prev_beta: Option<Vec<f64>>,
61 pub last_inner_iters: usize,
62 pub last_inner_converged: bool,
63 pub last_pirls_lm_lambda: Option<f64>,
64 pub last_ift_prediction_residual: Option<f64>,
65 pub last_pirls_accept_rho: Option<f64>,
66}
67
68#[derive(Clone, Debug, Serialize, Deserialize)]
69pub struct PersistentBlockInnerSummary {
70 pub log_likelihood: f64,
71 pub penalty_value: f64,
72 pub cycles: usize,
73 pub converged: bool,
74 pub block_logdet_h: f64,
75 pub block_logdet_s: f64,
76 pub block_log_lambdas: Vec<Vec<f64>>,
84 pub joint_log_lambdas: Vec<f64>,
87}
88
89impl PersistentBlockInnerSummary {
90 fn is_valid(&self) -> bool {
91 self.log_likelihood.is_finite()
92 && self.penalty_value.is_finite()
93 && self.block_logdet_h.is_finite()
94 && self.block_logdet_s.is_finite()
95 && self
96 .block_log_lambdas
97 .iter()
98 .all(|block| block.iter().all(|v| v.is_finite()))
99 && self.joint_log_lambdas.iter().all(|v| v.is_finite())
100 }
101}
102
103#[derive(Clone, Debug, Serialize, Deserialize)]
104pub struct PersistentBlockWarmStartRecord {
105 pub version: u32,
106 pub key: String,
107 pub package_version: String,
108 pub created_unix_secs: u64,
109 pub updated_unix_secs: u64,
110 pub n_rows: usize,
111 pub block_names: Vec<String>,
112 pub block_dims: Vec<usize>,
113 pub rho: Vec<f64>,
114 pub block_beta: Vec<Vec<f64>>,
115 pub active_sets: Vec<Option<Vec<usize>>>,
116 #[serde(default)]
117 pub inner: Option<PersistentBlockInnerSummary>,
118}
119
120impl PersistentBlockWarmStartRecord {
121 pub fn new(
122 key: String,
123 n_rows: usize,
124 block_names: Vec<String>,
125 block_dims: Vec<usize>,
126 ) -> Self {
127 let now = unix_secs_now();
128 Self {
129 version: CACHE_VERSION,
130 key,
131 package_version: env!("CARGO_PKG_VERSION").to_string(),
132 created_unix_secs: now,
133 updated_unix_secs: now,
134 n_rows,
135 block_names,
136 block_dims,
137 rho: Vec::new(),
138 block_beta: Vec::new(),
139 active_sets: Vec::new(),
140 inner: None,
141 }
142 }
143
144 pub fn is_compatible(
145 &self,
146 key: &str,
147 n_rows: usize,
148 block_names: &[String],
149 block_dims: &[usize],
150 rho_len: usize,
151 ) -> bool {
152 self.version == CACHE_VERSION
153 && self.key == key
154 && self.n_rows == n_rows
161 && self.block_names == block_names
162 && self.block_dims == block_dims
163 && self.rho.len() == rho_len
164 && self.rho.iter().all(|v| v.is_finite())
165 && self.block_beta.len() == block_dims.len()
166 && self
167 .block_beta
168 .iter()
169 .zip(block_dims.iter())
170 .all(|(beta, dim)| beta.len() == *dim && beta.iter().all(|v| v.is_finite()))
171 && self.active_sets.len() == block_dims.len()
172 && self.inner.as_ref().is_none_or(|inner| inner.is_valid())
173 }
174}
175
176impl PersistentWarmStartRecord {
177 pub fn new(key: String, n_rows: usize, n_cols: usize) -> Self {
178 let now = unix_secs_now();
179 Self {
180 version: CACHE_VERSION,
181 key,
182 package_version: env!("CARGO_PKG_VERSION").to_string(),
183 created_unix_secs: now,
184 updated_unix_secs: now,
185 n_rows,
186 n_cols,
187 rho: Vec::new(),
188 beta: Vec::new(),
189 prev_rho: None,
190 prev_beta: None,
191 last_inner_iters: 0,
192 last_inner_converged: false,
193 last_pirls_lm_lambda: None,
194 last_ift_prediction_residual: None,
195 last_pirls_accept_rho: None,
196 }
197 }
198
199 pub fn is_compatible(&self, key: &str, n_rows: usize, n_cols: usize) -> bool {
200 self.version == CACHE_VERSION
201 && self.key == key
202 && self.n_rows == n_rows
209 && self.n_cols == n_cols
210 && self.rho.iter().all(|v| v.is_finite())
211 && self.beta.len() == n_cols
212 && self.beta.iter().all(|v| v.is_finite())
213 && self
214 .prev_rho
215 .as_ref()
216 .is_none_or(|rho| rho.len() == self.rho.len() && rho.iter().all(|v| v.is_finite()))
217 && self
218 .prev_beta
219 .as_ref()
220 .is_none_or(|beta| beta.len() == n_cols && beta.iter().all(|v| v.is_finite()))
221 }
222}
223
224pub fn configured_store(root: PathBuf) -> ConfiguredWarmStartStore {
230 ConfiguredWarmStartStore::new(
231 root,
232 StoreOptions {
233 size_budget_bytes: MAX_TOTAL_BYTES,
234 ttl: Duration::from_secs(CACHE_TTL_SECS),
235 },
236 )
237}
238
239pub fn load_record(
240 store: &ConfiguredWarmStartStore,
241 key: &str,
242) -> Option<PersistentWarmStartRecord> {
243 best_effort(
244 store,
245 "load warm-start record",
246 load_json_record(store, key),
247 )
248}
249
250pub fn load_block_record(
251 store: &ConfiguredWarmStartStore,
252 key: &str,
253) -> Option<PersistentBlockWarmStartRecord> {
254 best_effort(
255 store,
256 "load custom-family warm-start record",
257 load_json_record(store, key),
258 )
259}
260
261pub fn store_record(store: &ConfiguredWarmStartStore, record: &PersistentWarmStartRecord) {
262 best_effort(
263 store,
264 "store warm-start record",
265 store_json_record(store, &record.key, record),
266 );
267}
268
269pub fn store_block_record(
270 store: &ConfiguredWarmStartStore,
271 record: &PersistentBlockWarmStartRecord,
272) {
273 best_effort(
274 store,
275 "store custom-family warm-start record",
276 store_json_record(store, &record.key, record),
277 );
278}
279
280#[derive(Debug)]
281enum PersistentStoreError {
282 Encode(String),
283 Unavailable(String),
284 Rejected(String),
285}
286
287impl std::fmt::Display for PersistentStoreError {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 match self {
290 Self::Encode(detail) => write!(f, "encode failed: {detail}"),
291 Self::Unavailable(detail) => write!(f, "filesystem unavailable: {detail}"),
292 Self::Rejected(detail) => write!(f, "store rejected operation: {detail}"),
293 }
294 }
295}
296
297fn classify_store_error(error: StoreError) -> PersistentStoreError {
298 match error {
299 StoreError::Io(error) => PersistentStoreError::Unavailable(error.to_string()),
300 StoreError::Json(error) => PersistentStoreError::Rejected(error.to_string()),
301 }
302}
303
304fn best_effort<T: Default>(
311 store: &ConfiguredWarmStartStore,
312 operation: &'static str,
313 result: Result<T, PersistentStoreError>,
314) -> T {
315 match result {
316 Ok(value) => value,
317 Err(PersistentStoreError::Unavailable(detail)) => {
318 store.mark_unavailable(operation, &detail);
319 T::default()
320 }
321 Err(error @ (PersistentStoreError::Encode(_) | PersistentStoreError::Rejected(_))) => {
322 log::warn!(
323 "[warm-start-cache] persistence defect operation={} explicit_root={}: {}",
324 operation,
325 store.root().display(),
326 error
327 );
328 T::default()
329 }
330 }
331}
332
333fn store_json_record<T: Serialize>(
338 configured: &ConfiguredWarmStartStore,
339 key: &str,
340 record: &T,
341) -> Result<(), PersistentStoreError> {
342 let bytes = serde_json::to_vec(record)
343 .map_err(|error| PersistentStoreError::Encode(error.to_string()))?;
344 if bytes.len() as u64 > MAX_ENTRY_BYTES {
345 return Ok(());
346 }
347 let Some(store) = configured.store() else {
348 return Ok(());
349 };
350 let mut fp = Fingerprinter::new();
351 fp.absorb_str(b"warm-start-key", key);
352 store
353 .save(&fp.finalize(), &bytes, None, None, EntryKind::Checkpoint)
354 .map_err(classify_store_error)?;
355 Ok(())
356}
357
358fn load_json_record<T: for<'de> Deserialize<'de>>(
381 configured: &ConfiguredWarmStartStore,
382 key: &str,
383) -> Result<Option<T>, PersistentStoreError> {
384 let Some(store) = configured.store() else {
385 return Ok(None);
386 };
387 let mut fp = Fingerprinter::new();
388 fp.absorb_str(b"warm-start-key", key);
389 let Some(entry) = store.lookup(&fp.finalize()).map_err(classify_store_error)? else {
390 return Ok(None);
391 };
392 if entry.payload.len() as u64 > MAX_ENTRY_BYTES {
393 return Ok(None);
394 }
395 serde_json::from_slice(&entry.payload)
396 .map(Some)
397 .map_err(|error| PersistentStoreError::Rejected(error.to_string()))
398}
399
400pub fn open_outer_session(
407 configured: &ConfiguredWarmStartStore,
408 key: &str,
409) -> Option<std::sync::Arc<gam_runtime::warm_start::Session>> {
410 let mut fp = Fingerprinter::new();
411 fp.absorb_str(b"outer-iterate-key", key);
412 let fp = fp.finalize();
413 configured.open_session(fp)
414}
415
416pub fn store_fit_artifact(
425 store: &ConfiguredWarmStartStore,
426 artifact: &crate::warm_start_artifact::FitArtifact,
427) {
428 best_effort(
429 store,
430 "store cross-fit artifact",
431 try_store_fit_artifact(store, artifact),
432 );
433}
434
435fn try_store_fit_artifact(
436 configured: &ConfiguredWarmStartStore,
437 artifact: &crate::warm_start_artifact::FitArtifact,
438) -> Result<(), PersistentStoreError> {
439 if !artifact.is_usable() {
440 return Ok(());
443 }
444 let bytes = serde_json::to_vec(artifact)
445 .map_err(|error| PersistentStoreError::Encode(error.to_string()))?;
446 if bytes.len() as u64 > MAX_ENTRY_BYTES {
447 return Ok(());
448 }
449 let Some(store) = configured.store() else {
450 return Ok(());
451 };
452 let key = artifact.descriptor.descriptor_key().to_hex();
453 let mut fp = Fingerprinter::new();
454 fp.absorb_str(b"fit-artifact-key", &cache_schema_tag());
455 fp.absorb_str(b"fit-artifact-descriptor", &key);
456 store
457 .save(&fp.finalize(), &bytes, None, None, EntryKind::Checkpoint)
458 .map_err(classify_store_error)?;
459 Ok(())
460}
461
462pub fn load_fit_artifact_by_descriptor(
470 store: &ConfiguredWarmStartStore,
471 descriptor_key_hex: &str,
472) -> Option<crate::warm_start_artifact::FitArtifact> {
473 best_effort(
474 store,
475 "load cross-fit artifact",
476 try_load_fit_artifact_by_descriptor(store, descriptor_key_hex),
477 )
478}
479
480fn try_load_fit_artifact_by_descriptor(
481 configured: &ConfiguredWarmStartStore,
482 descriptor_key_hex: &str,
483) -> Result<Option<crate::warm_start_artifact::FitArtifact>, PersistentStoreError> {
484 let Some(store) = configured.store() else {
485 return Ok(None);
486 };
487 let mut fp = Fingerprinter::new();
488 fp.absorb_str(b"fit-artifact-key", &cache_schema_tag());
489 fp.absorb_str(b"fit-artifact-descriptor", descriptor_key_hex);
490 let Some(entry) = store
491 .lookup_latest(&fp.finalize())
492 .map_err(classify_store_error)?
493 else {
494 return Ok(None);
495 };
496 if entry.payload.len() as u64 > MAX_ENTRY_BYTES {
497 return Ok(None);
498 }
499 let artifact: crate::warm_start_artifact::FitArtifact = serde_json::from_slice(&entry.payload)
500 .map_err(|error| PersistentStoreError::Rejected(error.to_string()))?;
501 Ok(artifact.is_usable().then_some(artifact))
504}
505
506fn unix_secs_now() -> u64 {
507 SystemTime::now()
508 .duration_since(UNIX_EPOCH)
509 .map(|d| d.as_secs())
510 .unwrap_or(0)
511}
512
513#[cfg(test)]
514mod warm_start_artifact_tests {
515 use super::*;
516 use crate::warm_start_artifact::{
517 FIT_ARTIFACT_SCHEMA, FitArtifact, FitDescriptor, GlobalFitSummary, ResponseSig,
518 SerializableBasisMeta, TermArtifact, TermRole, term_identity_from_block,
519 };
520 use serde::ser::Error as _;
521
522 fn isolated_store() -> (tempfile::TempDir, ConfiguredWarmStartStore) {
523 let directory = tempfile::tempdir().expect("create isolated warm-start root");
524 let store = configured_store(directory.path().join("warm"));
525 (directory, store)
526 }
527
528 fn sample_artifact(family: &str, var: &str, rho: Vec<f64>) -> FitArtifact {
529 let block_name = format!("s({var})");
532 let id = term_identity_from_block(TermRole::Mean, &block_name, &[None], &[1], 10);
533 FitArtifact {
534 schema: FIT_ARTIFACT_SCHEMA,
535 created_unix_secs: unix_secs_now(),
536 descriptor: FitDescriptor {
537 family_kind: family.to_string(),
538 term_identities: vec![id],
539 response_signature: ResponseSig {
540 family_kind: family.to_string(),
541 n_response_channels: 1,
542 },
543 row_population: None,
544 },
545 terms: vec![TermArtifact {
546 identity: id,
547 role: TermRole::Mean,
548 basis_meta: SerializableBasisMeta {
549 kind: "block-spec".to_string(),
550 degree: None,
551 num_knots: None,
552 n_centers: Some(8),
553 nullspace_order: None,
554 matern_nu: None,
555 periodic: false,
556 },
557 joint_null_rotation: None,
558 raw_beta: vec![0.1, -0.2, 0.3, 0.4, -0.5, 0.6, -0.7, 0.8],
559 rho_for_term: rho,
560 }],
561 global: GlobalFitSummary {
562 outer_objective: -42.0,
563 converged: true,
564 n_rows: 500,
565 },
566 }
567 }
568
569 #[test]
570 fn artifact_round_trips_on_disk_by_descriptor() {
571 let (_directory, store) = isolated_store();
572 let artifact = sample_artifact("test-roundtrip", "x", vec![2.5]);
573 let key_hex = artifact.descriptor.descriptor_key().to_hex();
574
575 try_store_fit_artifact(&store, &artifact).expect("store fit artifact");
579 let loaded = load_fit_artifact_by_descriptor(&store, &key_hex)
580 .expect("artifact must be retrievable by descriptor key");
581 assert_eq!(loaded.schema, artifact.schema);
582 assert_eq!(loaded.terms.len(), 1);
583 assert_eq!(loaded.terms[0].identity, artifact.terms[0].identity);
584 assert_eq!(loaded.terms[0].rho_for_term, vec![2.5]);
585 assert_eq!(loaded.terms[0].raw_beta, artifact.terms[0].raw_beta);
586 assert_eq!(
587 loaded.descriptor.descriptor_key(),
588 artifact.descriptor.descriptor_key()
589 );
590 }
591
592 #[test]
593 fn loso_fold_descriptor_matches_full_data_artifact() {
594 let (_directory, store) = isolated_store();
595 let family = "test-loso";
596 let mut full = sample_artifact(family, "x", vec![1.7]);
598 full.descriptor.row_population = Some(crate::warm_start_artifact::RowPopulationTag {
599 n_rows: 1000,
600 label: Some("full".to_string()),
601 });
602 full.global.n_rows = 1000;
603 let full_key = full.descriptor.descriptor_key().to_hex();
604
605 let fold = sample_artifact(family, "x", vec![1.7]);
608 let fold_key = fold.descriptor.descriptor_key().to_hex();
609 assert_eq!(
610 full_key, fold_key,
611 "fold and full descriptor keys must match"
612 );
613
614 try_store_fit_artifact(&store, &full).expect("store full-data artifact");
615 let loaded = load_fit_artifact_by_descriptor(&store, &fold_key)
616 .expect("LOSO fold must retrieve the full-data artifact");
617 assert_eq!(loaded.terms[0].rho_for_term, vec![1.7]);
618 }
619
620 #[test]
621 fn io_refusal_remains_typed_before_best_effort_interpretation() {
622 let error = StoreError::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
623 assert!(matches!(
624 classify_store_error(error),
625 PersistentStoreError::Unavailable(_)
626 ));
627 }
628
629 #[test]
630 fn persistence_defects_remain_typed_before_best_effort_interpretation() {
631 struct RefusesSerialization;
632
633 impl Serialize for RefusesSerialization {
634 fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
635 where
636 S: serde::Serializer,
637 {
638 Err(S::Error::custom("intentional encoding refusal"))
639 }
640 }
641
642 let (_directory, store) = isolated_store();
643 assert!(matches!(
644 store_json_record(&store, "encode-defect", &RefusesSerialization),
645 Err(PersistentStoreError::Encode(_))
646 ));
647
648 let malformed_json =
649 serde_json::from_str::<serde_json::Value>("{").expect_err("fixture must be malformed");
650 assert!(matches!(
651 classify_store_error(StoreError::Json(malformed_json)),
652 PersistentStoreError::Rejected(_)
653 ));
654 }
655}