1use 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
1197pub(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 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}