Skip to main content

gam_solve/
persistent_warm_start.rs

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
9// v2 (#2615): `PersistentBlockInnerSummary` carries the smoothing state its
10// cached inner mode was solved at, so a restored mode can be keyed correctly.
11const 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
16/// String tag identifying the on-disk cache schema, embedded directly in
17/// cache keys.
18///
19/// The leading `schema2-` prefix is bumped manually only when the
20/// serialized cache layout changes in a way that makes prior entries
21/// unsafe to consume (struct fields added/removed, optimization
22/// invariants altered, payload semantics shift). This is **deliberately
23/// separate** from `CARGO_PKG_VERSION` so a routine library version bump
24/// does NOT invalidate every user's warm-start cache.
25pub fn cache_schema_tag() -> String {
26    // Bumped from `schema2-` → `schema3-` when the three hand-written
27    // hashers (`Fingerprinter`, `StableHasher`, `CacheDigestBuilder`) were
28    // unified onto `Fingerprinter`. Prior on-disk warm-start entries are
29    // walled off into the `schema2-` keyspace and cold-start once; this
30    // is the intentional consequence of the unification, documented in
31    // the commit that performs it. See `src/warm_start/key.rs` for the new
32    // canonical hasher API.
33    // Bumped to `v2` when the persistent warm-start key stopped hashing the
34    // θ-dependent, lazily-refreshed isometry Jacobian cache slots
35    // (`jacobian_cache` / `jacobian_second_cache` / `third_decoder_derivative`).
36    // Those snapshots made the key non-reproducible across identical repeat
37    // fits, so the outer `skip-outer-validation` warm hit was lost (#1048). The
38    // bump walls off any entries written under the old, drifting keys; they are
39    // simply never matched (and TTL-evicted) rather than aliasing.
40    // Bumped to `v3` when the descriptor-indexed cross-fit `FitArtifact`
41    // keyspace ("fit-artifact-key") was introduced alongside the existing
42    // inner/outer warm-start records. The bump walls off any entries written
43    // under the old layouts so a mixed-schema store never aliases a legacy
44    // payload into the new artifact reader (and vice versa).
45    "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    /// Per-block `log λ` this mode was solved at, in block order (#2615).
77    ///
78    /// The record's own `rho` is the OPTIMIZER's coordinate vector, which for a
79    /// labeled / joint-penalty family is neither the per-block strengths nor
80    /// split into them — so it cannot serve as the reuse key. A cached Laplace
81    /// mode replayed at a different smoothing state is a criterion evaluated at
82    /// one ρ and reported at another; the state therefore travels with the mode.
83    pub block_log_lambdas: Vec<Vec<f64>>,
84    /// Joint-bundle `log λ` this mode was solved at; empty for a family that
85    /// declares no joint penalties (#2615).
86    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            // Note: `package_version` is no longer required to match. A
155            // library version bump that doesn't change the cache schema
156            // (the common case for patch / minor releases) should NOT
157            // invalidate users' on-disk warm-start caches. Schema-breaking
158            // changes bump the `schemaN-` prefix in `cache_schema_tag()`,
159            // which is encoded in the cache key itself.
160            && 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            // Note: `package_version` is no longer required to match. A
203            // library version bump that doesn't change the cache schema
204            // (the common case for patch / minor releases) should NOT
205            // invalidate users' on-disk warm-start caches. Schema-breaking
206            // changes bump the `schemaN-` prefix in `cache_schema_tag()`,
207            // which is encoded in the cache key itself.
208            && 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
224/// Configure persistent warm starts at the exact caller-supplied root.
225///
226/// This is lazy: request parsing and structural validation do not touch the
227/// filesystem. The first real persistence operation opens the root once, and
228/// all clones share that handle or the single best-effort unavailable decision.
229pub 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
304/// Interpret every persistent-cache failure in one place.
305///
306/// Filesystem refusal permanently disables this explicitly configured store
307/// for the fit and emits one diagnostic from the store capability. Encoding or
308/// store-contract defects remain loud in diagnostics and typed in the internal
309/// `try_*` functions, but persistence can never fail a statistical fit.
310fn 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
333/// Encode a record for the store.
334///
335/// See [`load_json_record`] before changing the encoding: JSON's inability to
336/// represent non-finite `f64` is relied upon on the load side (gam#2721).
337fn 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
358/// Decode a stored record.
359///
360/// THE JSON ENCODING IS LOAD-BEARING, NOT INCIDENTAL (gam#2721). `serde_json`
361/// cannot represent a non-finite `f64`: it writes `NaN` / `+-inf` as `null`, and
362/// deserializing `null` into `f64` fails here as
363/// [`PersistentStoreError::Rejected`]. That is the SECOND of exactly two
364/// independent barriers keeping a non-finite coefficient out of a restored
365/// warm start, and so out of `state.beta` at the cached-seed restore in
366/// `inner_blockwise_fit`, which applies no finiteness check of its own:
367///
368///   1. the write side refuses to persist a record whose `block_beta` holds a
369///      non-finite value (`store_persistent_custom_family_warm_start`);
370///   2. this encoding cannot carry one, so a record written by an older binary,
371///      hand-edited, or corrupted is REJECTED at parse rather than decoded into
372///      a `NaN`.
373///
374/// Barrier 1 covers only records this code wrote. Barrier 2 covers every other
375/// origin, and it is a property of the FORMAT rather than of any check — so
376/// swapping this encoding for one that can carry non-finite values (bincode,
377/// msgpack, arrow, CBOR) removes it with no diff to any guard and no failing
378/// test. A format change here must therefore come with a load-side finiteness
379/// check on the decoded coefficient vectors.
380fn 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
400/// Open a [`gam_runtime::warm_start::Session`] for outer-iterate (rho-axis) checkpoints.
401///
402/// Uses a different fingerprint tag than the inner `warm-start-key`
403/// absorption (see `load_json_record`) so the outer-iterate keyspace
404/// is disjoint from the inner beta-record keyspace —
405/// the two layers persist different payload shapes and must not alias.
406pub 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
416/// Persist a descriptor-indexed cross-fit `FitArtifact` under the
417/// `fit-artifact-key` keyspace, keyed by the descriptor's structural key (so
418/// an LOSO fold of the same model retrieves a prior full-data fit). The
419/// schema tag is folded into the key so legacy layouts are walled off.
420///
421/// Best-effort: the public function cannot fail a fit. Its internal `try_*`
422/// counterpart retains typed failures for contract tests, and `best_effort`
423/// owns the sole production interpretation.
424pub 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        // Never persist a non-finite / wrong-schema artifact: it could only
441        // ever be rejected on load anyway.
442        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
462/// Load the newest valid cross-fit `FitArtifact` whose descriptor key
463/// matches `descriptor_key_hex` (the hex of [`crate::warm_start_artifact::FitDescriptor::descriptor_key`]).
464///
465/// Uses `lookup_latest` (newest-valid) rather than objective-ranked lookup:
466/// descriptor-key matches can be different folds / row sets whose objectives
467/// are not on a common scale, so "lowest objective" is the wrong rule.
468/// Returns `None` (cold fallback) on any miss or non-finite payload.
469pub 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    // Finite-guard on the way out: a corrupt payload must cold-fallback,
502    // never poison a fit.
503    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        // Block-layer identity (the surviving, fold-invariant identity API):
530        // the block name carries the variable, with one unlabeled penalty.
531        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        // The test owns a writable scratch root, so its assertion and
576        // precondition are identical. Environment refusal is exercised through
577        // the typed `try_*` seam instead of weakening the round-trip contract.
578        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        // Full-data fit on 1000 rows.
597        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        // LOSO fold: same term identities, fewer rows. Its descriptor key
606        // must equal the full-data key, so the load hits the stored artifact.
607        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}