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