Skip to main content

dag_ml_core/runtime/
prediction_store.rs

1// Auto-split from the former monolithic `runtime.rs` (pure refactor).
2use super::*;
3
4#[derive(Clone, Debug, Default)]
5pub struct InMemoryPredictionStore {
6    blocks: Vec<PredictionBlock>,
7}
8
9impl InMemoryPredictionStore {
10    pub fn new() -> Self {
11        Self::default()
12    }
13
14    pub fn append(&mut self, block: PredictionBlock) -> Result<()> {
15        block.validate_content()?;
16        self.blocks.push(block);
17        Ok(())
18    }
19
20    pub fn blocks(&self) -> &[PredictionBlock] {
21        &self.blocks
22    }
23
24    pub fn find(
25        &self,
26        producer_node: Option<&NodeId>,
27        phase_partition: Option<&crate::oof::PredictionPartition>,
28        fold_id: Option<&FoldId>,
29    ) -> Vec<&PredictionBlock> {
30        self.blocks
31            .iter()
32            .filter(|block| {
33                producer_node.is_none_or(|node_id| &block.producer_node == node_id)
34                    && phase_partition.is_none_or(|partition| &block.partition == partition)
35                    && fold_id.is_none_or(|requested| block.fold_id.as_ref() == Some(requested))
36            })
37            .collect()
38    }
39}
40
41#[derive(Clone, Debug, Default)]
42pub struct InMemoryAggregatedPredictionStore {
43    blocks: Vec<AggregatedPredictionBlock>,
44}
45
46impl InMemoryAggregatedPredictionStore {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    pub fn append(&mut self, block: AggregatedPredictionBlock) -> Result<()> {
52        block.validate_shape()?;
53        self.blocks.push(block);
54        Ok(())
55    }
56
57    pub fn blocks(&self) -> &[AggregatedPredictionBlock] {
58        &self.blocks
59    }
60
61    pub fn find(
62        &self,
63        producer_node: Option<&NodeId>,
64        phase_partition: Option<&PredictionPartition>,
65        fold_id: Option<&FoldId>,
66        prediction_level: Option<PredictionLevel>,
67    ) -> Vec<&AggregatedPredictionBlock> {
68        self.blocks
69            .iter()
70            .filter(|block| {
71                producer_node.is_none_or(|node_id| &block.producer_node == node_id)
72                    && phase_partition.is_none_or(|partition| &block.partition == partition)
73                    && fold_id.is_none_or(|requested| block.fold_id.as_ref() == Some(requested))
74                    && prediction_level.is_none_or(|level| block.level == level)
75            })
76            .collect()
77    }
78}
79
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81pub struct PredictionCacheMaterializationRequest {
82    pub run_id: RunId,
83    pub bundle_id: BundleId,
84    pub phase: Phase,
85    pub variant_id: Option<VariantId>,
86    pub requirement: BundlePredictionRequirement,
87    pub cache: BundlePredictionCacheRecord,
88    pub producer_controller_id: ControllerId,
89}
90
91#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92pub struct PredictionCacheMaterializationRecord {
93    pub run_id: RunId,
94    pub bundle_id: BundleId,
95    pub phase: Phase,
96    pub variant_id: Option<VariantId>,
97    pub requirement_key: String,
98    pub cache_id: String,
99    pub handle: HandleRef,
100}
101
102pub trait RuntimePredictionCacheStore {
103    fn load_blocks(&self, requirement_key: &str) -> Result<Vec<PredictionBlock>>;
104    fn load_aggregated_blocks(
105        &self,
106        requirement_key: &str,
107    ) -> Result<Vec<AggregatedPredictionBlock>> {
108        Err(DagMlError::RuntimeValidation(format!(
109            "prediction cache store does not support aggregated requirement `{requirement_key}`"
110        )))
111    }
112    fn materialize(&self, request: &PredictionCacheMaterializationRequest) -> Result<HandleRef>;
113}
114
115pub const FILE_PREDICTION_CACHE_STORE_SCHEMA_VERSION: u32 = 1;
116pub const FILE_PREDICTION_CACHE_MANIFEST_FILE: &str = "prediction_cache_manifest.json";
117
118pub(crate) fn default_file_prediction_cache_store_schema_version() -> u32 {
119    FILE_PREDICTION_CACHE_STORE_SCHEMA_VERSION
120}
121
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123pub struct FilePredictionCacheEntry {
124    pub requirement_key: String,
125    pub cache_id: String,
126    pub file_name: String,
127    #[serde(default = "default_runtime_prediction_level")]
128    pub prediction_level: PredictionLevel,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub unit_ids: Vec<PredictionUnitId>,
131    pub block_count: usize,
132    pub row_count: usize,
133    pub content_fingerprint: String,
134}
135
136impl FilePredictionCacheEntry {
137    pub fn validate(&self) -> Result<()> {
138        validate_runtime_non_empty("requirement_key", &self.requirement_key)?;
139        validate_runtime_non_empty("cache_id", &self.cache_id)?;
140        validate_runtime_non_empty("file_name", &self.file_name)?;
141        validate_prediction_cache_file_name(&self.file_name)?;
142        if self.block_count == 0 {
143            return Err(DagMlError::RuntimeValidation(format!(
144                "file prediction cache `{}` has zero block_count",
145                self.cache_id
146            )));
147        }
148        if self.row_count == 0 {
149            return Err(DagMlError::RuntimeValidation(format!(
150                "file prediction cache `{}` has zero row_count",
151                self.cache_id
152            )));
153        }
154        if self.prediction_level != PredictionLevel::Sample && self.unit_ids.is_empty() {
155            return Err(DagMlError::RuntimeValidation(format!(
156                "file prediction cache `{}` has no aggregated unit ids",
157                self.cache_id
158            )));
159        }
160        if self
161            .unit_ids
162            .iter()
163            .any(|unit_id| unit_id.level() != self.prediction_level)
164        {
165            return Err(DagMlError::RuntimeValidation(format!(
166                "file prediction cache `{}` has unit ids outside {:?}",
167                self.cache_id, self.prediction_level
168            )));
169        }
170        validate_runtime_fingerprint("prediction cache content", &self.content_fingerprint)
171    }
172
173    fn from_payload(payload: &crate::bundle::BundlePredictionCachePayload) -> Result<Self> {
174        Ok(Self {
175            requirement_key: payload.requirement_key.clone(),
176            cache_id: payload.cache_id.clone(),
177            file_name: prediction_cache_payload_file_name(payload)?,
178            prediction_level: payload.prediction_level,
179            unit_ids: payload
180                .aggregated_blocks
181                .iter()
182                .flat_map(|block| block.unit_ids.iter().cloned())
183                .collect(),
184            block_count: payload.block_count,
185            row_count: payload.row_count,
186            content_fingerprint: payload.content_fingerprint.clone(),
187        })
188    }
189
190    fn matches_record(&self, record: &BundlePredictionCacheRecord) -> bool {
191        self.requirement_key == record.requirement_key
192            && self.cache_id == record.cache_id
193            && self.prediction_level == record.prediction_level
194            && self.unit_ids == record.unit_ids
195            && self.block_count == record.block_count
196            && self.row_count == record.row_count
197            && self.content_fingerprint == record.content_fingerprint
198    }
199}
200
201#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
202pub struct FilePredictionCacheManifest {
203    pub bundle_id: BundleId,
204    #[serde(default = "default_file_prediction_cache_store_schema_version")]
205    pub schema_version: u32,
206    #[serde(default)]
207    pub caches: Vec<FilePredictionCacheEntry>,
208}
209
210impl FilePredictionCacheManifest {
211    pub fn validate(&self) -> Result<()> {
212        if self.schema_version != FILE_PREDICTION_CACHE_STORE_SCHEMA_VERSION {
213            return Err(DagMlError::RuntimeValidation(format!(
214                "file prediction cache manifest for bundle `{}` uses unsupported schema_version {}, expected {}",
215                self.bundle_id,
216                self.schema_version,
217                FILE_PREDICTION_CACHE_STORE_SCHEMA_VERSION
218            )));
219        }
220        let mut requirement_keys = BTreeSet::new();
221        let mut cache_ids = BTreeSet::new();
222        let mut file_names = BTreeSet::new();
223        for entry in &self.caches {
224            entry.validate()?;
225            if !requirement_keys.insert(entry.requirement_key.as_str()) {
226                return Err(DagMlError::RuntimeValidation(format!(
227                    "file prediction cache manifest for bundle `{}` has duplicate requirement `{}`",
228                    self.bundle_id, entry.requirement_key
229                )));
230            }
231            if !cache_ids.insert(entry.cache_id.as_str()) {
232                return Err(DagMlError::RuntimeValidation(format!(
233                    "file prediction cache manifest for bundle `{}` has duplicate cache id `{}`",
234                    self.bundle_id, entry.cache_id
235                )));
236            }
237            if !file_names.insert(entry.file_name.as_str()) {
238                return Err(DagMlError::RuntimeValidation(format!(
239                    "file prediction cache manifest for bundle `{}` has duplicate file `{}`",
240                    self.bundle_id, entry.file_name
241                )));
242            }
243        }
244        Ok(())
245    }
246
247    pub fn validate_against_bundle(&self, bundle: &ExecutionBundle) -> Result<()> {
248        self.validate()?;
249        bundle.validate()?;
250        if self.bundle_id != bundle.bundle_id {
251            return Err(DagMlError::RuntimeValidation(format!(
252                "file prediction cache manifest bundle `{}` does not match bundle `{}`",
253                self.bundle_id, bundle.bundle_id
254            )));
255        }
256        if self.caches.len() != bundle.prediction_caches.len() {
257            return Err(DagMlError::RuntimeValidation(format!(
258                "file prediction cache manifest for bundle `{}` has {} cache(s) for {} bundle cache record(s)",
259                self.bundle_id,
260                self.caches.len(),
261                bundle.prediction_caches.len()
262            )));
263        }
264        let entries_by_requirement = self
265            .caches
266            .iter()
267            .map(|entry| (entry.requirement_key.as_str(), entry))
268            .collect::<BTreeMap<_, _>>();
269        for record in &bundle.prediction_caches {
270            let entry = entries_by_requirement
271                .get(record.requirement_key.as_str())
272                .ok_or_else(|| {
273                    DagMlError::RuntimeValidation(format!(
274                        "file prediction cache manifest for bundle `{}` is missing requirement `{}`",
275                        self.bundle_id, record.requirement_key
276                    ))
277                })?;
278            if !entry.matches_record(record) {
279                return Err(DagMlError::RuntimeValidation(format!(
280                    "file prediction cache manifest entry `{}` does not match bundle cache record",
281                    entry.cache_id
282                )));
283            }
284        }
285        Ok(())
286    }
287}
288
289#[derive(Clone, Debug)]
290pub struct FilePredictionCacheStore {
291    root: PathBuf,
292    manifest: FilePredictionCacheManifest,
293    records_by_requirement: BTreeMap<String, BundlePredictionCacheRecord>,
294    materialization_records: RefCell<Vec<PredictionCacheMaterializationRecord>>,
295}
296
297impl FilePredictionCacheStore {
298    pub fn write_payload_set(
299        root: impl AsRef<Path>,
300        bundle: &ExecutionBundle,
301        payloads: &BundlePredictionCachePayloadSet,
302    ) -> Result<FilePredictionCacheManifest> {
303        payloads.validate_against_bundle(bundle)?;
304        let root = root.as_ref();
305        fs::create_dir_all(root).map_err(|err| {
306            DagMlError::RuntimeValidation(format!(
307                "failed to create prediction cache store `{}`: {err}",
308                root.display()
309            ))
310        })?;
311
312        let mut entries = Vec::new();
313        let records_by_requirement = bundle
314            .prediction_caches
315            .iter()
316            .map(|record| (record.requirement_key.as_str(), record))
317            .collect::<BTreeMap<_, _>>();
318        for payload in &payloads.caches {
319            let record = records_by_requirement
320                .get(payload.requirement_key.as_str())
321                .ok_or_else(|| {
322                    DagMlError::RuntimeValidation(format!(
323                        "prediction cache payload `{}` references unknown requirement `{}`",
324                        payload.cache_id, payload.requirement_key
325                    ))
326                })?;
327            validate_prediction_cache_payload_matches_record(payload, record)?;
328            let entry = FilePredictionCacheEntry::from_payload(payload)?;
329            let payload_path = root.join(&entry.file_name);
330            write_runtime_json(&payload_path, payload, "prediction cache payload")?;
331            entries.push(entry);
332        }
333        entries.sort_by(|left, right| left.requirement_key.cmp(&right.requirement_key));
334        let manifest = FilePredictionCacheManifest {
335            bundle_id: bundle.bundle_id.clone(),
336            schema_version: FILE_PREDICTION_CACHE_STORE_SCHEMA_VERSION,
337            caches: entries,
338        };
339        manifest.validate_against_bundle(bundle)?;
340        write_runtime_json(
341            &root.join(FILE_PREDICTION_CACHE_MANIFEST_FILE),
342            &manifest,
343            "prediction cache manifest",
344        )?;
345        Ok(manifest)
346    }
347
348    pub fn open(root: impl Into<PathBuf>, bundle: &ExecutionBundle) -> Result<Self> {
349        bundle.validate()?;
350        let root = root.into();
351        let manifest: FilePredictionCacheManifest = read_runtime_json(
352            &root.join(FILE_PREDICTION_CACHE_MANIFEST_FILE),
353            "prediction cache manifest",
354        )?;
355        manifest.validate_against_bundle(bundle)?;
356        let records_by_requirement = bundle
357            .prediction_caches
358            .iter()
359            .cloned()
360            .map(|record| (record.requirement_key.clone(), record))
361            .collect::<BTreeMap<_, _>>();
362        Ok(Self {
363            root,
364            manifest,
365            records_by_requirement,
366            materialization_records: RefCell::new(Vec::new()),
367        })
368    }
369
370    pub fn manifest(&self) -> &FilePredictionCacheManifest {
371        &self.manifest
372    }
373
374    pub fn materialization_records(&self) -> Vec<PredictionCacheMaterializationRecord> {
375        self.materialization_records.borrow().clone()
376    }
377
378    fn payload_for_requirement(
379        &self,
380        requirement_key: &str,
381    ) -> Result<crate::bundle::BundlePredictionCachePayload> {
382        let entry = self
383            .manifest
384            .caches
385            .iter()
386            .find(|entry| entry.requirement_key == requirement_key)
387            .ok_or_else(|| {
388                DagMlError::RuntimeValidation(format!(
389                    "file prediction cache store is missing requirement `{requirement_key}`"
390                ))
391            })?;
392        let record = self
393            .records_by_requirement
394            .get(requirement_key)
395            .ok_or_else(|| {
396                DagMlError::RuntimeValidation(format!(
397                    "file prediction cache store has no bundle record for requirement `{requirement_key}`"
398                ))
399            })?;
400        let payload: crate::bundle::BundlePredictionCachePayload = read_runtime_json(
401            &self.root.join(&entry.file_name),
402            "prediction cache payload",
403        )?;
404        validate_prediction_cache_payload_matches_record(&payload, record)?;
405        Ok(payload)
406    }
407}
408
409impl RuntimePredictionCacheStore for FilePredictionCacheStore {
410    fn load_blocks(&self, requirement_key: &str) -> Result<Vec<PredictionBlock>> {
411        let payload = self.payload_for_requirement(requirement_key)?;
412        if payload.prediction_level != PredictionLevel::Sample {
413            return Err(DagMlError::RuntimeValidation(format!(
414                "file prediction cache store requirement `{requirement_key}` contains {:?} predictions, not sample blocks",
415                payload.prediction_level
416            )));
417        }
418        Ok(payload.blocks)
419    }
420
421    fn load_aggregated_blocks(
422        &self,
423        requirement_key: &str,
424    ) -> Result<Vec<AggregatedPredictionBlock>> {
425        let payload = self.payload_for_requirement(requirement_key)?;
426        if payload.prediction_level == PredictionLevel::Sample {
427            return Err(DagMlError::RuntimeValidation(format!(
428                "file prediction cache store requirement `{requirement_key}` contains sample predictions, not aggregated blocks"
429            )));
430        }
431        Ok(payload.aggregated_blocks)
432    }
433
434    fn materialize(&self, request: &PredictionCacheMaterializationRequest) -> Result<HandleRef> {
435        request.requirement.validate()?;
436        request.cache.validate()?;
437        let requirement_key = request.requirement.key();
438        let record = self
439            .records_by_requirement
440            .get(&requirement_key)
441            .ok_or_else(|| {
442                DagMlError::RuntimeValidation(format!(
443                    "file prediction cache store is missing requirement `{requirement_key}`"
444                ))
445            })?;
446        if record != &request.cache {
447            return Err(DagMlError::RuntimeValidation(format!(
448                "file prediction cache materialization request for `{requirement_key}` does not match bundle cache record"
449            )));
450        }
451        let payload = self.payload_for_requirement(&requirement_key)?;
452        validate_prediction_cache_payload_matches_record(&payload, record)?;
453        let fingerprint = stable_json_fingerprint(&(
454            &request.run_id,
455            &request.bundle_id,
456            request.phase,
457            &request.variant_id,
458            &request.cache.requirement_key,
459            &request.cache.cache_id,
460            request.cache.prediction_level,
461            &request.cache.content_fingerprint,
462        ))?;
463        let handle = HandleRef {
464            handle: u64::from_str_radix(&fingerprint[..16], 16)
465                .expect("sha256 hex prefix should fit into u64"),
466            kind: HandleKind::Prediction,
467            owner_controller: request.producer_controller_id.clone(),
468        };
469        self.materialization_records
470            .borrow_mut()
471            .push(PredictionCacheMaterializationRecord {
472                run_id: request.run_id.clone(),
473                bundle_id: request.bundle_id.clone(),
474                phase: request.phase,
475                variant_id: request.variant_id.clone(),
476                requirement_key,
477                cache_id: request.cache.cache_id.clone(),
478                handle: handle.clone(),
479            });
480        Ok(handle)
481    }
482}
483
484pub(crate) fn prediction_cache_payload_file_name(
485    payload: &crate::bundle::BundlePredictionCachePayload,
486) -> Result<String> {
487    let fingerprint = stable_json_fingerprint(&(
488        &payload.requirement_key,
489        &payload.cache_id,
490        payload.prediction_level,
491        &payload.content_fingerprint,
492        payload.block_count,
493        payload.row_count,
494    ))?;
495    Ok(format!("prediction-cache-{}.json", &fingerprint[..16]))
496}
497
498pub(crate) fn validate_prediction_cache_file_name(file_name: &str) -> Result<()> {
499    if file_name == "." || file_name == ".." || file_name.contains('/') || file_name.contains('\\')
500    {
501        return Err(DagMlError::RuntimeValidation(format!(
502            "prediction cache file name `{file_name}` must be a plain file name"
503        )));
504    }
505    Ok(())
506}
507
508#[derive(Clone, Debug, PartialEq)]
509pub struct ColumnarPredictionCacheBlock {
510    pub prediction_id: Option<String>,
511    pub producer_node: NodeId,
512    pub partition: PredictionPartition,
513    pub fold_id: Option<FoldId>,
514    pub prediction_level: PredictionLevel,
515    pub unit_ids: Vec<PredictionUnitId>,
516    pub sample_ids: Vec<SampleId>,
517    pub target_names: Vec<String>,
518    pub width: usize,
519    pub columns: Vec<Vec<f64>>,
520}
521
522impl ColumnarPredictionCacheBlock {
523    pub fn from_prediction_block(block: &PredictionBlock) -> Result<Self> {
524        let width = block.validate_shape()?;
525        let mut columns = vec![Vec::with_capacity(block.values.len()); width];
526        for row in &block.values {
527            for (column_idx, value) in row.iter().enumerate() {
528                columns[column_idx].push(*value);
529            }
530        }
531        Ok(Self {
532            prediction_id: block.prediction_id.clone(),
533            producer_node: block.producer_node.clone(),
534            partition: block.partition.clone(),
535            fold_id: block.fold_id.clone(),
536            prediction_level: PredictionLevel::Sample,
537            unit_ids: Vec::new(),
538            sample_ids: block.sample_ids.clone(),
539            target_names: block.target_names.clone(),
540            width,
541            columns,
542        })
543    }
544
545    pub fn from_aggregated_prediction_block(block: &AggregatedPredictionBlock) -> Result<Self> {
546        let width = block.validate_shape()?;
547        if block.level == PredictionLevel::Sample {
548            return Err(DagMlError::RuntimeValidation(format!(
549                "columnar aggregated prediction block for `{}` must use target/group level, got sample",
550                block.producer_node
551            )));
552        }
553        let mut columns = vec![Vec::with_capacity(block.values.len()); width];
554        for row in &block.values {
555            for (column_idx, value) in row.iter().enumerate() {
556                columns[column_idx].push(*value);
557            }
558        }
559        Ok(Self {
560            prediction_id: block.prediction_id.clone(),
561            producer_node: block.producer_node.clone(),
562            partition: block.partition.clone(),
563            fold_id: block.fold_id.clone(),
564            prediction_level: block.level,
565            unit_ids: block.unit_ids.clone(),
566            sample_ids: Vec::new(),
567            target_names: block.target_names.clone(),
568            width,
569            columns,
570        })
571    }
572
573    pub fn row_count(&self) -> usize {
574        match self.prediction_level {
575            PredictionLevel::Sample => self.sample_ids.len(),
576            PredictionLevel::Target | PredictionLevel::Group => self.unit_ids.len(),
577            PredictionLevel::Observation => 0,
578        }
579    }
580
581    pub fn value_count(&self) -> usize {
582        self.columns.iter().map(Vec::len).sum()
583    }
584
585    pub fn validate(&self) -> Result<()> {
586        match self.prediction_level {
587            PredictionLevel::Observation => {
588                return Err(DagMlError::RuntimeValidation(format!(
589                    "columnar prediction block for `{}` cannot store observation-level predictions",
590                    self.producer_node
591                )));
592            }
593            PredictionLevel::Sample => {
594                if self.sample_ids.is_empty() {
595                    return Err(DagMlError::RuntimeValidation(format!(
596                        "columnar sample prediction block for `{}` has no sample ids",
597                        self.producer_node
598                    )));
599                }
600                if !self.unit_ids.is_empty() {
601                    return Err(DagMlError::RuntimeValidation(format!(
602                        "columnar sample prediction block for `{}` unexpectedly carries unit ids",
603                        self.producer_node
604                    )));
605                }
606            }
607            PredictionLevel::Target | PredictionLevel::Group => {
608                if !self.sample_ids.is_empty() {
609                    return Err(DagMlError::RuntimeValidation(format!(
610                        "columnar aggregated prediction block for `{}` unexpectedly carries sample ids",
611                        self.producer_node
612                    )));
613                }
614                if self.unit_ids.is_empty() {
615                    return Err(DagMlError::RuntimeValidation(format!(
616                        "columnar aggregated prediction block for `{}` has no unit ids",
617                        self.producer_node
618                    )));
619                }
620                if self
621                    .unit_ids
622                    .iter()
623                    .any(|unit_id| unit_id.level() != self.prediction_level)
624                {
625                    return Err(DagMlError::RuntimeValidation(format!(
626                        "columnar aggregated prediction block for `{}` carries unit ids outside {:?}",
627                        self.producer_node, self.prediction_level
628                    )));
629                }
630            }
631        }
632        if self.width == 0 {
633            return Err(DagMlError::RuntimeValidation(format!(
634                "columnar prediction block for `{}` has zero width",
635                self.producer_node
636            )));
637        }
638        if self.columns.len() != self.width {
639            return Err(DagMlError::RuntimeValidation(format!(
640                "columnar prediction block for `{}` has {} column(s), expected {}",
641                self.producer_node,
642                self.columns.len(),
643                self.width
644            )));
645        }
646        for (column_idx, column) in self.columns.iter().enumerate() {
647            if column.len() != self.row_count() {
648                return Err(DagMlError::RuntimeValidation(format!(
649                    "columnar prediction block for `{}` column {} has {} value(s), expected {}",
650                    self.producer_node,
651                    column_idx,
652                    column.len(),
653                    self.row_count()
654                )));
655            }
656        }
657        if !self.target_names.is_empty() && self.target_names.len() != self.width {
658            return Err(DagMlError::RuntimeValidation(format!(
659                "columnar prediction block for `{}` has {} target names for width {}",
660                self.producer_node,
661                self.target_names.len(),
662                self.width
663            )));
664        }
665        Ok(())
666    }
667
668    pub fn to_prediction_block(&self) -> Result<PredictionBlock> {
669        self.validate()?;
670        if self.prediction_level != PredictionLevel::Sample {
671            return Err(DagMlError::RuntimeValidation(format!(
672                "columnar prediction block for `{}` contains {:?} predictions, not sample predictions",
673                self.producer_node, self.prediction_level
674            )));
675        }
676        let values = (0..self.row_count())
677            .map(|row_idx| {
678                self.columns
679                    .iter()
680                    .map(|column| column[row_idx])
681                    .collect::<Vec<_>>()
682            })
683            .collect();
684        let block = PredictionBlock {
685            prediction_id: self.prediction_id.clone(),
686            producer_node: self.producer_node.clone(),
687            partition: self.partition.clone(),
688            fold_id: self.fold_id.clone(),
689            sample_ids: self.sample_ids.clone(),
690            values,
691            target_names: self.target_names.clone(),
692        };
693        block.validate_shape()?;
694        Ok(block)
695    }
696
697    pub fn to_aggregated_prediction_block(&self) -> Result<AggregatedPredictionBlock> {
698        self.validate()?;
699        if self.prediction_level == PredictionLevel::Sample {
700            return Err(DagMlError::RuntimeValidation(format!(
701                "columnar prediction block for `{}` contains sample predictions, not aggregated predictions",
702                self.producer_node
703            )));
704        }
705        let values = (0..self.row_count())
706            .map(|row_idx| {
707                self.columns
708                    .iter()
709                    .map(|column| column[row_idx])
710                    .collect::<Vec<_>>()
711            })
712            .collect();
713        let block = AggregatedPredictionBlock {
714            prediction_id: self.prediction_id.clone(),
715            producer_node: self.producer_node.clone(),
716            partition: self.partition.clone(),
717            fold_id: self.fold_id.clone(),
718            level: self.prediction_level,
719            unit_ids: self.unit_ids.clone(),
720            values,
721            target_names: self.target_names.clone(),
722        };
723        block.validate_shape()?;
724        Ok(block)
725    }
726}
727
728#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
729pub struct ColumnarPredictionCacheManifest {
730    pub requirement_key: String,
731    pub cache_id: String,
732    pub prediction_level: PredictionLevel,
733    pub block_count: usize,
734    pub row_count: usize,
735    pub prediction_width: usize,
736    pub value_count: usize,
737    pub estimated_value_bytes: usize,
738    pub content_fingerprint: String,
739}
740
741#[derive(Clone, Debug, PartialEq)]
742pub(crate) struct ColumnarPredictionCacheEntry {
743    cache: BundlePredictionCacheRecord,
744    blocks: Vec<ColumnarPredictionCacheBlock>,
745}
746
747impl ColumnarPredictionCacheEntry {
748    fn from_payload(
749        payload: BundlePredictionCachePayload,
750        cache: BundlePredictionCacheRecord,
751    ) -> Result<Self> {
752        validate_prediction_cache_payload_matches_record(&payload, &cache)?;
753        let blocks = match payload.prediction_level {
754            PredictionLevel::Sample => payload
755                .blocks
756                .iter()
757                .map(ColumnarPredictionCacheBlock::from_prediction_block)
758                .collect::<Result<Vec<_>>>()?,
759            PredictionLevel::Target | PredictionLevel::Group => payload
760                .aggregated_blocks
761                .iter()
762                .map(ColumnarPredictionCacheBlock::from_aggregated_prediction_block)
763                .collect::<Result<Vec<_>>>()?,
764            PredictionLevel::Observation => {
765                return Err(DagMlError::RuntimeValidation(format!(
766                    "columnar prediction cache payload `{}` cannot use observation-level predictions",
767                    payload.cache_id
768                )));
769            }
770        };
771        let entry = Self { cache, blocks };
772        entry.validate()?;
773        Ok(entry)
774    }
775
776    fn validate(&self) -> Result<()> {
777        self.cache.validate()?;
778        if self.blocks.len() != self.cache.block_count {
779            return Err(DagMlError::RuntimeValidation(format!(
780                "columnar prediction cache `{}` has {} block(s), expected {}",
781                self.cache.cache_id,
782                self.blocks.len(),
783                self.cache.block_count
784            )));
785        }
786        let mut row_count = 0usize;
787        let mut value_count = 0usize;
788        for block in &self.blocks {
789            block.validate()?;
790            if block.prediction_level != self.cache.prediction_level {
791                return Err(DagMlError::RuntimeValidation(format!(
792                    "columnar prediction cache `{}` contains a {:?} block, expected {:?}",
793                    self.cache.cache_id, block.prediction_level, self.cache.prediction_level
794                )));
795            }
796            if block.partition != self.cache.partition {
797                return Err(DagMlError::RuntimeValidation(format!(
798                    "columnar prediction cache `{}` contains a block from partition {:?}",
799                    self.cache.cache_id, block.partition
800                )));
801            }
802            row_count += block.row_count();
803            value_count += block.value_count();
804        }
805        if row_count != self.cache.row_count {
806            return Err(DagMlError::RuntimeValidation(format!(
807                "columnar prediction cache `{}` has {} row(s), expected {}",
808                self.cache.cache_id, row_count, self.cache.row_count
809            )));
810        }
811        let expected_values = self
812            .cache
813            .row_count
814            .checked_mul(self.cache.prediction_width)
815            .ok_or_else(|| {
816                DagMlError::RuntimeValidation(format!(
817                    "columnar prediction cache `{}` value count overflow",
818                    self.cache.cache_id
819                ))
820            })?;
821        if value_count != expected_values {
822            return Err(DagMlError::RuntimeValidation(format!(
823                "columnar prediction cache `{}` has {} value(s), expected {}",
824                self.cache.cache_id, value_count, expected_values
825            )));
826        }
827        Ok(())
828    }
829
830    fn to_blocks(&self) -> Result<Vec<PredictionBlock>> {
831        self.validate()?;
832        self.blocks
833            .iter()
834            .map(ColumnarPredictionCacheBlock::to_prediction_block)
835            .collect()
836    }
837
838    fn to_aggregated_blocks(&self) -> Result<Vec<AggregatedPredictionBlock>> {
839        self.validate()?;
840        self.blocks
841            .iter()
842            .map(ColumnarPredictionCacheBlock::to_aggregated_prediction_block)
843            .collect()
844    }
845
846    fn validate_against_cache_record(&self, cache: &BundlePredictionCacheRecord) -> Result<()> {
847        if &self.cache != cache {
848            return Err(DagMlError::RuntimeValidation(format!(
849                "columnar prediction cache materialization request for `{}` does not match bundle cache record",
850                cache.requirement_key
851            )));
852        }
853        let (blocks, aggregated_blocks) = match self.cache.prediction_level {
854            PredictionLevel::Sample => (self.to_blocks()?, Vec::new()),
855            PredictionLevel::Target | PredictionLevel::Group => {
856                (Vec::new(), self.to_aggregated_blocks()?)
857            }
858            PredictionLevel::Observation => {
859                return Err(DagMlError::RuntimeValidation(format!(
860                    "columnar prediction cache `{}` cannot materialize observation-level predictions",
861                    self.cache.cache_id
862                )));
863            }
864        };
865        let payload = BundlePredictionCachePayload {
866            requirement_key: self.cache.requirement_key.clone(),
867            cache_id: self.cache.cache_id.clone(),
868            format: self.cache.format.clone(),
869            partition: self.cache.partition.clone(),
870            prediction_level: self.cache.prediction_level,
871            block_count: self.cache.block_count,
872            row_count: self.cache.row_count,
873            content_fingerprint: self.cache.content_fingerprint.clone(),
874            blocks,
875            aggregated_blocks,
876        };
877        validate_prediction_cache_payload_matches_record(&payload, cache)
878    }
879
880    fn manifest(&self) -> ColumnarPredictionCacheManifest {
881        let value_count = self
882            .blocks
883            .iter()
884            .map(ColumnarPredictionCacheBlock::value_count)
885            .sum::<usize>();
886        ColumnarPredictionCacheManifest {
887            requirement_key: self.cache.requirement_key.clone(),
888            cache_id: self.cache.cache_id.clone(),
889            prediction_level: self.cache.prediction_level,
890            block_count: self.cache.block_count,
891            row_count: self.cache.row_count,
892            prediction_width: self.cache.prediction_width,
893            value_count,
894            estimated_value_bytes: value_count * std::mem::size_of::<f64>(),
895            content_fingerprint: self.cache.content_fingerprint.clone(),
896        }
897    }
898}
899
900#[derive(Clone, Debug, Default)]
901pub struct ColumnarPredictionCacheStore {
902    entries: BTreeMap<String, ColumnarPredictionCacheEntry>,
903    materialization_records: RefCell<Vec<PredictionCacheMaterializationRecord>>,
904}
905
906impl ColumnarPredictionCacheStore {
907    pub fn from_payloads(
908        bundle: &ExecutionBundle,
909        payloads: BundlePredictionCachePayloadSet,
910    ) -> Result<Self> {
911        payloads.validate_against_bundle(bundle)?;
912        let records_by_requirement = bundle
913            .prediction_caches
914            .iter()
915            .cloned()
916            .map(|cache| (cache.requirement_key.clone(), cache))
917            .collect::<BTreeMap<_, _>>();
918        let mut entries = BTreeMap::new();
919        for payload in payloads.caches {
920            let cache = records_by_requirement
921                .get(&payload.requirement_key)
922                .cloned()
923                .ok_or_else(|| {
924                    DagMlError::RuntimeValidation(format!(
925                        "columnar prediction cache payload `{}` references unknown requirement `{}`",
926                        payload.cache_id, payload.requirement_key
927                    ))
928                })?;
929            let requirement_key = payload.requirement_key.clone();
930            let previous = entries.insert(
931                requirement_key,
932                ColumnarPredictionCacheEntry::from_payload(payload, cache)?,
933            );
934            debug_assert!(previous.is_none());
935        }
936        Ok(Self {
937            entries,
938            materialization_records: RefCell::new(Vec::new()),
939        })
940    }
941
942    pub fn entry_count(&self) -> usize {
943        self.entries.len()
944    }
945
946    pub fn manifests(&self) -> Vec<ColumnarPredictionCacheManifest> {
947        self.entries
948            .values()
949            .map(ColumnarPredictionCacheEntry::manifest)
950            .collect()
951    }
952
953    pub fn materialization_records(&self) -> Vec<PredictionCacheMaterializationRecord> {
954        self.materialization_records.borrow().clone()
955    }
956}
957
958impl RuntimePredictionCacheStore for ColumnarPredictionCacheStore {
959    fn load_blocks(&self, requirement_key: &str) -> Result<Vec<PredictionBlock>> {
960        let entry = self.entries.get(requirement_key).ok_or_else(|| {
961            DagMlError::RuntimeValidation(format!(
962                "columnar prediction cache store is missing requirement `{requirement_key}`"
963            ))
964        })?;
965        if entry.cache.prediction_level != PredictionLevel::Sample {
966            return Err(DagMlError::RuntimeValidation(format!(
967                "columnar prediction cache store requirement `{requirement_key}` contains {:?} predictions, not sample blocks",
968                entry.cache.prediction_level
969            )));
970        }
971        entry.validate_against_cache_record(&entry.cache)?;
972        entry.to_blocks()
973    }
974
975    fn load_aggregated_blocks(
976        &self,
977        requirement_key: &str,
978    ) -> Result<Vec<AggregatedPredictionBlock>> {
979        let entry = self.entries.get(requirement_key).ok_or_else(|| {
980            DagMlError::RuntimeValidation(format!(
981                "columnar prediction cache store is missing requirement `{requirement_key}`"
982            ))
983        })?;
984        if entry.cache.prediction_level == PredictionLevel::Sample {
985            return Err(DagMlError::RuntimeValidation(format!(
986                "columnar prediction cache store requirement `{requirement_key}` contains sample predictions, not aggregated blocks"
987            )));
988        }
989        entry.validate_against_cache_record(&entry.cache)?;
990        entry.to_aggregated_blocks()
991    }
992
993    fn materialize(&self, request: &PredictionCacheMaterializationRequest) -> Result<HandleRef> {
994        request.requirement.validate()?;
995        request.cache.validate()?;
996        let requirement_key = request.requirement.key();
997        if requirement_key != request.cache.requirement_key {
998            return Err(DagMlError::RuntimeValidation(format!(
999                "columnar prediction cache materialization request for `{}` uses cache `{}` with mismatched requirement `{}`",
1000                requirement_key, request.cache.cache_id, request.cache.requirement_key
1001            )));
1002        }
1003        let entry = self.entries.get(&requirement_key).ok_or_else(|| {
1004            DagMlError::RuntimeValidation(format!(
1005                "columnar prediction cache store is missing requirement `{requirement_key}`"
1006            ))
1007        })?;
1008        entry.validate_against_cache_record(&request.cache)?;
1009        let fingerprint = stable_json_fingerprint(&(
1010            &request.run_id,
1011            &request.bundle_id,
1012            request.phase,
1013            &request.variant_id,
1014            &request.cache.requirement_key,
1015            &request.cache.cache_id,
1016            request.cache.prediction_level,
1017            &request.cache.content_fingerprint,
1018        ))?;
1019        let handle = HandleRef {
1020            handle: u64::from_str_radix(&fingerprint[..16], 16)
1021                .expect("sha256 hex prefix should fit into u64"),
1022            kind: HandleKind::Prediction,
1023            owner_controller: request.producer_controller_id.clone(),
1024        };
1025        self.materialization_records
1026            .borrow_mut()
1027            .push(PredictionCacheMaterializationRecord {
1028                run_id: request.run_id.clone(),
1029                bundle_id: request.bundle_id.clone(),
1030                phase: request.phase,
1031                variant_id: request.variant_id.clone(),
1032                requirement_key,
1033                cache_id: request.cache.cache_id.clone(),
1034                handle: handle.clone(),
1035            });
1036        Ok(handle)
1037    }
1038}
1039
1040pub(crate) fn validate_runtime_non_empty(label: &str, value: &str) -> Result<()> {
1041    if value.trim().is_empty() {
1042        return Err(DagMlError::RuntimeValidation(format!("{label} is empty")));
1043    }
1044    Ok(())
1045}
1046
1047pub(crate) fn validate_artifact_optional_text(
1048    label: &str,
1049    value: &Option<String>,
1050    artifact_id: &ArtifactId,
1051) -> Result<()> {
1052    let Some(value) = value else {
1053        return Ok(());
1054    };
1055    if value.trim().is_empty() {
1056        return Err(DagMlError::RuntimeValidation(format!(
1057            "artifact `{artifact_id}` has empty {label}"
1058        )));
1059    }
1060    if value.chars().any(char::is_control) {
1061        return Err(DagMlError::RuntimeValidation(format!(
1062            "artifact `{artifact_id}` has control characters in {label}"
1063        )));
1064    }
1065    Ok(())
1066}
1067
1068pub(crate) fn artifact_payload_path(root: &Path, artifact: &ArtifactRef) -> Result<PathBuf> {
1069    artifact.validate_portable()?;
1070    let uri = artifact
1071        .uri
1072        .as_deref()
1073        .expect("portable artifact validation requires uri");
1074    Ok(root.join(uri))
1075}
1076
1077pub(crate) fn validate_artifact_payload_file(
1078    root: &Path,
1079    artifact: &ArtifactRef,
1080) -> Result<ArtifactPayloadMetadata> {
1081    artifact.validate_portable()?;
1082    let uri = artifact
1083        .uri
1084        .as_deref()
1085        .expect("portable artifact validation requires uri")
1086        .to_string();
1087    let path = artifact_payload_path(root, artifact)?;
1088    validate_payload_path_stays_within_root(root, &path, artifact)?;
1089    let metadata = fs::metadata(&path).map_err(|err| {
1090        DagMlError::RuntimeValidation(format!(
1091            "failed to stat artifact payload `{}` at {}: {err}",
1092            artifact.id,
1093            path.display()
1094        ))
1095    })?;
1096    if !metadata.is_file() {
1097        return Err(DagMlError::RuntimeValidation(format!(
1098            "artifact payload `{}` at {} is not a regular file",
1099            artifact.id,
1100            path.display()
1101        )));
1102    }
1103    let size_bytes = metadata.len();
1104    if let Some(expected_size) = artifact.size_bytes {
1105        if expected_size != size_bytes {
1106            return Err(DagMlError::RuntimeValidation(format!(
1107                "artifact payload `{}` size mismatch: expected {}, got {}",
1108                artifact.id, expected_size, size_bytes
1109            )));
1110        }
1111    }
1112    let content_fingerprint =
1113        sha256_file_hex(&path, &format!("artifact payload `{}`", artifact.id))?;
1114    let expected_fingerprint = artifact
1115        .content_fingerprint
1116        .as_deref()
1117        .expect("portable artifact validation requires content_fingerprint");
1118    if !content_fingerprint.eq_ignore_ascii_case(expected_fingerprint) {
1119        return Err(DagMlError::RuntimeValidation(format!(
1120            "artifact payload `{}` content fingerprint mismatch",
1121            artifact.id
1122        )));
1123    }
1124    Ok(ArtifactPayloadMetadata {
1125        uri,
1126        content_fingerprint,
1127        size_bytes,
1128    })
1129}
1130
1131pub(crate) fn validate_payload_path_stays_within_root(
1132    root: &Path,
1133    path: &Path,
1134    artifact: &ArtifactRef,
1135) -> Result<()> {
1136    let root = fs::canonicalize(root).map_err(|err| {
1137        DagMlError::RuntimeValidation(format!(
1138            "failed to canonicalize artifact payload root `{}`: {err}",
1139            root.display()
1140        ))
1141    })?;
1142    let path = fs::canonicalize(path).map_err(|err| {
1143        DagMlError::RuntimeValidation(format!(
1144            "failed to canonicalize artifact payload `{}` at {}: {err}",
1145            artifact.id,
1146            path.display()
1147        ))
1148    })?;
1149    if !path.starts_with(&root) {
1150        return Err(DagMlError::RuntimeValidation(format!(
1151            "artifact payload `{}` resolves outside store root `{}`",
1152            artifact.id,
1153            root.display()
1154        )));
1155    }
1156    Ok(())
1157}
1158
1159pub(crate) fn sha256_file_hex(path: &Path, label: &str) -> Result<String> {
1160    let mut file = fs::File::open(path).map_err(|err| {
1161        DagMlError::RuntimeValidation(format!(
1162            "failed to open {label} at {}: {err}",
1163            path.display()
1164        ))
1165    })?;
1166    let mut hasher = Sha256::new();
1167    let mut buffer = [0u8; 64 * 1024];
1168    loop {
1169        let read = file.read(&mut buffer).map_err(|err| {
1170            DagMlError::RuntimeValidation(format!(
1171                "failed to read {label} at {}: {err}",
1172                path.display()
1173            ))
1174        })?;
1175        if read == 0 {
1176            break;
1177        }
1178        hasher.update(&buffer[..read]);
1179    }
1180    Ok(bytes_to_hex(&hasher.finalize()))
1181}
1182
1183#[cfg(test)]
1184pub(crate) fn sha256_bytes_hex(bytes: &[u8]) -> String {
1185    bytes_to_hex(&Sha256::digest(bytes))
1186}
1187
1188pub(crate) fn bytes_to_hex(bytes: &[u8]) -> String {
1189    let mut out = String::with_capacity(bytes.len() * 2);
1190    for byte in bytes {
1191        use std::fmt::Write as _;
1192        write!(&mut out, "{byte:02x}").expect("writing to String cannot fail");
1193    }
1194    out
1195}
1196
1197/// Deterministic path safety for relative artifact URIs. Rejects empty values,
1198/// control characters, absolute paths (POSIX root, Windows root or drive
1199/// prefix), URI schemes such as `http://`, `s3://` or `file://` (any colon in
1200/// the leading path segment) and any `..` traversal component. Parsing is
1201/// platform-independent so portable manifests validate identically everywhere;
1202/// it adds no dependency.
1203pub(crate) fn validate_relative_artifact_uri(artifact_id: &ArtifactId, uri: &str) -> Result<()> {
1204    if uri.is_empty() {
1205        return Err(DagMlError::RuntimeValidation(format!(
1206            "artifact `{artifact_id}` has empty uri"
1207        )));
1208    }
1209    if uri.chars().any(char::is_control) {
1210        return Err(DagMlError::RuntimeValidation(format!(
1211            "artifact `{artifact_id}` uri has control characters"
1212        )));
1213    }
1214    if uri.starts_with('/') || uri.starts_with('\\') {
1215        return Err(DagMlError::RuntimeValidation(format!(
1216            "artifact `{artifact_id}` uri `{uri}` must be a relative path"
1217        )));
1218    }
1219    let mut prefix = uri.chars();
1220    if let (Some(drive), Some(':')) = (prefix.next(), prefix.next()) {
1221        if drive.is_ascii_alphabetic() {
1222            return Err(DagMlError::RuntimeValidation(format!(
1223                "artifact `{artifact_id}` uri `{uri}` must be a relative path"
1224            )));
1225        }
1226    }
1227    // Reject URI schemes (`http://`, `s3://`, `file://`, ...) and any other
1228    // colon in the leading path segment. A scheme always places a colon in the
1229    // first segment, so a strictly relative artifact path never carries one.
1230    let first_segment = uri.split(['/', '\\']).next().unwrap_or(uri);
1231    if first_segment.contains(':') {
1232        return Err(DagMlError::RuntimeValidation(format!(
1233            "artifact `{artifact_id}` uri `{uri}` must not include a scheme or colon in its first path segment"
1234        )));
1235    }
1236    for segment in uri.split(['/', '\\']) {
1237        if segment == ".." {
1238            return Err(DagMlError::RuntimeValidation(format!(
1239                "artifact `{artifact_id}` uri `{uri}` must not contain `..` components"
1240            )));
1241        }
1242    }
1243    Ok(())
1244}
1245
1246pub(crate) fn validate_runtime_fingerprint(label: &str, value: &str) -> Result<()> {
1247    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1248        return Err(DagMlError::RuntimeValidation(format!(
1249            "{label} fingerprint must be a 64-character hex digest"
1250        )));
1251    }
1252    Ok(())
1253}
1254
1255pub(crate) fn read_runtime_json<T: serde::de::DeserializeOwned>(
1256    path: &Path,
1257    label: &str,
1258) -> Result<T> {
1259    let data = fs::read(path).map_err(|err| {
1260        DagMlError::RuntimeValidation(format!(
1261            "failed to read {label} at {}: {err}",
1262            path.display()
1263        ))
1264    })?;
1265    serde_json::from_slice(&data).map_err(|err| {
1266        DagMlError::RuntimeValidation(format!(
1267            "failed to parse {label} at {}: {err}",
1268            path.display()
1269        ))
1270    })
1271}
1272
1273pub(crate) fn write_runtime_json<T: Serialize>(path: &Path, value: &T, label: &str) -> Result<()> {
1274    let mut data = serde_json::to_vec_pretty(value).map_err(|err| {
1275        DagMlError::RuntimeValidation(format!("failed to serialize {label}: {err}"))
1276    })?;
1277    data.push(b'\n');
1278    fs::write(path, data).map_err(|err| {
1279        DagMlError::RuntimeValidation(format!(
1280            "failed to write {label} at {}: {err}",
1281            path.display()
1282        ))
1283    })
1284}
1285#[derive(Clone, Debug, Default)]
1286pub struct InMemoryPredictionCacheStore {
1287    payloads: BTreeMap<String, crate::bundle::BundlePredictionCachePayload>,
1288    materialization_records: RefCell<Vec<PredictionCacheMaterializationRecord>>,
1289}
1290
1291impl InMemoryPredictionCacheStore {
1292    pub fn from_payloads(
1293        bundle: &ExecutionBundle,
1294        payloads: BundlePredictionCachePayloadSet,
1295    ) -> Result<Self> {
1296        payloads.validate_against_bundle(bundle)?;
1297        Ok(Self {
1298            payloads: payloads
1299                .caches
1300                .into_iter()
1301                .map(|payload| (payload.requirement_key.clone(), payload))
1302                .collect(),
1303            materialization_records: RefCell::new(Vec::new()),
1304        })
1305    }
1306
1307    pub fn payload_count(&self) -> usize {
1308        self.payloads.len()
1309    }
1310
1311    pub fn materialization_records(&self) -> Vec<PredictionCacheMaterializationRecord> {
1312        self.materialization_records.borrow().clone()
1313    }
1314}
1315
1316impl RuntimePredictionCacheStore for InMemoryPredictionCacheStore {
1317    fn load_blocks(&self, requirement_key: &str) -> Result<Vec<PredictionBlock>> {
1318        let payload = self.payloads.get(requirement_key).ok_or_else(|| {
1319            DagMlError::RuntimeValidation(format!(
1320                "prediction cache store is missing requirement `{requirement_key}`"
1321            ))
1322        })?;
1323        payload.validate()?;
1324        if payload.prediction_level != PredictionLevel::Sample {
1325            return Err(DagMlError::RuntimeValidation(format!(
1326                "prediction cache store requirement `{requirement_key}` contains {:?} predictions, not sample blocks",
1327                payload.prediction_level
1328            )));
1329        }
1330        Ok(payload.blocks.clone())
1331    }
1332
1333    fn load_aggregated_blocks(
1334        &self,
1335        requirement_key: &str,
1336    ) -> Result<Vec<AggregatedPredictionBlock>> {
1337        let payload = self.payloads.get(requirement_key).ok_or_else(|| {
1338            DagMlError::RuntimeValidation(format!(
1339                "prediction cache store is missing requirement `{requirement_key}`"
1340            ))
1341        })?;
1342        payload.validate()?;
1343        if payload.prediction_level == PredictionLevel::Sample {
1344            return Err(DagMlError::RuntimeValidation(format!(
1345                "prediction cache store requirement `{requirement_key}` contains sample predictions, not aggregated blocks"
1346            )));
1347        }
1348        Ok(payload.aggregated_blocks.clone())
1349    }
1350
1351    fn materialize(&self, request: &PredictionCacheMaterializationRequest) -> Result<HandleRef> {
1352        request.requirement.validate()?;
1353        request.cache.validate()?;
1354        if request.requirement.key() != request.cache.requirement_key {
1355            return Err(DagMlError::RuntimeValidation(format!(
1356                "prediction cache materialization request for `{}` uses cache `{}` with mismatched requirement `{}`",
1357                request.requirement.key(),
1358                request.cache.cache_id,
1359                request.cache.requirement_key
1360            )));
1361        }
1362        let payload = self
1363            .payloads
1364            .get(&request.cache.requirement_key)
1365            .ok_or_else(|| {
1366                DagMlError::RuntimeValidation(format!(
1367                    "prediction cache store is missing requirement `{}`",
1368                    request.cache.requirement_key
1369                ))
1370            })?;
1371        validate_prediction_cache_payload_matches_record(payload, &request.cache)?;
1372        let fingerprint = stable_json_fingerprint(&(
1373            &request.run_id,
1374            &request.bundle_id,
1375            request.phase,
1376            &request.variant_id,
1377            &request.cache.requirement_key,
1378            &request.cache.cache_id,
1379            request.cache.prediction_level,
1380            &request.cache.content_fingerprint,
1381        ))?;
1382        let handle = HandleRef {
1383            handle: u64::from_str_radix(&fingerprint[..16], 16)
1384                .expect("sha256 hex prefix should fit into u64"),
1385            kind: HandleKind::Prediction,
1386            owner_controller: request.producer_controller_id.clone(),
1387        };
1388        self.materialization_records
1389            .borrow_mut()
1390            .push(PredictionCacheMaterializationRecord {
1391                run_id: request.run_id.clone(),
1392                bundle_id: request.bundle_id.clone(),
1393                phase: request.phase,
1394                variant_id: request.variant_id.clone(),
1395                requirement_key: request.cache.requirement_key.clone(),
1396                cache_id: request.cache.cache_id.clone(),
1397                handle: handle.clone(),
1398            });
1399        Ok(handle)
1400    }
1401}