Skip to main content

dag_ml_core/runtime/
prediction_store.rs

1// Auto-split from the former monolithic `runtime.rs` (pure refactor).
2use super::*;
3
4#[derive(Clone, Debug, Default)]
5pub struct InMemoryPredictionStore {
6    blocks: Vec<PredictionBlock>,
7}
8
9impl InMemoryPredictionStore {
10    pub fn new() -> Self {
11        Self::default()
12    }
13
14    pub fn append(&mut self, block: PredictionBlock) -> Result<()> {
15        block.validate_content()?;
16        self.blocks.push(block);
17        Ok(())
18    }
19
20    pub fn blocks(&self) -> &[PredictionBlock] {
21        &self.blocks
22    }
23
24    pub fn find(
25        &self,
26        producer_node: Option<&NodeId>,
27        phase_partition: Option<&crate::oof::PredictionPartition>,
28        fold_id: Option<&FoldId>,
29    ) -> Vec<&PredictionBlock> {
30        self.blocks
31            .iter()
32            .filter(|block| {
33                producer_node.is_none_or(|node_id| &block.producer_node == node_id)
34                    && phase_partition.is_none_or(|partition| &block.partition == partition)
35                    && fold_id.is_none_or(|requested| block.fold_id.as_ref() == Some(requested))
36            })
37            .collect()
38    }
39}
40
41#[derive(Clone, Debug, Default)]
42pub struct InMemoryAggregatedPredictionStore {
43    blocks: Vec<AggregatedPredictionBlock>,
44}
45
46impl InMemoryAggregatedPredictionStore {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    pub fn append(&mut self, block: AggregatedPredictionBlock) -> Result<()> {
52        block.validate_shape()?;
53        self.blocks.push(block);
54        Ok(())
55    }
56
57    pub fn blocks(&self) -> &[AggregatedPredictionBlock] {
58        &self.blocks
59    }
60
61    pub fn find(
62        &self,
63        producer_node: Option<&NodeId>,
64        phase_partition: Option<&PredictionPartition>,
65        fold_id: Option<&FoldId>,
66        prediction_level: Option<PredictionLevel>,
67    ) -> Vec<&AggregatedPredictionBlock> {
68        self.blocks
69            .iter()
70            .filter(|block| {
71                producer_node.is_none_or(|node_id| &block.producer_node == node_id)
72                    && phase_partition.is_none_or(|partition| &block.partition == partition)
73                    && fold_id.is_none_or(|requested| block.fold_id.as_ref() == Some(requested))
74                    && prediction_level.is_none_or(|level| block.level == level)
75            })
76            .collect()
77    }
78}
79
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81pub struct PredictionCacheMaterializationRequest {
82    pub run_id: RunId,
83    pub bundle_id: BundleId,
84    pub phase: Phase,
85    pub variant_id: Option<VariantId>,
86    pub requirement: BundlePredictionRequirement,
87    pub cache: BundlePredictionCacheRecord,
88    pub producer_controller_id: ControllerId,
89}
90
91#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92pub struct PredictionCacheMaterializationRecord {
93    pub run_id: RunId,
94    pub bundle_id: BundleId,
95    pub phase: Phase,
96    pub variant_id: Option<VariantId>,
97    pub requirement_key: String,
98    pub cache_id: String,
99    #[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
1319/// Deterministic path safety for relative artifact URIs. Rejects empty values,
1320/// control characters, absolute paths (POSIX root, Windows root or drive
1321/// prefix), URI schemes such as `http://`, `s3://` or `file://` (any colon in
1322/// the leading path segment) and any `..` traversal component. Parsing is
1323/// platform-independent so portable manifests validate identically everywhere;
1324/// it adds no dependency.
1325pub(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    // Reject URI schemes (`http://`, `s3://`, `file://`, ...) and any other
1350    // colon in the leading path segment. A scheme always places a colon in the
1351    // first segment, so a strictly relative artifact path never carries one.
1352    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}