1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::aggregation::{AggregatedPredictionBlock, PredictionUnitId};
6use crate::campaign::stable_json_fingerprint;
7use crate::data::{
8 ExternalDataPlanEnvelope, RepresentationCompatibilityReport, RepresentationReplayManifest,
9};
10use crate::error::{DagMlError, Result};
11use crate::ids::{BundleId, ControllerId, FoldId, NodeId, SampleId, VariantId};
12use crate::metrics::ScoreSet;
13use crate::oof::{PredictionBlock, PredictionPartition};
14use crate::phase::Phase;
15use crate::plan::ExecutionPlan;
16use crate::policy::PredictionLevel;
17use crate::runtime::ArtifactRef;
18use crate::selection::SelectionDecision;
19
20pub const EXECUTION_BUNDLE_SCHEMA_VERSION: u32 = 1;
21pub const PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION: u32 = 1;
22pub const BUNDLE_PREDICTION_CACHE_FORMAT: &str = "dag-ml-json-prediction-blocks-v1";
23
24pub const MIN_READABLE_EXECUTION_BUNDLE_SCHEMA_VERSION: u32 = 1;
25pub const MIN_WRITABLE_EXECUTION_BUNDLE_SCHEMA_VERSION: u32 = 1;
26pub const MIN_READABLE_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION: u32 = 1;
27pub const MIN_WRITABLE_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION: u32 = 1;
28
29fn default_execution_bundle_schema_version() -> u32 {
30 EXECUTION_BUNDLE_SCHEMA_VERSION
31}
32
33fn default_prediction_cache_payload_schema_version() -> u32 {
34 PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION
35}
36
37fn default_prediction_level() -> PredictionLevel {
38 PredictionLevel::Sample
39}
40
41#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
42pub struct SchemaMigrationPolicy {
43 pub artifact: String,
44 pub current_version: u32,
45 pub min_readable_version: u32,
46 pub min_writable_version: u32,
47 #[serde(default)]
48 pub automatic_migrations: BTreeMap<u32, u32>,
49}
50
51impl SchemaMigrationPolicy {
52 pub fn validate(&self) -> Result<()> {
53 validate_non_empty("schema migration artifact", &self.artifact)?;
54 if self.current_version == 0
55 || self.min_readable_version == 0
56 || self.min_writable_version == 0
57 {
58 return Err(DagMlError::RuntimeValidation(format!(
59 "schema migration policy `{}` has zero version boundary",
60 self.artifact
61 )));
62 }
63 if self.min_readable_version > self.current_version {
64 return Err(DagMlError::RuntimeValidation(format!(
65 "schema migration policy `{}` min_readable_version exceeds current_version",
66 self.artifact
67 )));
68 }
69 if self.min_writable_version > self.current_version {
70 return Err(DagMlError::RuntimeValidation(format!(
71 "schema migration policy `{}` min_writable_version exceeds current_version",
72 self.artifact
73 )));
74 }
75 for (from, to) in &self.automatic_migrations {
76 if *from == 0 || *to == 0 {
77 return Err(DagMlError::RuntimeValidation(format!(
78 "schema migration policy `{}` contains a zero migration version",
79 self.artifact
80 )));
81 }
82 if from == to {
83 return Err(DagMlError::RuntimeValidation(format!(
84 "schema migration policy `{}` contains a no-op migration {from}->{to}",
85 self.artifact
86 )));
87 }
88 if *to > self.current_version {
89 return Err(DagMlError::RuntimeValidation(format!(
90 "schema migration policy `{}` migrates to unsupported future version {to}",
91 self.artifact
92 )));
93 }
94 }
95 Ok(())
96 }
97
98 pub fn validate_read_version(&self, version: u32, owner: &str) -> Result<()> {
99 self.validate()?;
100 if version < self.min_readable_version {
101 return Err(DagMlError::RuntimeValidation(format!(
102 "{owner} uses schema_version {version}, below minimum readable {} for {}",
103 self.min_readable_version, self.artifact
104 )));
105 }
106 if version > self.current_version {
107 return Err(DagMlError::RuntimeValidation(format!(
108 "{owner} uses future schema_version {version}, current readable {} for {}",
109 self.current_version, self.artifact
110 )));
111 }
112 if version != self.current_version && !self.automatic_migrations.contains_key(&version) {
113 return Err(DagMlError::RuntimeValidation(format!(
114 "{owner} uses schema_version {version}, but {} declares no automatic migration to current version {}",
115 self.artifact, self.current_version
116 )));
117 }
118 Ok(())
119 }
120}
121
122pub fn execution_bundle_schema_migration_policy() -> SchemaMigrationPolicy {
123 SchemaMigrationPolicy {
124 artifact: "execution_bundle".to_string(),
125 current_version: EXECUTION_BUNDLE_SCHEMA_VERSION,
126 min_readable_version: MIN_READABLE_EXECUTION_BUNDLE_SCHEMA_VERSION,
127 min_writable_version: MIN_WRITABLE_EXECUTION_BUNDLE_SCHEMA_VERSION,
128 automatic_migrations: BTreeMap::new(),
129 }
130}
131
132pub fn prediction_cache_payload_schema_migration_policy() -> SchemaMigrationPolicy {
133 SchemaMigrationPolicy {
134 artifact: "prediction_cache_payload".to_string(),
135 current_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
136 min_readable_version: MIN_READABLE_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
137 min_writable_version: MIN_WRITABLE_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
138 automatic_migrations: BTreeMap::new(),
139 }
140}
141
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
143pub struct BundleDataRequirement {
144 pub node_id: NodeId,
145 pub input_name: String,
146 pub schema_fingerprint: String,
147 pub plan_fingerprint: String,
148 #[serde(default)]
149 pub relation_fingerprint: Option<String>,
150 pub output_representation: String,
151 #[serde(default)]
152 pub feature_set_id: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub representation_replay_manifest: Option<RepresentationReplayManifest>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub representation_compatibility: Option<RepresentationCompatibilityReport>,
157}
158
159impl BundleDataRequirement {
160 pub fn key(&self) -> String {
161 format!("{}.{}", self.node_id, self.input_name)
162 }
163
164 fn matches_plan_requirement(&self, expected: &Self) -> bool {
165 self.node_id == expected.node_id
166 && self.input_name == expected.input_name
167 && self.schema_fingerprint == expected.schema_fingerprint
168 && self.plan_fingerprint == expected.plan_fingerprint
169 && self.relation_fingerprint == expected.relation_fingerprint
170 && self.output_representation == expected.output_representation
171 && self.feature_set_id == expected.feature_set_id
172 }
173
174 pub fn validate(&self) -> Result<()> {
175 if self.input_name.trim().is_empty() {
176 return Err(DagMlError::CampaignValidation(format!(
177 "bundle data requirement for `{}` has empty input_name",
178 self.node_id
179 )));
180 }
181 validate_fingerprint("schema", &self.schema_fingerprint)?;
182 validate_fingerprint("plan", &self.plan_fingerprint)?;
183 if let Some(relation_fingerprint) = &self.relation_fingerprint {
184 validate_fingerprint("relation", relation_fingerprint)?;
185 }
186 if let Some(replay_manifest) = &self.representation_replay_manifest {
187 replay_manifest.validate()?;
188 if let (Some(requirement), Some(manifest)) = (
189 self.relation_fingerprint.as_deref(),
190 replay_manifest.relation_fingerprint.as_deref(),
191 ) {
192 if requirement != manifest {
193 return Err(DagMlError::CampaignValidation(format!(
194 "bundle data requirement `{}` relation_fingerprint does not match representation replay manifest",
195 self.key()
196 )));
197 }
198 }
199 }
200 if let Some(report) = &self.representation_compatibility {
201 report.validate()?;
202 }
203 if self.output_representation.trim().is_empty() {
204 return Err(DagMlError::CampaignValidation(format!(
205 "bundle data requirement `{}` has empty output representation",
206 self.key()
207 )));
208 }
209 if let Some(feature_set_id) = &self.feature_set_id {
210 if feature_set_id.trim().is_empty() {
211 return Err(DagMlError::CampaignValidation(format!(
212 "bundle data requirement `{}` has empty feature_set_id",
213 self.key()
214 )));
215 }
216 }
217 Ok(())
218 }
219}
220
221#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
222pub struct BundlePredictionRequirement {
223 pub producer_node: NodeId,
224 pub source_port: String,
225 pub consumer_node: NodeId,
226 pub target_port: String,
227 pub partition: PredictionPartition,
228 #[serde(default = "default_prediction_level")]
229 pub prediction_level: PredictionLevel,
230 #[serde(default)]
231 pub fold_ids: Vec<FoldId>,
232 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub unit_ids: Vec<PredictionUnitId>,
234 #[serde(default)]
235 pub sample_ids: Vec<SampleId>,
236 pub prediction_width: usize,
237 pub target_names: Vec<String>,
238}
239
240impl BundlePredictionRequirement {
241 pub fn key(&self) -> String {
242 bundle_prediction_requirement_key(
243 &self.producer_node,
244 &self.source_port,
245 &self.consumer_node,
246 &self.target_port,
247 )
248 }
249
250 pub fn validate(&self) -> Result<()> {
251 validate_non_empty("source_port", &self.source_port)?;
252 validate_non_empty("target_port", &self.target_port)?;
253 if self.partition != PredictionPartition::Validation {
254 return Err(DagMlError::RuntimeValidation(format!(
255 "bundle prediction requirement `{}` must use validation OOF predictions",
256 self.key()
257 )));
258 }
259 validate_unique_ids("fold id", &self.fold_ids)?;
260 validate_prediction_requirement_units(self)?;
261 if self.prediction_width == 0 {
262 return Err(DagMlError::RuntimeValidation(format!(
263 "bundle prediction requirement `{}` has zero prediction width",
264 self.key()
265 )));
266 }
267 if self.target_names.len() != self.prediction_width {
268 return Err(DagMlError::RuntimeValidation(format!(
269 "bundle prediction requirement `{}` target name count does not match prediction width",
270 self.key()
271 )));
272 }
273 for target_name in &self.target_names {
274 validate_non_empty("target_name", target_name)?;
275 }
276 Ok(())
277 }
278}
279
280pub fn bundle_prediction_requirement_key(
281 producer_node: &NodeId,
282 source_port: &str,
283 consumer_node: &NodeId,
284 target_port: &str,
285) -> String {
286 format!("{producer_node}.{source_port}->{consumer_node}.{target_port}")
287}
288
289#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
290pub struct BundlePredictionBlockCacheRecord {
291 #[serde(default)]
292 pub prediction_id: Option<String>,
293 #[serde(default)]
294 pub fold_id: Option<FoldId>,
295 #[serde(default = "default_prediction_level")]
296 pub prediction_level: PredictionLevel,
297 pub row_count: usize,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub unit_ids: Vec<PredictionUnitId>,
300 #[serde(default)]
301 pub sample_ids: Vec<SampleId>,
302 pub content_fingerprint: String,
303}
304
305impl BundlePredictionBlockCacheRecord {
306 pub fn validate(&self) -> Result<()> {
307 if let Some(prediction_id) = &self.prediction_id {
308 validate_non_empty("prediction_id", prediction_id)?;
309 }
310 if self.row_count == 0 {
311 return Err(DagMlError::RuntimeValidation(
312 "prediction block cache record has zero rows".to_string(),
313 ));
314 }
315 validate_prediction_cache_block_record_units(self)?;
316 validate_fingerprint("prediction block cache content", &self.content_fingerprint)
317 }
318}
319
320#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
321pub struct BundlePredictionCacheRecord {
322 pub requirement_key: String,
323 pub cache_id: String,
324 pub format: String,
325 pub partition: PredictionPartition,
326 #[serde(default = "default_prediction_level")]
327 pub prediction_level: PredictionLevel,
328 #[serde(default)]
329 pub fold_ids: Vec<FoldId>,
330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
331 pub unit_ids: Vec<PredictionUnitId>,
332 #[serde(default)]
333 pub sample_ids: Vec<SampleId>,
334 pub prediction_width: usize,
335 pub target_names: Vec<String>,
336 pub block_count: usize,
337 pub row_count: usize,
338 pub content_fingerprint: String,
339 #[serde(default)]
340 pub blocks: Vec<BundlePredictionBlockCacheRecord>,
341}
342
343impl BundlePredictionCacheRecord {
344 pub fn validate(&self) -> Result<()> {
345 validate_non_empty("requirement_key", &self.requirement_key)?;
346 validate_non_empty("cache_id", &self.cache_id)?;
347 validate_non_empty("format", &self.format)?;
348 if self.format != BUNDLE_PREDICTION_CACHE_FORMAT {
349 return Err(DagMlError::RuntimeValidation(format!(
350 "prediction cache `{}` uses unsupported format `{}`",
351 self.cache_id, self.format
352 )));
353 }
354 if self.partition != PredictionPartition::Validation {
355 return Err(DagMlError::RuntimeValidation(format!(
356 "prediction cache `{}` must cache validation OOF predictions",
357 self.cache_id
358 )));
359 }
360 validate_unique_ids("fold id", &self.fold_ids)?;
361 validate_prediction_cache_record_units(self)?;
362 if self.prediction_width == 0 {
363 return Err(DagMlError::RuntimeValidation(format!(
364 "prediction cache `{}` has zero prediction width",
365 self.cache_id
366 )));
367 }
368 if self.target_names.len() != self.prediction_width {
369 return Err(DagMlError::RuntimeValidation(format!(
370 "prediction cache `{}` target name count does not match prediction width",
371 self.cache_id
372 )));
373 }
374 for target_name in &self.target_names {
375 validate_non_empty("target_name", target_name)?;
376 }
377 if self.block_count == 0 || self.block_count != self.blocks.len() {
378 return Err(DagMlError::RuntimeValidation(format!(
379 "prediction cache `{}` block_count does not match block records",
380 self.cache_id
381 )));
382 }
383 validate_prediction_cache_record_blocks(self)?;
384 validate_fingerprint("prediction cache content", &self.content_fingerprint)?;
385 Ok(())
386 }
387}
388
389fn validate_prediction_requirement_units(requirement: &BundlePredictionRequirement) -> Result<()> {
390 match requirement.prediction_level {
391 PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
392 "bundle prediction requirement `{}` cannot replay observation-level caches; aggregate to sample first",
393 requirement.key()
394 ))),
395 PredictionLevel::Sample => {
396 validate_unique_ids("sample id", &requirement.sample_ids)?;
397 if requirement.sample_ids.is_empty() {
398 return Err(DagMlError::RuntimeValidation(format!(
399 "bundle prediction requirement `{}` has no sample ids",
400 requirement.key()
401 )));
402 }
403 if !requirement.unit_ids.is_empty()
404 && requirement.unit_ids != sample_prediction_units(&requirement.sample_ids)
405 {
406 return Err(DagMlError::RuntimeValidation(format!(
407 "bundle prediction requirement `{}` sample ids do not match unit ids",
408 requirement.key()
409 )));
410 }
411 Ok(())
412 }
413 PredictionLevel::Target | PredictionLevel::Group => {
414 if !requirement.sample_ids.is_empty() {
415 return Err(DagMlError::RuntimeValidation(format!(
416 "bundle prediction requirement `{}` uses {:?} unit ids but also carries sample ids",
417 requirement.key(),
418 requirement.prediction_level
419 )));
420 }
421 validate_prediction_units(
422 "bundle prediction requirement unit",
423 requirement.prediction_level,
424 &requirement.unit_ids,
425 )?;
426 if requirement.unit_ids.is_empty() {
427 return Err(DagMlError::RuntimeValidation(format!(
428 "bundle prediction requirement `{}` has no unit ids",
429 requirement.key()
430 )));
431 }
432 Ok(())
433 }
434 }
435}
436
437fn validate_prediction_cache_block_record_units(
438 block: &BundlePredictionBlockCacheRecord,
439) -> Result<()> {
440 match block.prediction_level {
441 PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(
442 "prediction block cache record cannot use observation-level predictions".to_string(),
443 )),
444 PredictionLevel::Sample => {
445 validate_unique_ids("sample id", &block.sample_ids)?;
446 if block.row_count != block.sample_ids.len() {
447 return Err(DagMlError::RuntimeValidation(format!(
448 "prediction block cache record row_count {} does not match {} sample ids",
449 block.row_count,
450 block.sample_ids.len()
451 )));
452 }
453 if !block.unit_ids.is_empty()
454 && block.unit_ids != sample_prediction_units(&block.sample_ids)
455 {
456 return Err(DagMlError::RuntimeValidation(
457 "prediction block cache record sample ids do not match unit ids".to_string(),
458 ));
459 }
460 Ok(())
461 }
462 PredictionLevel::Target | PredictionLevel::Group => {
463 if !block.sample_ids.is_empty() {
464 return Err(DagMlError::RuntimeValidation(format!(
465 "prediction block cache record uses {:?} unit ids but also carries sample ids",
466 block.prediction_level
467 )));
468 }
469 validate_prediction_units(
470 "prediction block cache record unit",
471 block.prediction_level,
472 &block.unit_ids,
473 )?;
474 if block.row_count != block.unit_ids.len() {
475 return Err(DagMlError::RuntimeValidation(format!(
476 "prediction block cache record row_count {} does not match {} unit ids",
477 block.row_count,
478 block.unit_ids.len()
479 )));
480 }
481 Ok(())
482 }
483 }
484}
485
486fn validate_prediction_cache_record_units(cache: &BundlePredictionCacheRecord) -> Result<()> {
487 match cache.prediction_level {
488 PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
489 "prediction cache `{}` cannot use observation-level predictions",
490 cache.cache_id
491 ))),
492 PredictionLevel::Sample => {
493 validate_unique_ids("sample id", &cache.sample_ids)?;
494 if cache.row_count != cache.sample_ids.len() {
495 return Err(DagMlError::RuntimeValidation(format!(
496 "prediction cache `{}` row_count does not match unique sample ids",
497 cache.cache_id
498 )));
499 }
500 if !cache.unit_ids.is_empty()
501 && cache.unit_ids != sample_prediction_units(&cache.sample_ids)
502 {
503 return Err(DagMlError::RuntimeValidation(format!(
504 "prediction cache `{}` sample ids do not match unit ids",
505 cache.cache_id
506 )));
507 }
508 Ok(())
509 }
510 PredictionLevel::Target | PredictionLevel::Group => {
511 if !cache.sample_ids.is_empty() {
512 return Err(DagMlError::RuntimeValidation(format!(
513 "prediction cache `{}` uses {:?} unit ids but also carries sample ids",
514 cache.cache_id, cache.prediction_level
515 )));
516 }
517 validate_prediction_units(
518 "prediction cache unit",
519 cache.prediction_level,
520 &cache.unit_ids,
521 )?;
522 if cache.row_count != cache.unit_ids.len() {
523 return Err(DagMlError::RuntimeValidation(format!(
524 "prediction cache `{}` row_count does not match unique unit ids",
525 cache.cache_id
526 )));
527 }
528 Ok(())
529 }
530 }
531}
532
533fn validate_prediction_cache_record_blocks(cache: &BundlePredictionCacheRecord) -> Result<()> {
534 let mut row_count = 0usize;
535 let mut samples = BTreeSet::new();
536 let mut units = BTreeSet::new();
537 for block in &cache.blocks {
538 block.validate()?;
539 if block.prediction_level != cache.prediction_level {
540 return Err(DagMlError::RuntimeValidation(format!(
541 "prediction cache `{}` mixes block prediction levels",
542 cache.cache_id
543 )));
544 }
545 row_count += block.row_count;
546 match cache.prediction_level {
547 PredictionLevel::Sample => {
548 for sample_id in &block.sample_ids {
549 if !samples.insert(sample_id.clone()) {
550 return Err(DagMlError::RuntimeValidation(format!(
551 "prediction cache `{}` contains duplicate sample `{sample_id}`",
552 cache.cache_id
553 )));
554 }
555 }
556 }
557 PredictionLevel::Target | PredictionLevel::Group => {
558 for unit_id in &block.unit_ids {
559 if !units.insert(unit_id.clone()) {
560 return Err(DagMlError::RuntimeValidation(format!(
561 "prediction cache `{}` contains duplicate unit `{unit_id}`",
562 cache.cache_id
563 )));
564 }
565 }
566 }
567 PredictionLevel::Observation => {
568 unreachable!("record unit validation rejects observation")
569 }
570 }
571 }
572 if cache.row_count == 0 || cache.row_count != row_count {
573 return Err(DagMlError::RuntimeValidation(format!(
574 "prediction cache `{}` row_count does not match block records",
575 cache.cache_id
576 )));
577 }
578 if cache.prediction_level == PredictionLevel::Sample {
579 let expected = cache.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
580 if samples != expected {
581 return Err(DagMlError::RuntimeValidation(format!(
582 "prediction cache `{}` block samples do not match cache sample ids",
583 cache.cache_id
584 )));
585 }
586 } else {
587 let expected = cache.unit_ids.iter().cloned().collect::<BTreeSet<_>>();
588 if units != expected {
589 return Err(DagMlError::RuntimeValidation(format!(
590 "prediction cache `{}` block units do not match cache unit ids",
591 cache.cache_id
592 )));
593 }
594 }
595 Ok(())
596}
597
598fn validate_prediction_cache_payload_blocks(
599 payload: &BundlePredictionCachePayload,
600) -> Result<usize> {
601 match payload.prediction_level {
602 PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
603 "prediction cache payload `{}` cannot use observation-level predictions",
604 payload.cache_id
605 ))),
606 PredictionLevel::Sample => validate_sample_prediction_cache_payload_blocks(payload),
607 PredictionLevel::Target | PredictionLevel::Group => {
608 validate_aggregated_prediction_cache_payload_blocks(payload)
609 }
610 }
611}
612
613fn validate_sample_prediction_cache_payload_blocks(
614 payload: &BundlePredictionCachePayload,
615) -> Result<usize> {
616 let mut row_count = 0usize;
617 let mut sample_ids = BTreeSet::new();
618 for block in &payload.blocks {
619 block.validate_shape()?;
620 if block.partition != payload.partition {
621 return Err(DagMlError::RuntimeValidation(format!(
622 "prediction cache payload `{}` contains a block from partition {:?}",
623 payload.cache_id, block.partition
624 )));
625 }
626 for sample_id in &block.sample_ids {
627 if !sample_ids.insert(sample_id) {
628 return Err(DagMlError::RuntimeValidation(format!(
629 "prediction cache payload `{}` contains duplicate sample `{}`",
630 payload.cache_id, sample_id
631 )));
632 }
633 }
634 row_count += block.sample_ids.len();
635 }
636 Ok(row_count)
637}
638
639fn validate_aggregated_prediction_cache_payload_blocks(
640 payload: &BundlePredictionCachePayload,
641) -> Result<usize> {
642 let mut row_count = 0usize;
643 let mut unit_ids = BTreeSet::new();
644 for block in &payload.aggregated_blocks {
645 block.validate_shape()?;
646 if block.partition != payload.partition {
647 return Err(DagMlError::RuntimeValidation(format!(
648 "prediction cache payload `{}` contains an aggregated block from partition {:?}",
649 payload.cache_id, block.partition
650 )));
651 }
652 if block.level != payload.prediction_level {
653 return Err(DagMlError::RuntimeValidation(format!(
654 "prediction cache payload `{}` contains {:?} block inside {:?} payload",
655 payload.cache_id, block.level, payload.prediction_level
656 )));
657 }
658 for unit_id in &block.unit_ids {
659 if !unit_ids.insert(unit_id) {
660 return Err(DagMlError::RuntimeValidation(format!(
661 "prediction cache payload `{}` contains duplicate unit `{unit_id}`",
662 payload.cache_id
663 )));
664 }
665 }
666 row_count += block.unit_ids.len();
667 }
668 Ok(row_count)
669}
670
671#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
672pub struct BundlePredictionCachePayload {
673 pub requirement_key: String,
674 pub cache_id: String,
675 pub format: String,
676 pub partition: PredictionPartition,
677 #[serde(default = "default_prediction_level")]
678 pub prediction_level: PredictionLevel,
679 pub block_count: usize,
680 pub row_count: usize,
681 pub content_fingerprint: String,
682 #[serde(default)]
683 pub blocks: Vec<PredictionBlock>,
684 #[serde(default, skip_serializing_if = "Vec::is_empty")]
685 pub aggregated_blocks: Vec<AggregatedPredictionBlock>,
686}
687
688impl BundlePredictionCachePayload {
689 pub fn validate(&self) -> Result<()> {
690 validate_non_empty("requirement_key", &self.requirement_key)?;
691 validate_non_empty("cache_id", &self.cache_id)?;
692 validate_non_empty("format", &self.format)?;
693 if self.format != BUNDLE_PREDICTION_CACHE_FORMAT {
694 return Err(DagMlError::RuntimeValidation(format!(
695 "prediction cache payload `{}` uses unsupported format `{}`",
696 self.cache_id, self.format
697 )));
698 }
699 if self.partition != PredictionPartition::Validation {
700 return Err(DagMlError::RuntimeValidation(format!(
701 "prediction cache payload `{}` must cache validation OOF predictions",
702 self.cache_id
703 )));
704 }
705 let expected_block_count = if self.prediction_level == PredictionLevel::Sample {
706 if !self.aggregated_blocks.is_empty() {
707 return Err(DagMlError::RuntimeValidation(format!(
708 "prediction cache payload `{}` mixes sample and aggregated blocks",
709 self.cache_id
710 )));
711 }
712 self.blocks.len()
713 } else {
714 if !self.blocks.is_empty() {
715 return Err(DagMlError::RuntimeValidation(format!(
716 "prediction cache payload `{}` mixes aggregated and sample blocks",
717 self.cache_id
718 )));
719 }
720 self.aggregated_blocks.len()
721 };
722 if self.block_count == 0 || self.block_count != expected_block_count {
723 return Err(DagMlError::RuntimeValidation(format!(
724 "prediction cache payload `{}` block_count does not match blocks",
725 self.cache_id
726 )));
727 }
728 let row_count = validate_prediction_cache_payload_blocks(self)?;
729 if self.row_count == 0 || self.row_count != row_count {
730 return Err(DagMlError::RuntimeValidation(format!(
731 "prediction cache payload `{}` row_count does not match blocks",
732 self.cache_id
733 )));
734 }
735 validate_fingerprint(
736 "prediction cache payload content",
737 &self.content_fingerprint,
738 )?;
739 let actual_fingerprint = if self.prediction_level == PredictionLevel::Sample {
740 stable_json_fingerprint(&self.blocks)?
741 } else {
742 stable_json_fingerprint(&self.aggregated_blocks)?
743 };
744 if actual_fingerprint != self.content_fingerprint {
745 return Err(DagMlError::RuntimeValidation(format!(
746 "prediction cache payload `{}` content fingerprint does not match blocks",
747 self.cache_id
748 )));
749 }
750 Ok(())
751 }
752}
753
754#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
755pub struct BundlePredictionCachePayloadSet {
756 pub bundle_id: BundleId,
757 #[serde(default = "default_prediction_cache_payload_schema_version")]
758 pub schema_version: u32,
759 #[serde(default)]
760 pub caches: Vec<BundlePredictionCachePayload>,
761}
762
763impl BundlePredictionCachePayloadSet {
764 pub fn validate(&self) -> Result<()> {
765 prediction_cache_payload_schema_migration_policy().validate_read_version(
766 self.schema_version,
767 &format!(
768 "prediction cache payload set for bundle `{}`",
769 self.bundle_id
770 ),
771 )?;
772 let mut requirement_keys = BTreeSet::new();
773 let mut cache_ids = BTreeSet::new();
774 for payload in &self.caches {
775 payload.validate()?;
776 if !requirement_keys.insert(payload.requirement_key.as_str()) {
777 return Err(DagMlError::RuntimeValidation(format!(
778 "prediction cache payload set for bundle `{}` has duplicate requirement `{}`",
779 self.bundle_id, payload.requirement_key
780 )));
781 }
782 if !cache_ids.insert(payload.cache_id.as_str()) {
783 return Err(DagMlError::RuntimeValidation(format!(
784 "prediction cache payload set for bundle `{}` has duplicate cache id `{}`",
785 self.bundle_id, payload.cache_id
786 )));
787 }
788 }
789 Ok(())
790 }
791
792 pub fn validate_against_bundle(&self, bundle: &ExecutionBundle) -> Result<()> {
793 self.validate()?;
794 bundle.validate()?;
795 if self.bundle_id != bundle.bundle_id {
796 return Err(DagMlError::RuntimeValidation(format!(
797 "prediction cache payload set bundle `{}` does not match bundle `{}`",
798 self.bundle_id, bundle.bundle_id
799 )));
800 }
801 if self.caches.len() != bundle.prediction_caches.len() {
802 return Err(DagMlError::RuntimeValidation(format!(
803 "prediction cache payload set for bundle `{}` has {} payload(s) for {} cache record(s)",
804 self.bundle_id,
805 self.caches.len(),
806 bundle.prediction_caches.len()
807 )));
808 }
809 let records_by_requirement = bundle
810 .prediction_caches
811 .iter()
812 .map(|record| (record.requirement_key.as_str(), record))
813 .collect::<BTreeMap<_, _>>();
814 let payloads_by_requirement = self
815 .caches
816 .iter()
817 .map(|payload| (payload.requirement_key.as_str(), payload))
818 .collect::<BTreeMap<_, _>>();
819 for (requirement_key, record) in records_by_requirement {
820 let payload = payloads_by_requirement
821 .get(requirement_key)
822 .ok_or_else(|| {
823 DagMlError::RuntimeValidation(format!(
824 "prediction cache payload set for bundle `{}` is missing requirement `{}`",
825 self.bundle_id, requirement_key
826 ))
827 })?;
828 validate_prediction_cache_payload_matches_record(payload, record)?;
829 }
830 for requirement_key in payloads_by_requirement.keys() {
831 if !bundle
832 .prediction_caches
833 .iter()
834 .any(|record| record.requirement_key.as_str() == *requirement_key)
835 {
836 return Err(DagMlError::RuntimeValidation(format!(
837 "prediction cache payload set for bundle `{}` contains unknown requirement `{}`",
838 self.bundle_id, requirement_key
839 )));
840 }
841 }
842 Ok(())
843 }
844}
845
846#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
847pub struct RefitArtifactRecord {
848 pub node_id: NodeId,
849 pub controller_id: ControllerId,
850 pub artifact: ArtifactRef,
851 pub params_fingerprint: String,
852 #[serde(default)]
853 pub data_requirement_keys: Vec<String>,
854 #[serde(default)]
855 pub prediction_requirement_keys: Vec<String>,
856}
857
858impl RefitArtifactRecord {
859 pub fn validate(&self) -> Result<()> {
860 self.artifact.validate()?;
861 if self.artifact.id.as_str().is_empty() {
862 return Err(DagMlError::RuntimeValidation(format!(
863 "refit artifact for `{}` has empty artifact id",
864 self.node_id
865 )));
866 }
867 if self.artifact.kind.trim().is_empty() {
868 return Err(DagMlError::RuntimeValidation(format!(
869 "refit artifact `{}` has empty artifact kind",
870 self.artifact.id
871 )));
872 }
873 if self.artifact.controller_id != self.controller_id {
874 return Err(DagMlError::RuntimeValidation(format!(
875 "refit artifact `{}` controller `{}` does not match record controller `{}`",
876 self.artifact.id, self.artifact.controller_id, self.controller_id
877 )));
878 }
879 validate_fingerprint("params", &self.params_fingerprint)?;
880 let mut seen_keys = BTreeSet::new();
881 for key in &self.data_requirement_keys {
882 if key.trim().is_empty() {
883 return Err(DagMlError::RuntimeValidation(format!(
884 "refit artifact `{}` has empty data requirement key",
885 self.artifact.id
886 )));
887 }
888 if !seen_keys.insert(key.as_str()) {
889 return Err(DagMlError::RuntimeValidation(format!(
890 "refit artifact `{}` has duplicate data requirement key `{key}`",
891 self.artifact.id
892 )));
893 }
894 }
895 let mut seen_prediction_keys = BTreeSet::new();
896 for key in &self.prediction_requirement_keys {
897 if key.trim().is_empty() {
898 return Err(DagMlError::RuntimeValidation(format!(
899 "refit artifact `{}` has empty prediction requirement key",
900 self.artifact.id
901 )));
902 }
903 if !seen_prediction_keys.insert(key.as_str()) {
904 return Err(DagMlError::RuntimeValidation(format!(
905 "refit artifact `{}` has duplicate prediction requirement key `{key}`",
906 self.artifact.id
907 )));
908 }
909 }
910 Ok(())
911 }
912}
913
914#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
915pub struct ExecutionBundle {
916 pub bundle_id: BundleId,
917 #[serde(default = "default_execution_bundle_schema_version")]
918 pub schema_version: u32,
919 pub plan_id: String,
920 pub graph_fingerprint: String,
921 pub campaign_fingerprint: String,
922 pub controller_fingerprint: String,
923 #[serde(default)]
924 pub selected_variant_id: Option<VariantId>,
925 #[serde(default)]
926 pub selections: BTreeMap<String, SelectionDecision>,
927 #[serde(default)]
928 pub refit_artifacts: Vec<RefitArtifactRecord>,
929 #[serde(default)]
930 pub prediction_requirements: Vec<BundlePredictionRequirement>,
931 #[serde(default)]
932 pub prediction_caches: Vec<BundlePredictionCacheRecord>,
933 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub scores: Option<ScoreSet>,
938 #[serde(default)]
939 pub data_requirements: Vec<BundleDataRequirement>,
940 #[serde(default)]
941 pub unsafe_flags: BTreeSet<String>,
942 #[serde(default)]
943 pub metadata: BTreeMap<String, serde_json::Value>,
944}
945
946impl ExecutionBundle {
947 pub fn validate(&self) -> Result<()> {
948 execution_bundle_schema_migration_policy()
949 .validate_read_version(self.schema_version, &format!("bundle `{}`", self.bundle_id))?;
950 if self.plan_id.trim().is_empty() {
951 return Err(DagMlError::RuntimeValidation(format!(
952 "bundle `{}` has empty plan_id",
953 self.bundle_id
954 )));
955 }
956 validate_fingerprint("graph", &self.graph_fingerprint)?;
957 validate_fingerprint("campaign", &self.campaign_fingerprint)?;
958 validate_fingerprint("controller", &self.controller_fingerprint)?;
959 if let Some(scores) = &self.scores {
960 scores.validate()?;
961 if scores.plan_id != self.plan_id {
962 return Err(DagMlError::RuntimeValidation(format!(
963 "bundle `{}` plan_id `{}` does not match its embedded scores plan_id `{}`",
964 self.bundle_id, self.plan_id, scores.plan_id
965 )));
966 }
967 }
968 for (key, decision) in &self.selections {
969 if key.trim().is_empty() {
970 return Err(DagMlError::RuntimeValidation(format!(
971 "bundle `{}` contains empty selection key",
972 self.bundle_id
973 )));
974 }
975 decision.validate()?;
976 }
977 let mut data_keys = BTreeMap::new();
978 for requirement in &self.data_requirements {
979 requirement.validate()?;
980 let key = requirement.key();
981 if data_keys.insert(key.clone(), requirement).is_some() {
982 return Err(DagMlError::RuntimeValidation(format!(
983 "bundle `{}` has duplicate data requirement `{}`",
984 self.bundle_id, key
985 )));
986 }
987 }
988 let mut prediction_keys = BTreeMap::new();
989 for requirement in &self.prediction_requirements {
990 requirement.validate()?;
991 let key = requirement.key();
992 if prediction_keys.insert(key.clone(), requirement).is_some() {
993 return Err(DagMlError::RuntimeValidation(format!(
994 "bundle `{}` has duplicate prediction requirement `{}`",
995 self.bundle_id, key
996 )));
997 }
998 }
999 let mut prediction_cache_keys = BTreeMap::new();
1000 for cache in &self.prediction_caches {
1001 cache.validate()?;
1002 let requirement = prediction_keys.get(&cache.requirement_key).ok_or_else(|| {
1003 DagMlError::RuntimeValidation(format!(
1004 "prediction cache `{}` references unknown prediction requirement `{}`",
1005 cache.cache_id, cache.requirement_key
1006 ))
1007 })?;
1008 validate_prediction_cache_matches_requirement(cache, requirement)?;
1009 if prediction_cache_keys
1010 .insert(cache.requirement_key.clone(), cache)
1011 .is_some()
1012 {
1013 return Err(DagMlError::RuntimeValidation(format!(
1014 "bundle `{}` has duplicate prediction cache for requirement `{}`",
1015 self.bundle_id, cache.requirement_key
1016 )));
1017 }
1018 }
1019 for artifact in &self.refit_artifacts {
1020 artifact.validate()?;
1021 for key in &artifact.data_requirement_keys {
1022 match data_keys.get(key) {
1023 Some(requirement) if requirement.node_id == artifact.node_id => {}
1024 Some(requirement) => {
1025 return Err(DagMlError::RuntimeValidation(format!(
1026 "refit artifact `{}` for `{}` references data requirement `{key}` owned by `{}`",
1027 artifact.artifact.id, artifact.node_id, requirement.node_id
1028 )));
1029 }
1030 None => {
1031 return Err(DagMlError::RuntimeValidation(format!(
1032 "refit artifact `{}` references unknown data requirement `{key}`",
1033 artifact.artifact.id
1034 )));
1035 }
1036 }
1037 }
1038 for key in &artifact.prediction_requirement_keys {
1039 match prediction_keys.get(key) {
1040 Some(requirement) if requirement.consumer_node == artifact.node_id => {}
1041 Some(requirement) => {
1042 return Err(DagMlError::RuntimeValidation(format!(
1043 "refit artifact `{}` for `{}` references prediction requirement `{key}` consumed by `{}`",
1044 artifact.artifact.id, artifact.node_id, requirement.consumer_node
1045 )));
1046 }
1047 None => {
1048 return Err(DagMlError::RuntimeValidation(format!(
1049 "refit artifact `{}` references unknown prediction requirement `{key}`",
1050 artifact.artifact.id
1051 )));
1052 }
1053 }
1054 if !prediction_cache_keys.contains_key(key) {
1055 return Err(DagMlError::RuntimeValidation(format!(
1056 "refit artifact `{}` references prediction requirement `{key}` without a prediction cache record",
1057 artifact.artifact.id
1058 )));
1059 }
1060 }
1061 }
1062 for unsafe_flag in &self.unsafe_flags {
1063 if unsafe_flag.trim().is_empty() {
1064 return Err(DagMlError::RuntimeValidation(format!(
1065 "bundle `{}` contains an empty unsafe flag",
1066 self.bundle_id
1067 )));
1068 }
1069 }
1070 Ok(())
1071 }
1072
1073 pub fn validate_against_plan(&self, plan: &ExecutionPlan) -> Result<()> {
1074 self.validate()?;
1075 plan.validate()?;
1076 if self.plan_id != plan.id {
1077 return Err(DagMlError::RuntimeValidation(format!(
1078 "bundle `{}` plan_id `{}` does not match plan `{}`",
1079 self.bundle_id, self.plan_id, plan.id
1080 )));
1081 }
1082 if self.graph_fingerprint != plan.graph_fingerprint
1083 || self.campaign_fingerprint != plan.campaign_fingerprint
1084 || self.controller_fingerprint != plan.controller_fingerprint
1085 {
1086 return Err(DagMlError::RuntimeValidation(format!(
1087 "bundle `{}` fingerprints do not match execution plan",
1088 self.bundle_id
1089 )));
1090 }
1091 let selected_variant = match &self.selected_variant_id {
1092 Some(selected_variant_id) => Some(
1093 plan.variants
1094 .iter()
1095 .find(|variant| &variant.variant_id == selected_variant_id)
1096 .ok_or_else(|| {
1097 DagMlError::RuntimeValidation(format!(
1098 "bundle `{}` selected unknown variant `{selected_variant_id}`",
1099 self.bundle_id
1100 ))
1101 })?,
1102 ),
1103 None => None,
1104 };
1105 self.validate_selections_against_plan(plan)?;
1106 let expected_requirements = collect_data_requirements(plan)?;
1107 let expected_by_key = expected_requirements
1108 .iter()
1109 .map(|requirement| (requirement.key(), requirement))
1110 .collect::<BTreeMap<_, _>>();
1111 if self.data_requirements.len() != expected_by_key.len() {
1112 return Err(DagMlError::RuntimeValidation(format!(
1113 "bundle `{}` data requirement count does not match execution plan",
1114 self.bundle_id
1115 )));
1116 }
1117 for requirement in &self.data_requirements {
1118 let key = requirement.key();
1119 let expected = expected_by_key.get(&key).ok_or_else(|| {
1120 DagMlError::RuntimeValidation(format!(
1121 "bundle `{}` data requirement `{key}` does not exist in execution plan",
1122 self.bundle_id
1123 ))
1124 })?;
1125 if !requirement.matches_plan_requirement(expected) {
1126 return Err(DagMlError::RuntimeValidation(format!(
1127 "bundle `{}` data requirement `{key}` does not match execution plan",
1128 self.bundle_id
1129 )));
1130 }
1131 }
1132 for artifact in &self.refit_artifacts {
1133 let node_plan = plan.node_plans.get(&artifact.node_id).ok_or_else(|| {
1134 DagMlError::RuntimeValidation(format!(
1135 "bundle `{}` artifact references unknown node `{}`",
1136 self.bundle_id, artifact.node_id
1137 ))
1138 })?;
1139 if artifact.controller_id != node_plan.controller_id {
1140 return Err(DagMlError::RuntimeValidation(format!(
1141 "bundle `{}` artifact controller for `{}` does not match plan",
1142 self.bundle_id, artifact.node_id
1143 )));
1144 }
1145 let expected_params_fingerprint =
1146 expected_refit_artifact_params_fingerprint(node_plan, selected_variant)?;
1147 if artifact.params_fingerprint != expected_params_fingerprint {
1148 return Err(DagMlError::RuntimeValidation(format!(
1149 "bundle `{}` artifact params for `{}` do not match plan",
1150 self.bundle_id, artifact.node_id
1151 )));
1152 }
1153 }
1154 for requirement in &self.prediction_requirements {
1155 let edge = plan
1156 .graph_plan
1157 .graph
1158 .edges
1159 .iter()
1160 .find(|edge| {
1161 edge.source.node_id == requirement.producer_node
1162 && edge.source.port_name == requirement.source_port
1163 && edge.target.node_id == requirement.consumer_node
1164 && edge.target.port_name == requirement.target_port
1165 && edge.contract.requires_oof
1166 })
1167 .ok_or_else(|| {
1168 DagMlError::RuntimeValidation(format!(
1169 "bundle `{}` prediction requirement `{}` does not match an OOF edge in the plan",
1170 self.bundle_id,
1171 requirement.key()
1172 ))
1173 })?;
1174 let cache = self
1175 .prediction_caches
1176 .iter()
1177 .find(|cache| cache.requirement_key == requirement.key());
1178 validate_prediction_requirement_against_plan(self, plan, edge, requirement, cache)?;
1179 }
1180 let cache_by_key = self
1187 .prediction_caches
1188 .iter()
1189 .map(|cache| (cache.requirement_key.clone(), cache))
1190 .collect::<BTreeMap<_, _>>();
1191 let mut concat_merge_groups: BTreeMap<NodeId, Vec<&BundlePredictionRequirement>> =
1192 BTreeMap::new();
1193 for requirement in &self.prediction_requirements {
1194 if is_concat_merge_consumer(plan, &requirement.consumer_node) {
1195 concat_merge_groups
1196 .entry(requirement.consumer_node.clone())
1197 .or_default()
1198 .push(requirement);
1199 }
1200 }
1201 for (consumer_node, requirements) in &concat_merge_groups {
1202 validate_concat_merge_requirement_group(
1203 self,
1204 plan,
1205 consumer_node,
1206 requirements,
1207 &cache_by_key,
1208 )?;
1209 }
1210 Ok(())
1211 }
1212
1213 fn validate_selections_against_plan(&self, plan: &ExecutionPlan) -> Result<()> {
1214 if self.selections.is_empty() {
1215 return Ok(());
1216 }
1217 let artifact_node_ids = self
1218 .refit_artifacts
1219 .iter()
1220 .map(|artifact| artifact.node_id.clone())
1221 .collect::<BTreeSet<_>>();
1222 let required_metric_level = plan.campaign.aggregation_policy.selection_metric_level;
1223 for (selection_key, decision) in &self.selections {
1224 match decision.metric_level {
1225 Some(metric_level) if metric_level == required_metric_level => {}
1226 Some(metric_level) => {
1227 return Err(DagMlError::RuntimeValidation(format!(
1228 "bundle `{}` selection `{selection_key}` metric_level {:?} does not match campaign selection_metric_level {:?}",
1229 self.bundle_id, metric_level, required_metric_level
1230 )));
1231 }
1232 None => {
1233 return Err(DagMlError::RuntimeValidation(format!(
1234 "bundle `{}` selection `{selection_key}` is missing metric_level for campaign selection_metric_level {:?}",
1235 self.bundle_id, required_metric_level
1236 )));
1237 }
1238 }
1239 let selected_candidate_id = decision.selected_candidate_id.as_str();
1240 if let Ok(selected_node_id) = NodeId::new(selected_candidate_id) {
1241 if let Some(node_plan) = plan.node_plans.get(&selected_node_id) {
1242 if node_plan.supported_phases.contains(&Phase::Refit)
1243 && !artifact_node_ids.contains(&node_plan.node_id)
1244 {
1245 return Err(DagMlError::RuntimeValidation(format!(
1246 "bundle `{}` selection `{selection_key}` chose refittable node `{}` without a matching refit artifact",
1247 self.bundle_id, node_plan.node_id
1248 )));
1249 }
1250 continue;
1251 }
1252 }
1253 if VariantId::new(selected_candidate_id).is_ok()
1254 && plan
1255 .variants
1256 .iter()
1257 .any(|variant| variant.variant_id.as_str() == selected_candidate_id)
1258 {
1259 continue;
1260 }
1261 return Err(DagMlError::RuntimeValidation(format!(
1262 "bundle `{}` selection `{selection_key}` chose unknown candidate `{selected_candidate_id}` for plan `{}`",
1263 self.bundle_id, plan.id
1264 )));
1265 }
1266 Ok(())
1267 }
1268
1269 pub fn validate_replay_envelopes(
1270 &self,
1271 envelopes: &BTreeMap<String, ExternalDataPlanEnvelope>,
1272 ) -> Result<()> {
1273 self.validate()?;
1274 for requirement in &self.data_requirements {
1275 let key = requirement.key();
1276 let envelope = envelopes.get(&key).ok_or_else(|| {
1277 DagMlError::RuntimeValidation(format!(
1278 "replay is missing external data envelope for `{key}`"
1279 ))
1280 })?;
1281 envelope.validate()?;
1282 if requirement.schema_fingerprint != envelope.schema_fingerprint
1283 || requirement.plan_fingerprint != envelope.plan_fingerprint
1284 || requirement.relation_fingerprint != envelope.relation_fingerprint
1285 {
1286 return Err(DagMlError::RuntimeValidation(format!(
1287 "replay envelope for `{key}` does not match bundle data requirement"
1288 )));
1289 }
1290 }
1291 Ok(())
1292 }
1293}
1294
1295fn expected_refit_artifact_params_fingerprint(
1296 node_plan: &crate::plan::NodePlan,
1297 selected_variant: Option<&crate::generation::VariantPlan>,
1298) -> Result<String> {
1299 let Some(variant) = selected_variant else {
1300 return Ok(node_plan.params_fingerprint.clone());
1301 };
1302 let effective_params =
1303 variant.effective_params_for_node(&node_plan.node_id, &node_plan.params)?;
1304 stable_json_fingerprint(&effective_params)
1305}
1306
1307fn is_concat_merge_consumer(plan: &ExecutionPlan, consumer_node: &NodeId) -> bool {
1316 let Some(node_plan) = plan.node_plans.get(consumer_node) else {
1317 return false;
1318 };
1319 if node_plan.kind != crate::graph::NodeKind::PredictionJoin {
1320 return false;
1321 }
1322 plan.graph_plan
1323 .graph
1324 .nodes
1325 .iter()
1326 .find(|node| &node.id == consumer_node)
1327 .and_then(|node| node.metadata.get("merge_mode"))
1328 .and_then(serde_json::Value::as_str)
1329 == Some("concat")
1330}
1331
1332fn validate_prediction_requirement_against_plan(
1333 bundle: &ExecutionBundle,
1334 plan: &ExecutionPlan,
1335 edge: &crate::graph::EdgeSpec,
1336 requirement: &BundlePredictionRequirement,
1337 cache: Option<&BundlePredictionCacheRecord>,
1338) -> Result<()> {
1339 if !edge.contract.requires_fold_alignment {
1340 return Ok(());
1341 }
1342 if is_concat_merge_consumer(plan, &requirement.consumer_node) {
1351 return validate_concat_merge_branch_input_requirement(bundle, plan, requirement, cache);
1352 }
1353 let fold_set = plan.fold_set.as_ref().ok_or_else(|| {
1354 DagMlError::RuntimeValidation(format!(
1355 "bundle `{}` prediction requirement `{}` needs fold alignment but plan `{}` has no fold set",
1356 bundle.bundle_id,
1357 requirement.key(),
1358 plan.id
1359 ))
1360 })?;
1361 let expected_fold_ids = fold_set
1362 .folds
1363 .iter()
1364 .map(|fold| fold.fold_id.clone())
1365 .collect::<BTreeSet<_>>();
1366 let requirement_fold_ids = requirement
1367 .fold_ids
1368 .iter()
1369 .cloned()
1370 .collect::<BTreeSet<_>>();
1371 if requirement_fold_ids != expected_fold_ids {
1372 return Err(DagMlError::RuntimeValidation(format!(
1373 "bundle `{}` prediction requirement `{}` fold ids do not match plan fold set",
1374 bundle.bundle_id,
1375 requirement.key()
1376 )));
1377 }
1378 if requirement.prediction_level != PredictionLevel::Sample {
1379 if let Some(cache) = cache {
1380 validate_aggregated_prediction_cache_blocks_match_requirement(
1381 bundle,
1382 requirement,
1383 cache,
1384 fold_set.partition_mode,
1385 )?;
1386 }
1387 return Ok(());
1388 }
1389 let expected_sample_ids = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1390 let requirement_sample_ids = requirement
1391 .sample_ids
1392 .iter()
1393 .cloned()
1394 .collect::<BTreeSet<_>>();
1395 if requirement_sample_ids != expected_sample_ids {
1396 return Err(DagMlError::RuntimeValidation(format!(
1397 "bundle `{}` prediction requirement `{}` sample ids do not match plan fold set",
1398 bundle.bundle_id,
1399 requirement.key()
1400 )));
1401 }
1402 if let Some(cache) = cache {
1403 validate_prediction_cache_blocks_match_fold_set(bundle, requirement, cache, fold_set)?;
1404 }
1405 Ok(())
1406}
1407
1408fn validate_concat_merge_branch_input_requirement(
1420 bundle: &ExecutionBundle,
1421 plan: &ExecutionPlan,
1422 requirement: &BundlePredictionRequirement,
1423 cache: Option<&BundlePredictionCacheRecord>,
1424) -> Result<()> {
1425 let fold_set = plan.fold_set.as_ref().ok_or_else(|| {
1426 DagMlError::RuntimeValidation(format!(
1427 "bundle `{}` prediction requirement `{}` needs fold alignment but plan `{}` has no fold set",
1428 bundle.bundle_id,
1429 requirement.key(),
1430 plan.id
1431 ))
1432 })?;
1433 let universe_fold_ids = fold_set
1434 .folds
1435 .iter()
1436 .map(|fold| fold.fold_id.clone())
1437 .collect::<BTreeSet<_>>();
1438 let requirement_fold_ids = requirement
1439 .fold_ids
1440 .iter()
1441 .cloned()
1442 .collect::<BTreeSet<_>>();
1443 if !requirement_fold_ids.is_subset(&universe_fold_ids) {
1444 return Err(DagMlError::RuntimeValidation(format!(
1445 "bundle `{}` concat-merge prediction requirement `{}` has fold ids outside the plan fold set",
1446 bundle.bundle_id,
1447 requirement.key()
1448 )));
1449 }
1450 if requirement.prediction_level != PredictionLevel::Sample {
1453 return Err(DagMlError::RuntimeValidation(format!(
1454 "bundle `{}` concat-merge prediction requirement `{}` must be sample-level (got {:?})",
1455 bundle.bundle_id,
1456 requirement.key(),
1457 requirement.prediction_level
1458 )));
1459 }
1460 let universe_sample_ids = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1461 let requirement_sample_ids = requirement
1462 .sample_ids
1463 .iter()
1464 .cloned()
1465 .collect::<BTreeSet<_>>();
1466 if !requirement_sample_ids.is_subset(&universe_sample_ids) {
1467 return Err(DagMlError::RuntimeValidation(format!(
1468 "bundle `{}` concat-merge prediction requirement `{}` covers samples outside the plan fold set",
1469 bundle.bundle_id,
1470 requirement.key()
1471 )));
1472 }
1473 if let Some(cache) = cache {
1474 let folds = fold_set
1475 .folds
1476 .iter()
1477 .map(|fold| (&fold.fold_id, fold))
1478 .collect::<BTreeMap<_, _>>();
1479 for block in &cache.blocks {
1480 let fold_id = block.fold_id.as_ref().ok_or_else(|| {
1481 DagMlError::RuntimeValidation(format!(
1482 "bundle `{}` prediction cache `{}` has an OOF block without a fold id",
1483 bundle.bundle_id, cache.cache_id
1484 ))
1485 })?;
1486 let fold = folds.get(fold_id).ok_or_else(|| {
1487 DagMlError::RuntimeValidation(format!(
1488 "bundle `{}` prediction cache `{}` references unknown fold `{fold_id}`",
1489 bundle.bundle_id, cache.cache_id
1490 ))
1491 })?;
1492 let block_samples = block.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1493 if block_samples.len() != block.sample_ids.len() {
1494 return Err(DagMlError::RuntimeValidation(format!(
1495 "bundle `{}` prediction cache `{}` block for fold `{fold_id}` has a duplicate sample for requirement `{}`",
1496 bundle.bundle_id,
1497 cache.cache_id,
1498 requirement.key()
1499 )));
1500 }
1501 let validation_samples = fold
1502 .validation_sample_ids
1503 .iter()
1504 .cloned()
1505 .collect::<BTreeSet<_>>();
1506 if !block_samples.is_subset(&validation_samples) {
1507 return Err(DagMlError::RuntimeValidation(format!(
1508 "bundle `{}` prediction cache `{}` block for fold `{fold_id}` covers samples outside the fold validation set for requirement `{}`",
1509 bundle.bundle_id,
1510 cache.cache_id,
1511 requirement.key()
1512 )));
1513 }
1514 }
1515 }
1516 Ok(())
1517}
1518
1519fn validate_concat_merge_requirement_group(
1532 bundle: &ExecutionBundle,
1533 plan: &ExecutionPlan,
1534 consumer_node: &NodeId,
1535 requirements: &[&BundlePredictionRequirement],
1536 caches: &BTreeMap<String, &BundlePredictionCacheRecord>,
1537) -> Result<()> {
1538 let fold_set = plan.fold_set.as_ref().ok_or_else(|| {
1539 DagMlError::RuntimeValidation(format!(
1540 "bundle `{}` concat-merge node `{consumer_node}` needs fold alignment but plan `{}` has no fold set",
1541 bundle.bundle_id, plan.id
1542 ))
1543 })?;
1544
1545 let expected_keys = plan
1554 .graph_plan
1555 .graph
1556 .edges
1557 .iter()
1558 .filter(|edge| {
1559 &edge.target.node_id == consumer_node
1560 && edge.contract.requires_oof
1561 && edge.contract.requires_fold_alignment
1562 })
1563 .map(|edge| {
1564 bundle_prediction_requirement_key(
1565 &edge.source.node_id,
1566 &edge.source.port_name,
1567 &edge.target.node_id,
1568 &edge.target.port_name,
1569 )
1570 })
1571 .collect::<BTreeSet<_>>();
1572 let supplied_keys = requirements
1573 .iter()
1574 .map(|req| req.key())
1575 .collect::<BTreeSet<_>>();
1576 if supplied_keys != expected_keys {
1577 let missing: Vec<&str> = expected_keys
1578 .difference(&supplied_keys)
1579 .map(String::as_str)
1580 .collect();
1581 let extra: Vec<&str> = supplied_keys
1582 .difference(&expected_keys)
1583 .map(String::as_str)
1584 .collect();
1585 return Err(DagMlError::RuntimeValidation(format!(
1586 "bundle `{}` concat-merge node `{consumer_node}` branch inputs do not match the plan's incoming OOF edges (missing: [{}]; extra: [{}])",
1587 bundle.bundle_id,
1588 missing.join(", "),
1589 extra.join(", ")
1590 )));
1591 }
1592
1593 let expected_universe = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1595 let mut covered_universe = BTreeSet::new();
1596 for requirement in requirements {
1597 for sample_id in &requirement.sample_ids {
1598 if !covered_universe.insert(sample_id.clone()) {
1599 return Err(DagMlError::RuntimeValidation(format!(
1600 "bundle `{}` concat-merge node `{consumer_node}` received overlapping branch predictions: sample `{sample_id}` is covered by more than one partition",
1601 bundle.bundle_id
1602 )));
1603 }
1604 }
1605 }
1606 if covered_universe != expected_universe {
1607 return Err(DagMlError::RuntimeValidation(format!(
1608 "bundle `{}` concat-merge node `{consumer_node}` branch inputs do not cover the full fold set sample universe (each sample exactly once)",
1609 bundle.bundle_id
1610 )));
1611 }
1612
1613 let cached_count = requirements
1624 .iter()
1625 .filter(|req| caches.contains_key(&req.key()))
1626 .count();
1627 if cached_count != 0 && cached_count != requirements.len() {
1628 return Err(DagMlError::RuntimeValidation(format!(
1629 "bundle `{}` concat-merge node `{consumer_node}` has partial prediction-cache coverage ({cached_count} of {} branch inputs cached): all branch inputs must carry a per-fold OOF cache or none",
1630 bundle.bundle_id,
1631 requirements.len()
1632 )));
1633 }
1634 if cached_count == requirements.len() {
1635 let mut covered_by_fold: BTreeMap<FoldId, BTreeSet<SampleId>> = BTreeMap::new();
1636 for requirement in requirements {
1637 let cache = caches.get(&requirement.key()).expect("checked above");
1638 for block in &cache.blocks {
1639 let Some(fold_id) = block.fold_id.as_ref() else {
1640 continue;
1641 };
1642 let covered = covered_by_fold.entry(fold_id.clone()).or_default();
1643 for sample_id in &block.sample_ids {
1644 if !covered.insert(sample_id.clone()) {
1645 return Err(DagMlError::RuntimeValidation(format!(
1646 "bundle `{}` concat-merge node `{consumer_node}` has overlapping branch predictions in fold `{fold_id}`: sample `{sample_id}` is covered by more than one partition",
1647 bundle.bundle_id
1648 )));
1649 }
1650 }
1651 }
1652 }
1653 for fold in &fold_set.folds {
1654 let expected = fold
1655 .validation_sample_ids
1656 .iter()
1657 .cloned()
1658 .collect::<BTreeSet<_>>();
1659 let covered = covered_by_fold.remove(&fold.fold_id).unwrap_or_default();
1660 if covered != expected {
1661 return Err(DagMlError::RuntimeValidation(format!(
1662 "bundle `{}` concat-merge node `{consumer_node}` branch inputs do not cover fold `{}` validation set (each sample exactly once)",
1663 bundle.bundle_id, fold.fold_id
1664 )));
1665 }
1666 }
1667 }
1668 Ok(())
1669}
1670
1671fn validate_prediction_cache_blocks_match_fold_set(
1672 bundle: &ExecutionBundle,
1673 requirement: &BundlePredictionRequirement,
1674 cache: &BundlePredictionCacheRecord,
1675 fold_set: &crate::fold::FoldSet,
1676) -> Result<()> {
1677 let folds = fold_set
1678 .folds
1679 .iter()
1680 .map(|fold| (&fold.fold_id, fold))
1681 .collect::<BTreeMap<_, _>>();
1682 let expected_fold_ids = fold_set
1683 .folds
1684 .iter()
1685 .map(|fold| fold.fold_id.clone())
1686 .collect::<BTreeSet<_>>();
1687 let mut covered_fold_ids = BTreeSet::new();
1688 let mut covered_sample_ids = BTreeSet::new();
1689 for block in &cache.blocks {
1690 let fold_id = block.fold_id.as_ref().ok_or_else(|| {
1691 DagMlError::RuntimeValidation(format!(
1692 "bundle `{}` prediction cache `{}` has an OOF block without a fold id",
1693 bundle.bundle_id, cache.cache_id
1694 ))
1695 })?;
1696 covered_fold_ids.insert(fold_id.clone());
1697 let fold = folds.get(fold_id).ok_or_else(|| {
1698 DagMlError::RuntimeValidation(format!(
1699 "bundle `{}` prediction cache `{}` references unknown fold `{fold_id}`",
1700 bundle.bundle_id, cache.cache_id
1701 ))
1702 })?;
1703 let block_samples = block.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1704 let expected_samples = fold
1705 .validation_sample_ids
1706 .iter()
1707 .cloned()
1708 .collect::<BTreeSet<_>>();
1709 if block_samples != expected_samples {
1710 return Err(DagMlError::RuntimeValidation(format!(
1711 "bundle `{}` prediction cache `{}` block for fold `{fold_id}` does not match validation samples for requirement `{}`",
1712 bundle.bundle_id,
1713 cache.cache_id,
1714 requirement.key()
1715 )));
1716 }
1717 for sample_id in block_samples {
1718 if !covered_sample_ids.insert(sample_id.clone())
1723 && fold_set.partition_mode == crate::fold::FoldPartitionMode::Partition
1724 {
1725 return Err(DagMlError::RuntimeValidation(format!(
1726 "bundle `{}` prediction cache `{}` has duplicate OOF sample `{sample_id}`",
1727 bundle.bundle_id, cache.cache_id
1728 )));
1729 }
1730 }
1731 }
1732 if covered_fold_ids != expected_fold_ids {
1733 return Err(DagMlError::RuntimeValidation(format!(
1734 "bundle `{}` prediction cache `{}` does not cover all folds for requirement `{}`",
1735 bundle.bundle_id,
1736 cache.cache_id,
1737 requirement.key()
1738 )));
1739 }
1740 let expected_sample_ids = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1741 if covered_sample_ids != expected_sample_ids {
1742 return Err(DagMlError::RuntimeValidation(format!(
1743 "bundle `{}` prediction cache `{}` does not cover the full OOF sample universe for requirement `{}`",
1744 bundle.bundle_id,
1745 cache.cache_id,
1746 requirement.key()
1747 )));
1748 }
1749 Ok(())
1750}
1751
1752fn validate_aggregated_prediction_cache_blocks_match_requirement(
1753 bundle: &ExecutionBundle,
1754 requirement: &BundlePredictionRequirement,
1755 cache: &BundlePredictionCacheRecord,
1756 partition_mode: crate::fold::FoldPartitionMode,
1757) -> Result<()> {
1758 let mut covered_fold_ids = BTreeSet::new();
1759 let mut covered_unit_ids = BTreeSet::new();
1760 for block in &cache.blocks {
1761 if block.prediction_level != requirement.prediction_level {
1762 return Err(DagMlError::RuntimeValidation(format!(
1763 "bundle `{}` prediction cache `{}` block level does not match requirement `{}`",
1764 bundle.bundle_id,
1765 cache.cache_id,
1766 requirement.key()
1767 )));
1768 }
1769 if let Some(fold_id) = &block.fold_id {
1770 covered_fold_ids.insert(fold_id.clone());
1771 }
1772 for unit_id in &block.unit_ids {
1773 if !covered_unit_ids.insert(unit_id.clone())
1777 && partition_mode == crate::fold::FoldPartitionMode::Partition
1778 {
1779 return Err(DagMlError::RuntimeValidation(format!(
1780 "bundle `{}` prediction cache `{}` has duplicate aggregated unit `{unit_id}`",
1781 bundle.bundle_id, cache.cache_id
1782 )));
1783 }
1784 }
1785 }
1786 let expected_fold_ids = requirement
1787 .fold_ids
1788 .iter()
1789 .cloned()
1790 .collect::<BTreeSet<_>>();
1791 if covered_fold_ids != expected_fold_ids {
1792 return Err(DagMlError::RuntimeValidation(format!(
1793 "bundle `{}` prediction cache `{}` does not cover all folds for aggregated requirement `{}`",
1794 bundle.bundle_id,
1795 cache.cache_id,
1796 requirement.key()
1797 )));
1798 }
1799 let expected_unit_ids = requirement
1800 .unit_ids
1801 .iter()
1802 .cloned()
1803 .collect::<BTreeSet<_>>();
1804 if covered_unit_ids != expected_unit_ids {
1805 return Err(DagMlError::RuntimeValidation(format!(
1806 "bundle `{}` prediction cache `{}` does not cover all units for aggregated requirement `{}`",
1807 bundle.bundle_id,
1808 cache.cache_id,
1809 requirement.key()
1810 )));
1811 }
1812 Ok(())
1813}
1814
1815pub fn build_execution_bundle(
1816 bundle_id: BundleId,
1817 plan: &ExecutionPlan,
1818 selected_variant_id: Option<VariantId>,
1819 selections: BTreeMap<String, SelectionDecision>,
1820 refit_artifacts: Vec<RefitArtifactRecord>,
1821) -> Result<ExecutionBundle> {
1822 build_execution_bundle_with_prediction_requirements(
1823 bundle_id,
1824 plan,
1825 selected_variant_id,
1826 selections,
1827 refit_artifacts,
1828 Vec::new(),
1829 )
1830}
1831
1832pub fn build_execution_bundle_with_prediction_requirements(
1833 bundle_id: BundleId,
1834 plan: &ExecutionPlan,
1835 selected_variant_id: Option<VariantId>,
1836 selections: BTreeMap<String, SelectionDecision>,
1837 refit_artifacts: Vec<RefitArtifactRecord>,
1838 prediction_requirements: Vec<BundlePredictionRequirement>,
1839) -> Result<ExecutionBundle> {
1840 build_execution_bundle_with_prediction_contracts(
1841 bundle_id,
1842 plan,
1843 selected_variant_id,
1844 selections,
1845 refit_artifacts,
1846 prediction_requirements,
1847 Vec::new(),
1848 )
1849}
1850
1851pub fn build_execution_bundle_with_prediction_contracts(
1852 bundle_id: BundleId,
1853 plan: &ExecutionPlan,
1854 selected_variant_id: Option<VariantId>,
1855 selections: BTreeMap<String, SelectionDecision>,
1856 refit_artifacts: Vec<RefitArtifactRecord>,
1857 prediction_requirements: Vec<BundlePredictionRequirement>,
1858 prediction_caches: Vec<BundlePredictionCacheRecord>,
1859) -> Result<ExecutionBundle> {
1860 plan.validate()?;
1861 let bundle = ExecutionBundle {
1862 bundle_id,
1863 schema_version: EXECUTION_BUNDLE_SCHEMA_VERSION,
1864 plan_id: plan.id.clone(),
1865 graph_fingerprint: plan.graph_fingerprint.clone(),
1866 campaign_fingerprint: plan.campaign_fingerprint.clone(),
1867 controller_fingerprint: plan.controller_fingerprint.clone(),
1868 selected_variant_id,
1869 selections,
1870 refit_artifacts,
1871 prediction_requirements,
1872 prediction_caches,
1873 scores: None,
1874 data_requirements: collect_data_requirements(plan)?,
1875 unsafe_flags: BTreeSet::new(),
1876 metadata: BTreeMap::new(),
1877 };
1878 bundle.validate_against_plan(plan)?;
1879 Ok(bundle)
1880}
1881
1882fn collect_data_requirements(plan: &ExecutionPlan) -> Result<Vec<BundleDataRequirement>> {
1883 let mut requirements = Vec::new();
1884 for node_plan in plan.node_plans.values() {
1885 for binding in &node_plan.data_bindings {
1886 requirements.push(BundleDataRequirement {
1887 node_id: node_plan.node_id.clone(),
1888 input_name: binding.input_name.clone(),
1889 schema_fingerprint: binding.schema_fingerprint.clone(),
1890 plan_fingerprint: binding.plan_fingerprint.clone(),
1891 relation_fingerprint: binding.relation_fingerprint.clone(),
1892 output_representation: binding.output_representation.clone(),
1893 feature_set_id: binding.feature_set_id.clone(),
1894 representation_replay_manifest: None,
1895 representation_compatibility: None,
1896 });
1897 }
1898 }
1899 requirements.sort_by_key(BundleDataRequirement::key);
1900 for requirement in &requirements {
1901 requirement.validate()?;
1902 }
1903 Ok(requirements)
1904}
1905
1906pub fn build_prediction_cache_record(
1907 requirement: &BundlePredictionRequirement,
1908 blocks: &[PredictionBlock],
1909) -> Result<BundlePredictionCacheRecord> {
1910 let selected = select_prediction_cache_blocks(requirement, blocks)?;
1911 build_prediction_cache_record_from_selected(requirement, &selected)
1912}
1913
1914pub fn build_prediction_cache_payload(
1915 requirement: &BundlePredictionRequirement,
1916 blocks: &[PredictionBlock],
1917) -> Result<BundlePredictionCachePayload> {
1918 let selected = select_prediction_cache_blocks(requirement, blocks)?;
1919 let payload = BundlePredictionCachePayload {
1920 requirement_key: requirement.key(),
1921 cache_id: format!("prediction-cache:{}", requirement.key()),
1922 format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
1923 partition: requirement.partition.clone(),
1924 prediction_level: requirement.prediction_level,
1925 block_count: selected.len(),
1926 row_count: selected.iter().map(|block| block.sample_ids.len()).sum(),
1927 content_fingerprint: stable_json_fingerprint(&selected)?,
1928 blocks: selected,
1929 aggregated_blocks: Vec::new(),
1930 };
1931 payload.validate()?;
1932 let record = build_prediction_cache_record(requirement, &payload.blocks)?;
1933 validate_prediction_cache_payload_matches_record(&payload, &record)?;
1934 Ok(payload)
1935}
1936
1937pub fn build_aggregated_prediction_cache_record(
1938 requirement: &BundlePredictionRequirement,
1939 blocks: &[AggregatedPredictionBlock],
1940) -> Result<BundlePredictionCacheRecord> {
1941 let selected = select_aggregated_prediction_cache_blocks(requirement, blocks)?;
1942 build_aggregated_prediction_cache_record_from_selected(requirement, &selected)
1943}
1944
1945pub fn build_aggregated_prediction_cache_payload(
1946 requirement: &BundlePredictionRequirement,
1947 blocks: &[AggregatedPredictionBlock],
1948) -> Result<BundlePredictionCachePayload> {
1949 let selected = select_aggregated_prediction_cache_blocks(requirement, blocks)?;
1950 let payload = BundlePredictionCachePayload {
1951 requirement_key: requirement.key(),
1952 cache_id: format!("prediction-cache:{}", requirement.key()),
1953 format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
1954 partition: requirement.partition.clone(),
1955 prediction_level: requirement.prediction_level,
1956 block_count: selected.len(),
1957 row_count: selected.iter().map(|block| block.unit_ids.len()).sum(),
1958 content_fingerprint: stable_json_fingerprint(&selected)?,
1959 blocks: Vec::new(),
1960 aggregated_blocks: selected,
1961 };
1962 payload.validate()?;
1963 let record = build_aggregated_prediction_cache_record(requirement, &payload.aggregated_blocks)?;
1964 validate_prediction_cache_payload_matches_record(&payload, &record)?;
1965 Ok(payload)
1966}
1967
1968pub fn validate_prediction_cache_payload_matches_record(
1969 payload: &BundlePredictionCachePayload,
1970 record: &BundlePredictionCacheRecord,
1971) -> Result<()> {
1972 payload.validate()?;
1973 record.validate()?;
1974 if payload.requirement_key != record.requirement_key
1975 || payload.cache_id != record.cache_id
1976 || payload.format != record.format
1977 || payload.partition != record.partition
1978 || payload.prediction_level != record.prediction_level
1979 || payload.block_count != record.block_count
1980 || payload.row_count != record.row_count
1981 || payload.content_fingerprint != record.content_fingerprint
1982 {
1983 return Err(DagMlError::RuntimeValidation(format!(
1984 "prediction cache payload `{}` does not match cache record `{}`",
1985 payload.cache_id, record.cache_id
1986 )));
1987 }
1988 let block_records = if payload.prediction_level == PredictionLevel::Sample {
1989 payload
1990 .blocks
1991 .iter()
1992 .map(|block| {
1993 Ok(BundlePredictionBlockCacheRecord {
1994 prediction_id: block.prediction_id.clone(),
1995 fold_id: block.fold_id.clone(),
1996 prediction_level: PredictionLevel::Sample,
1997 row_count: block.sample_ids.len(),
1998 unit_ids: Vec::new(),
1999 sample_ids: block.sample_ids.clone(),
2000 content_fingerprint: stable_json_fingerprint(block)?,
2001 })
2002 })
2003 .collect::<Result<Vec<_>>>()?
2004 } else {
2005 payload
2006 .aggregated_blocks
2007 .iter()
2008 .map(|block| {
2009 Ok(BundlePredictionBlockCacheRecord {
2010 prediction_id: block.prediction_id.clone(),
2011 fold_id: block.fold_id.clone(),
2012 prediction_level: block.level,
2013 row_count: block.unit_ids.len(),
2014 unit_ids: block.unit_ids.clone(),
2015 sample_ids: Vec::new(),
2016 content_fingerprint: stable_json_fingerprint(block)?,
2017 })
2018 })
2019 .collect::<Result<Vec<_>>>()?
2020 };
2021 if block_records != record.blocks {
2022 return Err(DagMlError::RuntimeValidation(format!(
2023 "prediction cache payload `{}` block fingerprints do not match cache record",
2024 payload.cache_id
2025 )));
2026 }
2027 Ok(())
2028}
2029
2030fn select_prediction_cache_blocks(
2031 requirement: &BundlePredictionRequirement,
2032 blocks: &[PredictionBlock],
2033) -> Result<Vec<PredictionBlock>> {
2034 requirement.validate()?;
2035 let mut selected = blocks
2036 .iter()
2037 .filter(|block| {
2038 block.producer_node == requirement.producer_node
2039 && block.partition == requirement.partition
2040 })
2041 .cloned()
2042 .collect::<Vec<_>>();
2043 if selected.is_empty() {
2044 return Err(DagMlError::RuntimeValidation(format!(
2045 "prediction cache requirement `{}` has no matching prediction blocks",
2046 requirement.key()
2047 )));
2048 }
2049 selected.sort_by(|left, right| {
2050 (
2051 left.fold_id.as_ref().map(ToString::to_string),
2052 left.prediction_id.clone(),
2053 )
2054 .cmp(&(
2055 right.fold_id.as_ref().map(ToString::to_string),
2056 right.prediction_id.clone(),
2057 ))
2058 });
2059 Ok(selected)
2060}
2061
2062fn select_aggregated_prediction_cache_blocks(
2063 requirement: &BundlePredictionRequirement,
2064 blocks: &[AggregatedPredictionBlock],
2065) -> Result<Vec<AggregatedPredictionBlock>> {
2066 requirement.validate()?;
2067 if requirement.prediction_level == PredictionLevel::Sample {
2068 return Err(DagMlError::RuntimeValidation(format!(
2069 "aggregated prediction cache requirement `{}` must use target or group level",
2070 requirement.key()
2071 )));
2072 }
2073 let mut selected = blocks
2074 .iter()
2075 .filter(|block| {
2076 block.producer_node == requirement.producer_node
2077 && block.partition == requirement.partition
2078 && block.level == requirement.prediction_level
2079 })
2080 .cloned()
2081 .collect::<Vec<_>>();
2082 if selected.is_empty() {
2083 return Err(DagMlError::RuntimeValidation(format!(
2084 "aggregated prediction cache requirement `{}` has no matching prediction blocks",
2085 requirement.key()
2086 )));
2087 }
2088 selected.sort_by(|left, right| {
2089 (
2090 left.fold_id.as_ref().map(ToString::to_string),
2091 left.prediction_id.clone(),
2092 )
2093 .cmp(&(
2094 right.fold_id.as_ref().map(ToString::to_string),
2095 right.prediction_id.clone(),
2096 ))
2097 });
2098 Ok(selected)
2099}
2100
2101fn build_prediction_cache_record_from_selected(
2102 requirement: &BundlePredictionRequirement,
2103 selected: &[PredictionBlock],
2104) -> Result<BundlePredictionCacheRecord> {
2105 requirement.validate()?;
2106 if selected.is_empty() {
2107 return Err(DagMlError::RuntimeValidation(format!(
2108 "prediction cache requirement `{}` has no matching prediction blocks",
2109 requirement.key()
2110 )));
2111 }
2112 let mut fold_ids = BTreeSet::new();
2113 let mut sample_ids = BTreeSet::new();
2114 let mut target_names: Option<Vec<String>> = None;
2115 let mut prediction_width: Option<usize> = None;
2116 let mut row_count = 0usize;
2117 let mut block_records = Vec::new();
2118 for block in selected {
2119 if block.producer_node != requirement.producer_node
2120 || block.partition != requirement.partition
2121 {
2122 return Err(DagMlError::RuntimeValidation(format!(
2123 "prediction cache `{}` contains a block outside the requirement scope",
2124 requirement.key()
2125 )));
2126 }
2127 let width = block.validate_shape()?;
2128 if prediction_width.is_some_and(|expected| expected != width) {
2129 return Err(DagMlError::RuntimeValidation(format!(
2130 "prediction cache `{}` has inconsistent prediction width",
2131 requirement.key()
2132 )));
2133 }
2134 prediction_width = Some(width);
2135 let block_target_names = normalized_prediction_targets(block, width);
2136 if target_names
2137 .as_ref()
2138 .is_some_and(|expected| expected != &block_target_names)
2139 {
2140 return Err(DagMlError::RuntimeValidation(format!(
2141 "prediction cache `{}` has inconsistent target names",
2142 requirement.key()
2143 )));
2144 }
2145 target_names = Some(block_target_names);
2146 if let Some(fold_id) = &block.fold_id {
2147 fold_ids.insert(fold_id.clone());
2148 }
2149 sample_ids.extend(block.sample_ids.iter().cloned());
2150 row_count += block.sample_ids.len();
2151 block_records.push(BundlePredictionBlockCacheRecord {
2152 prediction_id: block.prediction_id.clone(),
2153 fold_id: block.fold_id.clone(),
2154 prediction_level: PredictionLevel::Sample,
2155 row_count: block.sample_ids.len(),
2156 unit_ids: Vec::new(),
2157 sample_ids: block.sample_ids.clone(),
2158 content_fingerprint: stable_json_fingerprint(block)?,
2159 });
2160 }
2161
2162 let record = BundlePredictionCacheRecord {
2163 requirement_key: requirement.key(),
2164 cache_id: format!("prediction-cache:{}", requirement.key()),
2165 format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
2166 partition: requirement.partition.clone(),
2167 prediction_level: requirement.prediction_level,
2168 fold_ids: fold_ids.into_iter().collect(),
2169 unit_ids: requirement.unit_ids.clone(),
2170 sample_ids: sample_ids.into_iter().collect(),
2171 prediction_width: prediction_width.unwrap_or_default(),
2172 target_names: target_names.unwrap_or_default(),
2173 block_count: block_records.len(),
2174 row_count,
2175 content_fingerprint: stable_json_fingerprint(selected)?,
2176 blocks: block_records,
2177 };
2178 validate_prediction_cache_matches_requirement(&record, requirement)?;
2179 record.validate()?;
2180 Ok(record)
2181}
2182
2183fn build_aggregated_prediction_cache_record_from_selected(
2184 requirement: &BundlePredictionRequirement,
2185 selected: &[AggregatedPredictionBlock],
2186) -> Result<BundlePredictionCacheRecord> {
2187 requirement.validate()?;
2188 if requirement.prediction_level == PredictionLevel::Sample {
2189 return Err(DagMlError::RuntimeValidation(format!(
2190 "aggregated prediction cache requirement `{}` must use target or group level",
2191 requirement.key()
2192 )));
2193 }
2194 if selected.is_empty() {
2195 return Err(DagMlError::RuntimeValidation(format!(
2196 "aggregated prediction cache requirement `{}` has no matching prediction blocks",
2197 requirement.key()
2198 )));
2199 }
2200 let mut fold_ids = BTreeSet::new();
2201 let mut unit_ids = BTreeSet::new();
2202 let mut target_names: Option<Vec<String>> = None;
2203 let mut prediction_width: Option<usize> = None;
2204 let mut row_count = 0usize;
2205 let mut block_records = Vec::new();
2206 for block in selected {
2207 if block.producer_node != requirement.producer_node
2208 || block.partition != requirement.partition
2209 || block.level != requirement.prediction_level
2210 {
2211 return Err(DagMlError::RuntimeValidation(format!(
2212 "aggregated prediction cache `{}` contains a block outside the requirement scope",
2213 requirement.key()
2214 )));
2215 }
2216 let width = block.validate_shape()?;
2217 if prediction_width.is_some_and(|expected| expected != width) {
2218 return Err(DagMlError::RuntimeValidation(format!(
2219 "aggregated prediction cache `{}` has inconsistent prediction width",
2220 requirement.key()
2221 )));
2222 }
2223 prediction_width = Some(width);
2224 let block_target_names = normalized_aggregated_prediction_targets(block, width);
2225 if target_names
2226 .as_ref()
2227 .is_some_and(|expected| expected != &block_target_names)
2228 {
2229 return Err(DagMlError::RuntimeValidation(format!(
2230 "aggregated prediction cache `{}` has inconsistent target names",
2231 requirement.key()
2232 )));
2233 }
2234 target_names = Some(block_target_names);
2235 if let Some(fold_id) = &block.fold_id {
2236 fold_ids.insert(fold_id.clone());
2237 }
2238 unit_ids.extend(block.unit_ids.iter().cloned());
2239 row_count += block.unit_ids.len();
2240 block_records.push(BundlePredictionBlockCacheRecord {
2241 prediction_id: block.prediction_id.clone(),
2242 fold_id: block.fold_id.clone(),
2243 prediction_level: block.level,
2244 row_count: block.unit_ids.len(),
2245 unit_ids: block.unit_ids.clone(),
2246 sample_ids: Vec::new(),
2247 content_fingerprint: stable_json_fingerprint(block)?,
2248 });
2249 }
2250
2251 let record = BundlePredictionCacheRecord {
2252 requirement_key: requirement.key(),
2253 cache_id: format!("prediction-cache:{}", requirement.key()),
2254 format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
2255 partition: requirement.partition.clone(),
2256 prediction_level: requirement.prediction_level,
2257 fold_ids: fold_ids.into_iter().collect(),
2258 unit_ids: unit_ids.into_iter().collect(),
2259 sample_ids: Vec::new(),
2260 prediction_width: prediction_width.unwrap_or_default(),
2261 target_names: target_names.unwrap_or_default(),
2262 block_count: block_records.len(),
2263 row_count,
2264 content_fingerprint: stable_json_fingerprint(selected)?,
2265 blocks: block_records,
2266 };
2267 validate_prediction_cache_matches_requirement(&record, requirement)?;
2268 record.validate()?;
2269 Ok(record)
2270}
2271
2272fn validate_prediction_cache_matches_requirement(
2273 cache: &BundlePredictionCacheRecord,
2274 requirement: &BundlePredictionRequirement,
2275) -> Result<()> {
2276 if cache.requirement_key != requirement.key()
2277 || cache.partition != requirement.partition
2278 || cache.prediction_level != requirement.prediction_level
2279 || cache.fold_ids != requirement.fold_ids
2280 || cache.unit_ids != requirement.unit_ids
2281 || cache.sample_ids != requirement.sample_ids
2282 || cache.prediction_width != requirement.prediction_width
2283 || cache.target_names != requirement.target_names
2284 {
2285 return Err(DagMlError::RuntimeValidation(format!(
2286 "prediction cache `{}` does not match requirement `{}`",
2287 cache.cache_id,
2288 requirement.key()
2289 )));
2290 }
2291 Ok(())
2292}
2293
2294fn normalized_prediction_targets(block: &PredictionBlock, width: usize) -> Vec<String> {
2295 if block.target_names.is_empty() {
2296 (0..width).map(|index| format!("p{index}")).collect()
2297 } else {
2298 block.target_names.clone()
2299 }
2300}
2301
2302fn normalized_aggregated_prediction_targets(
2303 block: &AggregatedPredictionBlock,
2304 width: usize,
2305) -> Vec<String> {
2306 if block.target_names.is_empty() {
2307 (0..width).map(|index| format!("p{index}")).collect()
2308 } else {
2309 block.target_names.clone()
2310 }
2311}
2312
2313fn sample_prediction_units(sample_ids: &[SampleId]) -> Vec<PredictionUnitId> {
2314 sample_ids
2315 .iter()
2316 .cloned()
2317 .map(PredictionUnitId::Sample)
2318 .collect()
2319}
2320
2321fn validate_prediction_units(
2322 label: &str,
2323 expected_level: PredictionLevel,
2324 unit_ids: &[PredictionUnitId],
2325) -> Result<()> {
2326 validate_unique_ids(label, unit_ids)?;
2327 for unit_id in unit_ids {
2328 if unit_id.level() != expected_level {
2329 return Err(DagMlError::RuntimeValidation(format!(
2330 "{label} `{unit_id}` does not match prediction level {:?}",
2331 expected_level
2332 )));
2333 }
2334 }
2335 Ok(())
2336}
2337
2338fn validate_fingerprint(label: &str, value: &str) -> Result<()> {
2339 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2340 return Err(DagMlError::RuntimeValidation(format!(
2341 "{label} fingerprint must be a 64-character hex digest"
2342 )));
2343 }
2344 Ok(())
2345}
2346
2347fn validate_non_empty(label: &str, value: &str) -> Result<()> {
2348 if value.trim().is_empty() {
2349 return Err(DagMlError::RuntimeValidation(format!("{label} is empty")));
2350 }
2351 Ok(())
2352}
2353
2354fn validate_unique_ids<T>(label: &str, values: &[T]) -> Result<()>
2355where
2356 T: Ord + ToString,
2357{
2358 let mut seen = BTreeSet::new();
2359 for value in values {
2360 if !seen.insert(value) {
2361 return Err(DagMlError::RuntimeValidation(format!(
2362 "duplicate {label} `{}`",
2363 value.to_string()
2364 )));
2365 }
2366 }
2367 Ok(())
2368}
2369
2370#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2371pub struct ReplayPhaseRequest {
2372 pub bundle_id: BundleId,
2373 pub phase: Phase,
2374 #[serde(default)]
2375 pub data_envelope_keys: Vec<String>,
2376}
2377
2378impl ReplayPhaseRequest {
2379 pub fn validate_for_bundle(&self, bundle: &ExecutionBundle) -> Result<()> {
2380 self.validate_for_bundle_with_prediction_cache_store(bundle, false)
2381 }
2382
2383 pub fn validate_for_bundle_with_prediction_cache_store(
2384 &self,
2385 bundle: &ExecutionBundle,
2386 prediction_cache_available: bool,
2387 ) -> Result<()> {
2388 self.validate_for_bundle_internal(bundle, prediction_cache_available)
2389 }
2390
2391 pub fn validate_for_bundle_with_prediction_cache_payloads(
2392 &self,
2393 bundle: &ExecutionBundle,
2394 prediction_cache_payloads: Option<&BundlePredictionCachePayloadSet>,
2395 ) -> Result<()> {
2396 if let Some(payloads) = prediction_cache_payloads {
2397 payloads.validate_against_bundle(bundle)?;
2398 }
2399 self.validate_for_bundle_internal(bundle, prediction_cache_payloads.is_some())
2400 }
2401
2402 fn validate_for_bundle_internal(
2403 &self,
2404 bundle: &ExecutionBundle,
2405 prediction_cache_available: bool,
2406 ) -> Result<()> {
2407 bundle.validate()?;
2408 if self.bundle_id != bundle.bundle_id {
2409 return Err(DagMlError::RuntimeValidation(format!(
2410 "replay request bundle `{}` does not match bundle `{}`",
2411 self.bundle_id, bundle.bundle_id
2412 )));
2413 }
2414 if !matches!(self.phase, Phase::Predict | Phase::Explain | Phase::Refit) {
2415 return Err(DagMlError::RuntimeValidation(format!(
2416 "bundle replay phase {:?} is not supported",
2417 self.phase
2418 )));
2419 }
2420 if self.phase == Phase::Refit && !bundle.prediction_requirements.is_empty() {
2421 if prediction_cache_available {
2422 return self.validate_data_envelope_keys(bundle);
2423 }
2424 return Err(DagMlError::RuntimeValidation(format!(
2425 "bundle `{}` cannot replay REFIT because it depends on {} OOF prediction requirement(s) but stores only prediction cache manifests",
2426 bundle.bundle_id,
2427 bundle.prediction_requirements.len()
2428 )));
2429 }
2430 self.validate_data_envelope_keys(bundle)
2431 }
2432
2433 fn validate_data_envelope_keys(&self, bundle: &ExecutionBundle) -> Result<()> {
2434 let expected = bundle
2435 .data_requirements
2436 .iter()
2437 .map(BundleDataRequirement::key)
2438 .collect::<BTreeSet<_>>();
2439 let mut requested = BTreeSet::new();
2440 for key in &self.data_envelope_keys {
2441 if key.trim().is_empty() {
2442 return Err(DagMlError::RuntimeValidation(
2443 "replay request contains an empty data envelope key".to_string(),
2444 ));
2445 }
2446 if !requested.insert(key.as_str()) {
2447 return Err(DagMlError::RuntimeValidation(format!(
2448 "replay request contains duplicate data envelope key `{key}`"
2449 )));
2450 }
2451 if !expected.contains(key.as_str()) {
2452 return Err(DagMlError::RuntimeValidation(format!(
2453 "replay request references unknown data envelope key `{key}`"
2454 )));
2455 }
2456 }
2457 for requirement in &bundle.data_requirements {
2458 let key = requirement.key();
2459 if !requested.contains(key.as_str()) {
2460 return Err(DagMlError::RuntimeValidation(format!(
2461 "replay request is missing data envelope key `{key}`"
2462 )));
2463 }
2464 }
2465 Ok(())
2466 }
2467}
2468
2469#[cfg(test)]
2470mod tests {
2471 use super::*;
2472 use crate::controller::{ControllerManifest, ControllerRegistry};
2473 use crate::data::{
2474 AggregateRepresentation, RepresentationCardinality, RepresentationCompatibilityOutcome,
2475 RepresentationCompatibilityReport, RepresentationMissingSourcePolicy, RepresentationPlan,
2476 RepresentationReplayManifest,
2477 };
2478 use crate::dsl::{compile_pipeline_dsl_with_generation, PipelineDslSpec};
2479 use crate::graph::GraphSpec;
2480 use crate::ids::{ArtifactId, FoldId, SampleId, TargetId};
2481 use crate::plan::{build_execution_plan, CampaignSpec};
2482 use crate::relation::EntityUnitLevel;
2483 use crate::selection::{
2484 select_candidate, CandidateScore, MetricObjective, SelectionMetric, SelectionPolicy,
2485 };
2486
2487 fn plan() -> ExecutionPlan {
2488 let graph: GraphSpec =
2489 serde_json::from_str(include_str!("../../../examples/minimal_graph.json")).unwrap();
2490 let campaign: CampaignSpec = serde_json::from_str(include_str!(
2491 "../../../examples/campaign_oof_generation.json"
2492 ))
2493 .unwrap();
2494 let manifests: Vec<ControllerManifest> =
2495 serde_json::from_str(include_str!("../../../examples/controller_manifests.json"))
2496 .unwrap();
2497 let mut registry = ControllerRegistry::new();
2498 for manifest in manifests {
2499 registry.register(manifest).unwrap();
2500 }
2501 build_execution_plan("plan:bundle", graph, campaign, ®istry).unwrap()
2502 }
2503
2504 fn branch_merge_plan() -> ExecutionPlan {
2505 let graph: GraphSpec = serde_json::from_str(include_str!(
2506 "../../../examples/branch_merge_oof_graph.json"
2507 ))
2508 .unwrap();
2509 let campaign: CampaignSpec = serde_json::from_str(include_str!(
2510 "../../../examples/campaign_branch_merge_oof.json"
2511 ))
2512 .unwrap();
2513 let manifests: Vec<ControllerManifest> =
2514 serde_json::from_str(include_str!("../../../examples/controller_manifests.json"))
2515 .unwrap();
2516 let mut registry = ControllerRegistry::new();
2517 for manifest in manifests {
2518 registry.register(manifest).unwrap();
2519 }
2520 build_execution_plan("plan:branch.merge.bundle", graph, campaign, ®istry).unwrap()
2521 }
2522
2523 fn separation_concat_merge_plan() -> ExecutionPlan {
2530 let graph: GraphSpec = serde_json::from_str(include_str!(
2531 "../../../examples/separation_branch_concat_merge_oof_graph.json"
2532 ))
2533 .unwrap();
2534 let campaign: CampaignSpec = serde_json::from_str(include_str!(
2535 "../../../examples/campaign_separation_branch_concat_merge_oof.json"
2536 ))
2537 .unwrap();
2538 let manifests: Vec<ControllerManifest> =
2539 serde_json::from_str(include_str!("../../../examples/controller_manifests.json"))
2540 .unwrap();
2541 let mut registry = ControllerRegistry::new();
2542 for manifest in manifests {
2543 registry.register(manifest).unwrap();
2544 }
2545 build_execution_plan(
2546 "plan:separation.concat.merge.bundle",
2547 graph,
2548 campaign,
2549 ®istry,
2550 )
2551 .unwrap()
2552 }
2553
2554 fn separation_branch_requirement(
2558 producer_node: &str,
2559 partition_samples: &[&str],
2560 partition_folds: &[&str],
2561 ) -> BundlePredictionRequirement {
2562 BundlePredictionRequirement {
2563 producer_node: NodeId::new(producer_node).unwrap(),
2564 source_port: "oof".to_string(),
2565 consumer_node: NodeId::new("merge:sites").unwrap(),
2566 target_port: format!("oof_{producer_node}"),
2567 partition: PredictionPartition::Validation,
2568 prediction_level: PredictionLevel::Sample,
2569 fold_ids: partition_folds
2570 .iter()
2571 .map(|f| FoldId::new(*f).unwrap())
2572 .collect(),
2573 unit_ids: Vec::new(),
2574 sample_ids: partition_samples
2575 .iter()
2576 .map(|s| SampleId::new(*s).unwrap())
2577 .collect(),
2578 prediction_width: 1,
2579 target_names: vec!["y".to_string()],
2580 }
2581 }
2582
2583 fn separation_branch_blocks(
2586 producer_node: &str,
2587 fold0_sample: &str,
2588 fold1_sample: &str,
2589 offset: f64,
2590 ) -> Vec<PredictionBlock> {
2591 let producer_node = NodeId::new(producer_node).unwrap();
2592 vec![
2593 PredictionBlock {
2594 prediction_id: Some(format!("prediction:{producer_node}:fold0")),
2595 producer_node: producer_node.clone(),
2596 partition: PredictionPartition::Validation,
2597 fold_id: Some(FoldId::new("fold:0").unwrap()),
2598 sample_ids: vec![SampleId::new(fold0_sample).unwrap()],
2599 values: vec![vec![offset + 0.1]],
2600 target_names: vec!["y".to_string()],
2601 },
2602 PredictionBlock {
2603 prediction_id: Some(format!("prediction:{producer_node}:fold1")),
2604 producer_node,
2605 partition: PredictionPartition::Validation,
2606 fold_id: Some(FoldId::new("fold:1").unwrap()),
2607 sample_ids: vec![SampleId::new(fold1_sample).unwrap()],
2608 values: vec![vec![offset + 0.2]],
2609 target_names: vec!["y".to_string()],
2610 },
2611 ]
2612 }
2613
2614 fn executable_dsl_plan() -> ExecutionPlan {
2615 let spec: PipelineDslSpec = serde_json::from_str(include_str!(
2616 "../../../examples/pipeline_dsl_branch_merge_executable.json"
2617 ))
2618 .unwrap();
2619 let compiled = compile_pipeline_dsl_with_generation(&spec).unwrap();
2620 let manifests: Vec<ControllerManifest> =
2621 serde_json::from_str(include_str!("../../../examples/controller_manifests.json"))
2622 .unwrap();
2623 let mut registry = ControllerRegistry::new();
2624 for manifest in manifests {
2625 registry.register(manifest).unwrap();
2626 }
2627 build_execution_plan(
2628 "plan:dsl.branch.merge.bundle",
2629 compiled.graph,
2630 compiled.campaign_template,
2631 ®istry,
2632 )
2633 .unwrap()
2634 }
2635
2636 fn branch_merge_selection_decisions() -> BTreeMap<String, SelectionDecision> {
2637 serde_json::from_str(include_str!(
2638 "../../../examples/fixtures/bundle/selection_decisions_branch_merge.json"
2639 ))
2640 .unwrap()
2641 }
2642
2643 fn refit_artifact(
2644 plan: &ExecutionPlan,
2645 node_id: &str,
2646 data_requirement_keys: Vec<String>,
2647 prediction_requirement_keys: Vec<String>,
2648 ) -> RefitArtifactRecord {
2649 let node_id = NodeId::new(node_id).unwrap();
2650 let node_plan = plan.node_plans.get(&node_id).unwrap();
2651 RefitArtifactRecord {
2652 node_id: node_plan.node_id.clone(),
2653 controller_id: node_plan.controller_id.clone(),
2654 artifact: ArtifactRef {
2655 id: ArtifactId::new(format!("artifact:{}:refit", node_plan.node_id)).unwrap(),
2656 kind: "mock_model".to_string(),
2657 controller_id: node_plan.controller_id.clone(),
2658 backend: None,
2659 uri: None,
2660 content_fingerprint: None,
2661 size_bytes: Some(128),
2662 plugin: None,
2663 plugin_version: None,
2664 },
2665 params_fingerprint: node_plan.params_fingerprint.clone(),
2666 data_requirement_keys,
2667 prediction_requirement_keys,
2668 }
2669 }
2670
2671 fn branch_merge_samples() -> Vec<SampleId> {
2672 vec![
2673 SampleId::new("sample:1").unwrap(),
2674 SampleId::new("sample:2").unwrap(),
2675 SampleId::new("sample:3").unwrap(),
2676 SampleId::new("sample:4").unwrap(),
2677 ]
2678 }
2679
2680 fn branch_merge_requirement(
2681 producer_node: &str,
2682 target_port: &str,
2683 ) -> BundlePredictionRequirement {
2684 BundlePredictionRequirement {
2685 producer_node: NodeId::new(producer_node).unwrap(),
2686 source_port: "oof".to_string(),
2687 consumer_node: NodeId::new("merge:stack.pred_plus_original.meta:ridge").unwrap(),
2688 target_port: target_port.to_string(),
2689 partition: PredictionPartition::Validation,
2690 prediction_level: PredictionLevel::Sample,
2691 fold_ids: vec![
2692 FoldId::new("fold:0").unwrap(),
2693 FoldId::new("fold:1").unwrap(),
2694 ],
2695 unit_ids: Vec::new(),
2696 sample_ids: branch_merge_samples(),
2697 prediction_width: 1,
2698 target_names: vec!["y".to_string()],
2699 }
2700 }
2701
2702 fn branch_merge_prediction_blocks(producer_node: &str, offset: f64) -> Vec<PredictionBlock> {
2703 let producer_node = NodeId::new(producer_node).unwrap();
2704 let samples = branch_merge_samples();
2705 vec![
2706 PredictionBlock {
2707 prediction_id: Some(format!("prediction:{producer_node}:fold0")),
2708 producer_node: producer_node.clone(),
2709 partition: PredictionPartition::Validation,
2710 fold_id: Some(FoldId::new("fold:0").unwrap()),
2711 sample_ids: samples[0..2].to_vec(),
2712 values: vec![vec![offset + 0.1], vec![offset + 0.2]],
2713 target_names: vec!["y".to_string()],
2714 },
2715 PredictionBlock {
2716 prediction_id: Some(format!("prediction:{producer_node}:fold1")),
2717 producer_node,
2718 partition: PredictionPartition::Validation,
2719 fold_id: Some(FoldId::new("fold:1").unwrap()),
2720 sample_ids: samples[2..4].to_vec(),
2721 values: vec![vec![offset + 0.3], vec![offset + 0.4]],
2722 target_names: vec!["y".to_string()],
2723 },
2724 ]
2725 }
2726
2727 fn decision() -> SelectionDecision {
2728 select_candidate(
2729 &SelectionPolicy {
2730 id: "select:merge".to_string(),
2731 metric: SelectionMetric {
2732 name: "rmse".to_string(),
2733 objective: MetricObjective::Minimize,
2734 },
2735 required_metric_level: Some(crate::policy::PredictionLevel::Sample),
2736 require_finite: true,
2737 evaluation_scope: None,
2738 refit_slot_plan: None,
2739 stacking_fit_contract: None,
2740 reduction_id: None,
2741 },
2742 &[
2743 CandidateScore {
2744 candidate_id: "model:base".to_string(),
2745 metrics: BTreeMap::from([("rmse".to_string(), 1.0)]),
2746 metadata: BTreeMap::from([(
2747 "metric_level".to_string(),
2748 serde_json::Value::String("sample".to_string()),
2749 )]),
2750 },
2751 CandidateScore {
2752 candidate_id: "model:other".to_string(),
2753 metrics: BTreeMap::from([("rmse".to_string(), 2.0)]),
2754 metadata: BTreeMap::from([(
2755 "metric_level".to_string(),
2756 serde_json::Value::String("sample".to_string()),
2757 )]),
2758 },
2759 ],
2760 )
2761 .unwrap()
2762 }
2763
2764 fn selected_model_base_decision() -> SelectionDecision {
2765 decision()
2766 }
2767
2768 fn model_base_refit_artifact(plan: &ExecutionPlan) -> RefitArtifactRecord {
2769 let model_plan = plan
2770 .node_plans
2771 .get(&NodeId::new("model:base").unwrap())
2772 .unwrap();
2773 RefitArtifactRecord {
2774 node_id: model_plan.node_id.clone(),
2775 controller_id: model_plan.controller_id.clone(),
2776 artifact: ArtifactRef {
2777 id: ArtifactId::new("artifact:model:base:refit").unwrap(),
2778 kind: "sklearn_pickle".to_string(),
2779 controller_id: model_plan.controller_id.clone(),
2780 backend: None,
2781 uri: None,
2782 content_fingerprint: None,
2783 size_bytes: Some(128),
2784 plugin: None,
2785 plugin_version: None,
2786 },
2787 params_fingerprint: model_plan.params_fingerprint.clone(),
2788 data_requirement_keys: vec!["model:base.x".to_string()],
2789 prediction_requirement_keys: Vec::new(),
2790 }
2791 }
2792
2793 #[test]
2794 fn builds_bundle_from_execution_plan() {
2795 let plan = plan();
2796 let artifact = model_base_refit_artifact(&plan);
2797
2798 let bundle = build_execution_bundle(
2799 BundleId::new("bundle:demo").unwrap(),
2800 &plan,
2801 Some(plan.variants[0].variant_id.clone()),
2802 BTreeMap::from([("merge".to_string(), decision())]),
2803 vec![artifact],
2804 )
2805 .unwrap();
2806
2807 bundle.validate_against_plan(&plan).unwrap();
2808 assert_eq!(bundle.data_requirements.len(), 1);
2809 }
2810
2811 #[test]
2812 fn bundle_data_requirements_accept_d7_replay_contracts() {
2813 let plan = plan();
2814 let artifact = model_base_refit_artifact(&plan);
2815 let mut bundle = build_execution_bundle(
2816 BundleId::new("bundle:d7.replay").unwrap(),
2817 &plan,
2818 Some(plan.variants[0].variant_id.clone()),
2819 BTreeMap::from([("merge".to_string(), decision())]),
2820 vec![artifact],
2821 )
2822 .unwrap();
2823 let relation_fingerprint = bundle.data_requirements[0]
2824 .relation_fingerprint
2825 .clone()
2826 .unwrap_or_else(|| "a".repeat(64));
2827 bundle.data_requirements[0].representation_replay_manifest =
2828 Some(RepresentationReplayManifest {
2829 manifest_id: "repr:d7.bundle".to_string(),
2830 representation_plan: RepresentationPlan::Aggregate(AggregateRepresentation {
2831 input_unit_level: EntityUnitLevel::Observation,
2832 output_unit_level: EntityUnitLevel::PhysicalSample,
2833 reducer_id: None,
2834 method: Some("mean".to_string()),
2835 cardinality: RepresentationCardinality::ManyToOne,
2836 }),
2837 combination_plan: None,
2838 output_unit_level: EntityUnitLevel::PhysicalSample,
2839 output_representation: Some("tabular_numeric".to_string()),
2840 relation_fingerprint: Some(relation_fingerprint.clone()),
2841 feature_schema_fingerprint: Some("b".repeat(64)),
2842 final_reduction_id: None,
2843 sample_observation_mapping: Vec::new(),
2844 combo_selection: Vec::new(),
2845 qc_policy_refs: Vec::new(),
2846 outlier_policy_refs: Vec::new(),
2847 missing_source_policy: None,
2848 missing_repetition_policy: None,
2849 prediction_representation: None,
2850 final_output_unit_level: Some(EntityUnitLevel::PhysicalSample),
2851 train_compatibility: None,
2852 predict_compatibility: None,
2853 metadata: BTreeMap::new(),
2854 });
2855 bundle.data_requirements[0].representation_compatibility =
2856 Some(RepresentationCompatibilityReport {
2857 policy: RepresentationMissingSourcePolicy::Strict,
2858 outcome: RepresentationCompatibilityOutcome::Compatible,
2859 fallback_used: None,
2860 warning_severity: None,
2861 affected_source_count: 0,
2862 affected_repetition_count: 0,
2863 affected_sample_count: 0,
2864 train_relation_fingerprint: Some(relation_fingerprint),
2865 predict_relation_fingerprint: None,
2866 train_unit_count: Some(2),
2867 predict_unit_count: Some(2),
2868 fixed_width_required: false,
2869 final_reducer_stabilizes_output: true,
2870 cartesian_combo_count_changed: false,
2871 late_fusion_branch_delta: false,
2872 messages: Vec::new(),
2873 metadata: BTreeMap::new(),
2874 });
2875 bundle.validate_against_plan(&plan).unwrap();
2876
2877 bundle.data_requirements[0]
2878 .representation_replay_manifest
2879 .as_mut()
2880 .unwrap()
2881 .relation_fingerprint = Some("c".repeat(64));
2882 if bundle.data_requirements[0].relation_fingerprint.is_some() {
2883 assert!(bundle.validate().is_err());
2884 }
2885 }
2886
2887 #[test]
2888 fn d9_negative_prediction_cache_refuses_missing_aggregated_unit_ids() {
2889 let cache = BundlePredictionCacheRecord {
2890 requirement_key: "model:base.oof->model:meta.pred".to_string(),
2891 cache_id: "prediction-cache:d9.missing-units".to_string(),
2892 format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
2893 partition: PredictionPartition::Validation,
2894 prediction_level: PredictionLevel::Target,
2895 fold_ids: vec![FoldId::new("fold:0").unwrap()],
2896 unit_ids: Vec::new(),
2897 sample_ids: Vec::new(),
2898 prediction_width: 1,
2899 target_names: vec!["y".to_string()],
2900 block_count: 1,
2901 row_count: 1,
2902 content_fingerprint: "d".repeat(64),
2903 blocks: vec![BundlePredictionBlockCacheRecord {
2904 prediction_id: Some("prediction:d9.target.fold0".to_string()),
2905 fold_id: Some(FoldId::new("fold:0").unwrap()),
2906 prediction_level: PredictionLevel::Target,
2907 row_count: 1,
2908 unit_ids: vec![PredictionUnitId::Target(TargetId::new("target:a").unwrap())],
2909 sample_ids: Vec::new(),
2910 content_fingerprint: "e".repeat(64),
2911 }],
2912 };
2913
2914 let error = cache.validate().unwrap_err().to_string();
2915 assert!(
2916 error.contains("row_count does not match unique unit ids"),
2917 "unexpected D9 missing-unit-id cache error: {error}"
2918 );
2919 }
2920
2921 #[test]
2922 fn refit_artifact_validation_checks_portable_artifact_metadata() {
2923 let plan = plan();
2924 let mut artifact = model_base_refit_artifact(&plan);
2925 artifact.artifact.backend = Some(crate::runtime::ArtifactBackend::Joblib);
2926 artifact.artifact.uri = Some("artifacts/model.joblib".to_string());
2927 artifact.artifact.content_fingerprint = Some("c".repeat(64));
2928 artifact.artifact.plugin = Some("dagml.sklearn".to_string());
2929 artifact.artifact.plugin_version = Some("1.0.0".to_string());
2930 artifact.validate().unwrap();
2931
2932 artifact.artifact.content_fingerprint = Some("short".to_string());
2933 assert!(artifact
2934 .validate()
2935 .unwrap_err()
2936 .to_string()
2937 .contains("artifact content fingerprint"));
2938 }
2939
2940 #[test]
2941 fn bundle_selections_must_match_plan_and_refit_artifacts() {
2942 let plan = plan();
2943 let artifact = model_base_refit_artifact(&plan);
2944 let valid = build_execution_bundle(
2945 BundleId::new("bundle:selected.model").unwrap(),
2946 &plan,
2947 Some(plan.variants[0].variant_id.clone()),
2948 BTreeMap::from([("model".to_string(), selected_model_base_decision())]),
2949 vec![artifact.clone()],
2950 )
2951 .unwrap();
2952 valid.validate_against_plan(&plan).unwrap();
2953
2954 assert!(build_execution_bundle(
2955 BundleId::new("bundle:selected.model.missing.artifact").unwrap(),
2956 &plan,
2957 Some(plan.variants[0].variant_id.clone()),
2958 BTreeMap::from([("model".to_string(), selected_model_base_decision())]),
2959 Vec::new(),
2960 )
2961 .is_err());
2962
2963 let mut missing_level = selected_model_base_decision();
2964 missing_level.metric_level = None;
2965 assert!(build_execution_bundle(
2966 BundleId::new("bundle:selected.missing.level").unwrap(),
2967 &plan,
2968 Some(plan.variants[0].variant_id.clone()),
2969 BTreeMap::from([("model".to_string(), missing_level)]),
2970 vec![artifact.clone()],
2971 )
2972 .is_err());
2973
2974 let mut wrong_level = selected_model_base_decision();
2975 wrong_level.metric_level = Some(crate::policy::PredictionLevel::Target);
2976 assert!(build_execution_bundle(
2977 BundleId::new("bundle:selected.wrong.level").unwrap(),
2978 &plan,
2979 Some(plan.variants[0].variant_id.clone()),
2980 BTreeMap::from([("model".to_string(), wrong_level)]),
2981 vec![artifact.clone()],
2982 )
2983 .is_err());
2984
2985 let mut unknown = selected_model_base_decision();
2986 unknown.selected_candidate_id = "model:missing".to_string();
2987 unknown.ranked_candidates[0].candidate_id = "model:missing".to_string();
2988 assert!(build_execution_bundle(
2989 BundleId::new("bundle:selected.unknown").unwrap(),
2990 &plan,
2991 Some(plan.variants[0].variant_id.clone()),
2992 BTreeMap::from([("model".to_string(), unknown)]),
2993 vec![artifact],
2994 )
2995 .is_err());
2996 }
2997
2998 #[test]
2999 fn bundle_artifact_params_follow_selected_generation_variant() {
3000 let plan = executable_dsl_plan();
3001 let selected_variant = &plan.variants[0];
3002 let node_plan = plan
3003 .node_plans
3004 .get(&NodeId::new("branch:b0.model:ridge").unwrap())
3005 .unwrap();
3006 let effective_params = selected_variant
3007 .effective_params_for_node(&node_plan.node_id, &node_plan.params)
3008 .unwrap();
3009 let effective_fingerprint = stable_json_fingerprint(&effective_params).unwrap();
3010 assert_ne!(effective_fingerprint, node_plan.params_fingerprint);
3011
3012 let artifact = RefitArtifactRecord {
3013 node_id: node_plan.node_id.clone(),
3014 controller_id: node_plan.controller_id.clone(),
3015 artifact: ArtifactRef {
3016 id: ArtifactId::new("artifact:branch:b0.model:ridge:refit").unwrap(),
3017 kind: "mock_model".to_string(),
3018 controller_id: node_plan.controller_id.clone(),
3019 backend: None,
3020 uri: None,
3021 content_fingerprint: None,
3022 size_bytes: Some(128),
3023 plugin: None,
3024 plugin_version: None,
3025 },
3026 params_fingerprint: effective_fingerprint,
3027 data_requirement_keys: vec!["branch:b0.model:ridge.x".to_string()],
3028 prediction_requirement_keys: Vec::new(),
3029 };
3030
3031 build_execution_bundle(
3032 BundleId::new("bundle:dsl.variant.params").unwrap(),
3033 &plan,
3034 Some(selected_variant.variant_id.clone()),
3035 BTreeMap::new(),
3036 vec![artifact.clone()],
3037 )
3038 .unwrap();
3039
3040 let mut stale_artifact = artifact;
3041 stale_artifact.params_fingerprint = node_plan.params_fingerprint.clone();
3042 let error = build_execution_bundle(
3043 BundleId::new("bundle:dsl.variant.params.stale").unwrap(),
3044 &plan,
3045 Some(selected_variant.variant_id.clone()),
3046 BTreeMap::new(),
3047 vec![stale_artifact],
3048 )
3049 .unwrap_err();
3050 assert!(format!("{error}").contains("artifact params"));
3051 }
3052
3053 #[test]
3054 fn branch_merge_bundle_links_selected_refits_and_fold_aligned_oof_caches() {
3055 let plan = branch_merge_plan();
3056 let b0_requirement = branch_merge_requirement("branch:b0.model:ridge", "b0_oof");
3057 let b1_requirement = branch_merge_requirement("branch:b1.model:rf", "b1_oof");
3058 let b0_cache = build_prediction_cache_record(
3059 &b0_requirement,
3060 &branch_merge_prediction_blocks("branch:b0.model:ridge", 0.0),
3061 )
3062 .unwrap();
3063 let b1_cache = build_prediction_cache_record(
3064 &b1_requirement,
3065 &branch_merge_prediction_blocks("branch:b1.model:rf", 1.0),
3066 )
3067 .unwrap();
3068 let b0_artifact = refit_artifact(
3069 &plan,
3070 "branch:b0.model:ridge",
3071 vec!["branch:b0.model:ridge.x".to_string()],
3072 Vec::new(),
3073 );
3074 let b1_artifact = refit_artifact(
3075 &plan,
3076 "branch:b1.model:rf",
3077 vec!["branch:b1.model:rf.x".to_string()],
3078 Vec::new(),
3079 );
3080 let merge_artifact = refit_artifact(
3081 &plan,
3082 "merge:stack.pred_plus_original.meta:ridge",
3083 vec!["merge:stack.pred_plus_original.meta:ridge.x_original".to_string()],
3084 vec![b0_requirement.key(), b1_requirement.key()],
3085 );
3086
3087 let bundle = build_execution_bundle_with_prediction_contracts(
3088 BundleId::new("bundle:branch.merge.selected.refit").unwrap(),
3089 &plan,
3090 Some(plan.variants[0].variant_id.clone()),
3091 branch_merge_selection_decisions(),
3092 vec![
3093 b0_artifact.clone(),
3094 b1_artifact.clone(),
3095 merge_artifact.clone(),
3096 ],
3097 vec![b0_requirement.clone(), b1_requirement.clone()],
3098 vec![b0_cache.clone(), b1_cache.clone()],
3099 )
3100 .unwrap();
3101 bundle.validate_against_plan(&plan).unwrap();
3102 assert_eq!(bundle.selections.len(), 3);
3103 assert_eq!(bundle.prediction_requirements.len(), 2);
3104 assert_eq!(
3105 bundle.refit_artifacts[2].data_requirement_keys,
3106 vec!["merge:stack.pred_plus_original.meta:ridge.x_original"]
3107 );
3108 assert_eq!(
3109 bundle.refit_artifacts[2].prediction_requirement_keys,
3110 vec![
3111 "branch:b0.model:ridge.oof->merge:stack.pred_plus_original.meta:ridge.b0_oof",
3112 "branch:b1.model:rf.oof->merge:stack.pred_plus_original.meta:ridge.b1_oof",
3113 ]
3114 );
3115
3116 assert!(build_execution_bundle_with_prediction_contracts(
3117 BundleId::new("bundle:branch.merge.missing.branch.refit").unwrap(),
3118 &plan,
3119 Some(plan.variants[0].variant_id.clone()),
3120 branch_merge_selection_decisions(),
3121 vec![b0_artifact.clone(), merge_artifact.clone()],
3122 vec![b0_requirement.clone(), b1_requirement.clone()],
3123 vec![b0_cache.clone(), b1_cache.clone()],
3124 )
3125 .is_err());
3126
3127 let mut misaligned_cache = b0_cache;
3128 misaligned_cache.blocks[0].sample_ids = vec![
3129 SampleId::new("sample:1").unwrap(),
3130 SampleId::new("sample:3").unwrap(),
3131 ];
3132 misaligned_cache.blocks[1].sample_ids = vec![
3133 SampleId::new("sample:2").unwrap(),
3134 SampleId::new("sample:4").unwrap(),
3135 ];
3136 let error = build_execution_bundle_with_prediction_contracts(
3137 BundleId::new("bundle:branch.merge.misaligned.oof.cache").unwrap(),
3138 &plan,
3139 Some(plan.variants[0].variant_id.clone()),
3140 branch_merge_selection_decisions(),
3141 vec![b0_artifact, b1_artifact, merge_artifact],
3142 vec![b0_requirement, b1_requirement],
3143 vec![misaligned_cache, b1_cache],
3144 )
3145 .unwrap_err()
3146 .to_string();
3147 assert!(
3148 error.contains("does not match validation samples"),
3149 "unexpected fold-alignment error: {error}"
3150 );
3151 }
3152
3153 #[test]
3158 fn separation_concat_merge_bundle_assembles_and_is_scored() {
3159 let plan = separation_concat_merge_plan();
3160 let a_requirement = separation_branch_requirement(
3163 "branch:site__A.model:pls",
3164 &["sample:1", "sample:3"],
3165 &["fold:0", "fold:1"],
3166 );
3167 let b_requirement = separation_branch_requirement(
3168 "branch:site__B.model:pls",
3169 &["sample:2", "sample:4"],
3170 &["fold:0", "fold:1"],
3171 );
3172 let a_cache = build_prediction_cache_record(
3173 &a_requirement,
3174 &separation_branch_blocks("branch:site__A.model:pls", "sample:1", "sample:3", 0.0),
3175 )
3176 .unwrap();
3177 let b_cache = build_prediction_cache_record(
3178 &b_requirement,
3179 &separation_branch_blocks("branch:site__B.model:pls", "sample:2", "sample:4", 1.0),
3180 )
3181 .unwrap();
3182 let a_artifact = refit_artifact(
3183 &plan,
3184 "branch:site__A.model:pls",
3185 vec!["branch:site__A.model:pls.x".to_string()],
3186 Vec::new(),
3187 );
3188 let b_artifact = refit_artifact(
3189 &plan,
3190 "branch:site__B.model:pls",
3191 vec!["branch:site__B.model:pls.x".to_string()],
3192 Vec::new(),
3193 );
3194
3195 let mut bundle = build_execution_bundle_with_prediction_contracts(
3196 BundleId::new("bundle:separation.concat.merge").unwrap(),
3197 &plan,
3198 Some(plan.variants[0].variant_id.clone()),
3199 BTreeMap::new(),
3200 vec![a_artifact, b_artifact],
3201 vec![a_requirement, b_requirement],
3202 vec![a_cache, b_cache],
3203 )
3204 .expect("separation-branch concat-merge bundle must assemble");
3205
3206 bundle
3210 .validate_against_plan(&plan)
3211 .expect("partition-covering branch inputs must validate as a group");
3212 assert_eq!(bundle.prediction_requirements.len(), 2);
3213
3214 let scores = ScoreSet {
3218 schema_version: crate::metrics::SCORE_SET_SCHEMA_VERSION,
3219 plan_id: plan.id.clone(),
3220 selection_metric: Some("rmse".to_string()),
3221 reports: vec![crate::metrics::RegressionMetricReport {
3222 prediction_id: Some("prediction:merge:sites:avg".to_string()),
3223 producer_node: NodeId::new("merge:sites").unwrap(),
3224 variant_id: Some(plan.variants[0].variant_id.clone()),
3225 variant_label: None,
3226 partition: PredictionPartition::Validation,
3227 fold_id: Some(FoldId::new("avg").unwrap()),
3228 level: PredictionLevel::Sample,
3229 row_count: 4,
3230 target_width: 1,
3231 target_names: vec!["y".to_string()],
3232 metrics: BTreeMap::from([("rmse".to_string(), 1.5)]),
3233 }],
3234 };
3235 bundle.scores = Some(scores);
3236 bundle
3237 .validate_against_plan(&plan)
3238 .expect("bundle with merge-producer scores must validate");
3239 let cv_best = bundle
3240 .scores
3241 .as_ref()
3242 .unwrap()
3243 .reports
3244 .iter()
3245 .find(|report| {
3246 report.producer_node.as_str() == "merge:sites"
3247 && report.fold_id.as_ref().map(FoldId::as_str) == Some("avg")
3248 })
3249 .expect("merge producer must have a cross-fold (avg) score");
3250 assert_eq!(cv_best.metrics.get("rmse"), Some(&1.5));
3251 }
3252
3253 #[test]
3257 fn separation_concat_merge_rejects_overlapping_partitions() {
3258 let plan = separation_concat_merge_plan();
3259 let a_requirement = separation_branch_requirement(
3262 "branch:site__A.model:pls",
3263 &["sample:1", "sample:3"],
3264 &["fold:0", "fold:1"],
3265 );
3266 let b_requirement = separation_branch_requirement(
3267 "branch:site__B.model:pls",
3268 &["sample:2", "sample:3"],
3269 &["fold:0", "fold:1"],
3270 );
3271 let a_cache = build_prediction_cache_record(
3272 &a_requirement,
3273 &separation_branch_blocks("branch:site__A.model:pls", "sample:1", "sample:3", 0.0),
3274 )
3275 .unwrap();
3276 let b_cache = build_prediction_cache_record(
3277 &b_requirement,
3278 &separation_branch_blocks("branch:site__B.model:pls", "sample:2", "sample:3", 1.0),
3279 )
3280 .unwrap();
3281 let a_artifact = refit_artifact(
3282 &plan,
3283 "branch:site__A.model:pls",
3284 vec!["branch:site__A.model:pls.x".to_string()],
3285 Vec::new(),
3286 );
3287 let b_artifact = refit_artifact(
3288 &plan,
3289 "branch:site__B.model:pls",
3290 vec!["branch:site__B.model:pls.x".to_string()],
3291 Vec::new(),
3292 );
3293
3294 let error = build_execution_bundle_with_prediction_contracts(
3295 BundleId::new("bundle:separation.concat.merge.overlap").unwrap(),
3296 &plan,
3297 Some(plan.variants[0].variant_id.clone()),
3298 BTreeMap::new(),
3299 vec![a_artifact, b_artifact],
3300 vec![a_requirement, b_requirement],
3301 vec![a_cache, b_cache],
3302 )
3303 .unwrap_err()
3304 .to_string();
3305 assert!(
3306 error.contains("overlapping branch predictions"),
3307 "overlap must be rejected, got: {error}"
3308 );
3309 }
3310
3311 #[test]
3315 fn separation_concat_merge_rejects_incomplete_coverage() {
3316 let plan = separation_concat_merge_plan();
3317 let a_requirement =
3320 separation_branch_requirement("branch:site__A.model:pls", &["sample:1"], &["fold:0"]);
3321 let b_requirement = separation_branch_requirement(
3322 "branch:site__B.model:pls",
3323 &["sample:2", "sample:4"],
3324 &["fold:0", "fold:1"],
3325 );
3326 let a_cache = build_prediction_cache_record(
3327 &a_requirement,
3328 &[PredictionBlock {
3329 prediction_id: Some("prediction:a:fold0".to_string()),
3330 producer_node: NodeId::new("branch:site__A.model:pls").unwrap(),
3331 partition: PredictionPartition::Validation,
3332 fold_id: Some(FoldId::new("fold:0").unwrap()),
3333 sample_ids: vec![SampleId::new("sample:1").unwrap()],
3334 values: vec![vec![0.1]],
3335 target_names: vec!["y".to_string()],
3336 }],
3337 )
3338 .unwrap();
3339 let b_cache = build_prediction_cache_record(
3340 &b_requirement,
3341 &separation_branch_blocks("branch:site__B.model:pls", "sample:2", "sample:4", 1.0),
3342 )
3343 .unwrap();
3344 let a_artifact = refit_artifact(
3345 &plan,
3346 "branch:site__A.model:pls",
3347 vec!["branch:site__A.model:pls.x".to_string()],
3348 Vec::new(),
3349 );
3350 let b_artifact = refit_artifact(
3351 &plan,
3352 "branch:site__B.model:pls",
3353 vec!["branch:site__B.model:pls.x".to_string()],
3354 Vec::new(),
3355 );
3356
3357 let error = build_execution_bundle_with_prediction_contracts(
3358 BundleId::new("bundle:separation.concat.merge.gap").unwrap(),
3359 &plan,
3360 Some(plan.variants[0].variant_id.clone()),
3361 BTreeMap::new(),
3362 vec![a_artifact, b_artifact],
3363 vec![a_requirement, b_requirement],
3364 vec![a_cache, b_cache],
3365 )
3366 .unwrap_err()
3367 .to_string();
3368 assert!(
3369 error.contains("do not cover"),
3370 "an OOF gap must be rejected, got: {error}"
3371 );
3372 }
3373
3374 #[test]
3379 fn separation_concat_merge_rejects_missing_branch_edge() {
3380 let plan = separation_concat_merge_plan();
3381 let a_requirement = separation_branch_requirement(
3385 "branch:site__A.model:pls",
3386 &["sample:1", "sample:2", "sample:3", "sample:4"],
3387 &["fold:0", "fold:1"],
3388 );
3389 let a_artifact = refit_artifact(
3390 &plan,
3391 "branch:site__A.model:pls",
3392 vec!["branch:site__A.model:pls.x".to_string()],
3393 Vec::new(),
3394 );
3395 let b_artifact = refit_artifact(
3396 &plan,
3397 "branch:site__B.model:pls",
3398 vec!["branch:site__B.model:pls.x".to_string()],
3399 Vec::new(),
3400 );
3401
3402 let error = build_execution_bundle_with_prediction_contracts(
3403 BundleId::new("bundle:separation.concat.merge.missing.branch").unwrap(),
3404 &plan,
3405 Some(plan.variants[0].variant_id.clone()),
3406 BTreeMap::new(),
3407 vec![a_artifact, b_artifact],
3408 vec![a_requirement],
3409 Vec::new(),
3410 )
3411 .unwrap_err()
3412 .to_string();
3413 assert!(
3414 error.contains("do not match the plan's incoming OOF edges"),
3415 "a missing branch edge must be rejected, got: {error}"
3416 );
3417 }
3418
3419 #[test]
3424 fn separation_concat_merge_rejects_partial_cache_coverage() {
3425 let plan = separation_concat_merge_plan();
3426 let a_requirement = separation_branch_requirement(
3427 "branch:site__A.model:pls",
3428 &["sample:1", "sample:3"],
3429 &["fold:0", "fold:1"],
3430 );
3431 let b_requirement = separation_branch_requirement(
3432 "branch:site__B.model:pls",
3433 &["sample:2", "sample:4"],
3434 &["fold:0", "fold:1"],
3435 );
3436 let a_cache = build_prediction_cache_record(
3438 &a_requirement,
3439 &separation_branch_blocks("branch:site__A.model:pls", "sample:1", "sample:3", 0.0),
3440 )
3441 .unwrap();
3442 let a_artifact = refit_artifact(
3443 &plan,
3444 "branch:site__A.model:pls",
3445 vec!["branch:site__A.model:pls.x".to_string()],
3446 Vec::new(),
3447 );
3448 let b_artifact = refit_artifact(
3449 &plan,
3450 "branch:site__B.model:pls",
3451 vec!["branch:site__B.model:pls.x".to_string()],
3452 Vec::new(),
3453 );
3454
3455 let error = build_execution_bundle_with_prediction_contracts(
3456 BundleId::new("bundle:separation.concat.merge.partial.cache").unwrap(),
3457 &plan,
3458 Some(plan.variants[0].variant_id.clone()),
3459 BTreeMap::new(),
3460 vec![a_artifact, b_artifact],
3461 vec![a_requirement, b_requirement],
3462 vec![a_cache],
3463 )
3464 .unwrap_err()
3465 .to_string();
3466 assert!(
3467 error.contains("partial prediction-cache coverage"),
3468 "a partial-cache concat group must be rejected, got: {error}"
3469 );
3470 }
3471
3472 #[test]
3473 fn prediction_requirements_are_typed_and_validate_against_oof_edges() {
3474 let plan = branch_merge_plan();
3475 let meta_plan = plan
3476 .node_plans
3477 .get(&NodeId::new("merge:stack.pred_plus_original.meta:ridge").unwrap())
3478 .unwrap();
3479 let producer_node = NodeId::new("branch:b0.model:ridge").unwrap();
3480 let fold0 = FoldId::new("fold:0").unwrap();
3481 let fold1 = FoldId::new("fold:1").unwrap();
3482 let samples = [
3483 SampleId::new("sample:1").unwrap(),
3484 SampleId::new("sample:2").unwrap(),
3485 SampleId::new("sample:3").unwrap(),
3486 SampleId::new("sample:4").unwrap(),
3487 ];
3488 let requirement = BundlePredictionRequirement {
3489 producer_node: producer_node.clone(),
3490 source_port: "oof".to_string(),
3491 consumer_node: meta_plan.node_id.clone(),
3492 target_port: "b0_oof".to_string(),
3493 partition: PredictionPartition::Validation,
3494 prediction_level: PredictionLevel::Sample,
3495 fold_ids: vec![fold0.clone(), fold1.clone()],
3496 unit_ids: Vec::new(),
3497 sample_ids: samples.to_vec(),
3498 prediction_width: 1,
3499 target_names: vec!["y".to_string()],
3500 };
3501 let prediction_blocks = vec![
3502 PredictionBlock {
3503 prediction_id: Some("prediction:branch:b0.fold0".to_string()),
3504 producer_node: producer_node.clone(),
3505 partition: PredictionPartition::Validation,
3506 fold_id: Some(fold0),
3507 sample_ids: samples[0..2].to_vec(),
3508 values: vec![vec![0.1], vec![0.2]],
3509 target_names: vec!["y".to_string()],
3510 },
3511 PredictionBlock {
3512 prediction_id: Some("prediction:branch:b0.fold1".to_string()),
3513 producer_node: producer_node.clone(),
3514 partition: PredictionPartition::Validation,
3515 fold_id: Some(fold1),
3516 sample_ids: samples[2..4].to_vec(),
3517 values: vec![vec![0.3], vec![0.4]],
3518 target_names: vec!["y".to_string()],
3519 },
3520 ];
3521 let cache = build_prediction_cache_record(&requirement, &prediction_blocks).unwrap();
3522 let payload = build_prediction_cache_payload(&requirement, &prediction_blocks).unwrap();
3523 assert_eq!(cache.prediction_level, PredictionLevel::Sample);
3524 assert_eq!(payload.prediction_level, PredictionLevel::Sample);
3525 assert!(cache
3526 .blocks
3527 .iter()
3528 .all(|block| block.prediction_level == PredictionLevel::Sample));
3529 validate_prediction_cache_payload_matches_record(&payload, &cache).unwrap();
3530 let mut wrong_level_requirement = requirement.clone();
3531 wrong_level_requirement.prediction_level = PredictionLevel::Target;
3532 assert!(wrong_level_requirement.validate().is_err());
3533 let mut wrong_level_cache = cache.clone();
3534 wrong_level_cache.prediction_level = PredictionLevel::Target;
3535 assert!(wrong_level_cache.validate().is_err());
3536 let mut wrong_level_payload = payload.clone();
3537 wrong_level_payload.prediction_level = PredictionLevel::Target;
3538 assert!(wrong_level_payload.validate().is_err());
3539 let prediction_key = requirement.key();
3540 let artifact = RefitArtifactRecord {
3541 node_id: meta_plan.node_id.clone(),
3542 controller_id: meta_plan.controller_id.clone(),
3543 artifact: ArtifactRef {
3544 id: ArtifactId::new("artifact:merge:stack.pred_plus_original.meta:ridge:refit")
3545 .unwrap(),
3546 kind: "mock_model".to_string(),
3547 controller_id: meta_plan.controller_id.clone(),
3548 backend: None,
3549 uri: None,
3550 content_fingerprint: None,
3551 size_bytes: Some(128),
3552 plugin: None,
3553 plugin_version: None,
3554 },
3555 params_fingerprint: meta_plan.params_fingerprint.clone(),
3556 data_requirement_keys: vec![
3557 "merge:stack.pred_plus_original.meta:ridge.x_original".to_string()
3558 ],
3559 prediction_requirement_keys: vec![prediction_key],
3560 };
3561
3562 assert!(build_execution_bundle(
3563 BundleId::new("bundle:missing.prediction.requirement").unwrap(),
3564 &plan,
3565 Some(plan.variants[0].variant_id.clone()),
3566 BTreeMap::new(),
3567 vec![artifact.clone()],
3568 )
3569 .is_err());
3570
3571 assert!(build_execution_bundle_with_prediction_requirements(
3572 BundleId::new("bundle:typed.prediction.requirement.without.cache").unwrap(),
3573 &plan,
3574 Some(plan.variants[0].variant_id.clone()),
3575 BTreeMap::new(),
3576 vec![artifact.clone()],
3577 vec![requirement.clone()],
3578 )
3579 .is_err());
3580
3581 let bundle = build_execution_bundle_with_prediction_contracts(
3582 BundleId::new("bundle:typed.prediction.requirement").unwrap(),
3583 &plan,
3584 Some(plan.variants[0].variant_id.clone()),
3585 BTreeMap::new(),
3586 vec![artifact],
3587 vec![requirement],
3588 vec![cache],
3589 )
3590 .unwrap();
3591 bundle.validate_against_plan(&plan).unwrap();
3592 assert_eq!(bundle.prediction_requirements.len(), 1);
3593 assert_eq!(bundle.prediction_caches.len(), 1);
3594 assert_eq!(
3595 bundle.refit_artifacts[0].prediction_requirement_keys,
3596 vec!["branch:b0.model:ridge.oof->merge:stack.pred_plus_original.meta:ridge.b0_oof"]
3597 );
3598 let payload_set = BundlePredictionCachePayloadSet {
3599 bundle_id: bundle.bundle_id.clone(),
3600 schema_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
3601 caches: vec![payload],
3602 };
3603 payload_set.validate_against_bundle(&bundle).unwrap();
3604 let refit_replay_request = ReplayPhaseRequest {
3605 bundle_id: bundle.bundle_id.clone(),
3606 phase: Phase::Refit,
3607 data_envelope_keys: bundle
3608 .data_requirements
3609 .iter()
3610 .map(BundleDataRequirement::key)
3611 .collect(),
3612 };
3613 refit_replay_request
3614 .validate_for_bundle_with_prediction_cache_payloads(&bundle, Some(&payload_set))
3615 .unwrap();
3616 let mut tampered_payload_set = payload_set.clone();
3617 tampered_payload_set.caches[0].blocks[0].values[0][0] = 99.0;
3618 assert!(tampered_payload_set
3619 .validate_against_bundle(&bundle)
3620 .is_err());
3621 let mut missing_payload_set = payload_set.clone();
3622 missing_payload_set.caches.clear();
3623 assert!(missing_payload_set
3624 .validate_against_bundle(&bundle)
3625 .is_err());
3626 assert!(refit_replay_request.validate_for_bundle(&bundle).is_err());
3627
3628 let mut wrong_data_owner = bundle.clone();
3629 wrong_data_owner.refit_artifacts[0].data_requirement_keys =
3630 vec!["branch:b0.model:ridge.x".to_string()];
3631 assert!(wrong_data_owner.validate().is_err());
3632
3633 let mut wrong_prediction_consumer = bundle;
3634 wrong_prediction_consumer.refit_artifacts[0].node_id =
3635 NodeId::new("branch:b0.model:ridge").unwrap();
3636 wrong_prediction_consumer.refit_artifacts[0]
3637 .data_requirement_keys
3638 .clear();
3639 assert!(wrong_prediction_consumer.validate().is_err());
3640 }
3641
3642 #[test]
3643 fn aggregated_prediction_cache_contracts_preserve_unit_ids() {
3644 let plan = branch_merge_plan();
3645 let producer_node = NodeId::new("branch:b0.model:ridge").unwrap();
3646 let consumer_node = NodeId::new("merge:stack.pred_plus_original.meta:ridge").unwrap();
3647 let fold0 = FoldId::new("fold:0").unwrap();
3648 let fold1 = FoldId::new("fold:1").unwrap();
3649 let target_a = PredictionUnitId::Target(TargetId::new("target:a").unwrap());
3650 let target_b = PredictionUnitId::Target(TargetId::new("target:b").unwrap());
3651 let requirement = BundlePredictionRequirement {
3652 producer_node: producer_node.clone(),
3653 source_port: "oof".to_string(),
3654 consumer_node: consumer_node.clone(),
3655 target_port: "b0_oof".to_string(),
3656 partition: PredictionPartition::Validation,
3657 prediction_level: PredictionLevel::Target,
3658 fold_ids: vec![fold0.clone(), fold1.clone()],
3659 unit_ids: vec![target_a.clone(), target_b.clone()],
3660 sample_ids: Vec::new(),
3661 prediction_width: 1,
3662 target_names: vec!["y".to_string()],
3663 };
3664 let aggregated_blocks = vec![
3665 AggregatedPredictionBlock {
3666 prediction_id: Some("prediction:branch:b0.target.fold0".to_string()),
3667 producer_node: producer_node.clone(),
3668 partition: PredictionPartition::Validation,
3669 fold_id: Some(fold0),
3670 level: PredictionLevel::Target,
3671 unit_ids: vec![target_a],
3672 values: vec![vec![0.15]],
3673 target_names: vec!["y".to_string()],
3674 },
3675 AggregatedPredictionBlock {
3676 prediction_id: Some("prediction:branch:b0.target.fold1".to_string()),
3677 producer_node,
3678 partition: PredictionPartition::Validation,
3679 fold_id: Some(fold1),
3680 level: PredictionLevel::Target,
3681 unit_ids: vec![target_b],
3682 values: vec![vec![0.35]],
3683 target_names: vec!["y".to_string()],
3684 },
3685 ];
3686
3687 let cache =
3688 build_aggregated_prediction_cache_record(&requirement, &aggregated_blocks).unwrap();
3689 let payload =
3690 build_aggregated_prediction_cache_payload(&requirement, &aggregated_blocks).unwrap();
3691 assert_eq!(cache.prediction_level, PredictionLevel::Target);
3692 assert_eq!(cache.unit_ids, requirement.unit_ids);
3693 assert!(cache.sample_ids.is_empty());
3694 assert!(payload.blocks.is_empty());
3695 assert_eq!(payload.aggregated_blocks.len(), 2);
3696 validate_prediction_cache_payload_matches_record(&payload, &cache).unwrap();
3697
3698 let artifact = refit_artifact(
3699 &plan,
3700 "merge:stack.pred_plus_original.meta:ridge",
3701 vec!["merge:stack.pred_plus_original.meta:ridge.x_original".to_string()],
3702 vec![requirement.key()],
3703 );
3704 let bundle = build_execution_bundle_with_prediction_contracts(
3705 BundleId::new("bundle:target.prediction.requirement").unwrap(),
3706 &plan,
3707 Some(plan.variants[0].variant_id.clone()),
3708 BTreeMap::new(),
3709 vec![artifact],
3710 vec![requirement],
3711 vec![cache],
3712 )
3713 .unwrap();
3714 bundle.validate_against_plan(&plan).unwrap();
3715
3716 let mut tampered_payload = payload;
3717 tampered_payload.aggregated_blocks[0].unit_ids =
3718 vec![PredictionUnitId::Target(TargetId::new("target:z").unwrap())];
3719 assert!(validate_prediction_cache_payload_matches_record(
3720 &tampered_payload,
3721 &bundle.prediction_caches[0]
3722 )
3723 .is_err());
3724 }
3725
3726 #[test]
3727 fn replay_envelopes_must_match_bundle_requirements() {
3728 let plan = plan();
3729 let bundle = build_execution_bundle(
3730 BundleId::new("bundle:demo").unwrap(),
3731 &plan,
3732 None,
3733 BTreeMap::new(),
3734 Vec::new(),
3735 )
3736 .unwrap();
3737 let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
3738 "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
3739 ))
3740 .unwrap();
3741
3742 bundle
3743 .validate_replay_envelopes(&BTreeMap::from([(
3744 "model:base.x".to_string(),
3745 envelope.clone(),
3746 )]))
3747 .unwrap();
3748
3749 let mut mismatched = envelope;
3750 mismatched.schema_fingerprint = "0".repeat(64);
3751 assert!(bundle
3752 .validate_replay_envelopes(&BTreeMap::from([("model:base.x".to_string(), mismatched,)]))
3753 .is_err());
3754 }
3755
3756 #[test]
3757 fn rejects_unsupported_bundle_schema_version() {
3758 let mut bundle = build_execution_bundle(
3759 BundleId::new("bundle:demo").unwrap(),
3760 &plan(),
3761 None,
3762 BTreeMap::new(),
3763 Vec::new(),
3764 )
3765 .unwrap();
3766 bundle.schema_version = EXECUTION_BUNDLE_SCHEMA_VERSION + 1;
3767
3768 assert!(bundle.validate().is_err());
3769
3770 bundle.schema_version = 0;
3771 assert!(bundle.validate().is_err());
3772 }
3773
3774 #[test]
3775 fn rejects_bundle_with_scores_plan_id_mismatch() {
3776 let plan = plan();
3777 let mut bundle = build_execution_bundle(
3778 BundleId::new("bundle:demo").unwrap(),
3779 &plan,
3780 None,
3781 BTreeMap::new(),
3782 Vec::new(),
3783 )
3784 .unwrap();
3785 bundle.scores = Some(ScoreSet {
3786 schema_version: crate::metrics::SCORE_SET_SCHEMA_VERSION,
3787 plan_id: bundle.plan_id.clone(),
3788 selection_metric: Some("rmse".to_string()),
3789 reports: vec![crate::metrics::RegressionMetricReport {
3790 prediction_id: None,
3791 producer_node: NodeId::new("model:compat.0").unwrap(),
3792 variant_id: None,
3793 variant_label: None,
3794 partition: PredictionPartition::Test,
3795 fold_id: Some(FoldId::new("final").unwrap()),
3796 level: PredictionLevel::Sample,
3797 row_count: 4,
3798 target_width: 1,
3799 target_names: vec!["y".to_string()],
3800 metrics: BTreeMap::from([("rmse".to_string(), 1.0)]),
3801 }],
3802 });
3803 bundle.validate().unwrap();
3805 bundle.scores.as_mut().unwrap().plan_id = "plan:wrong".to_string();
3807 let err = bundle.validate().unwrap_err().to_string();
3808 assert!(
3809 err.contains("does not match its embedded scores plan_id"),
3810 "{err}"
3811 );
3812 }
3813
3814 #[test]
3815 fn schema_migration_policy_is_explicit_and_refuses_implicit_migrations() {
3816 let bundle_policy = execution_bundle_schema_migration_policy();
3817 assert_eq!(
3818 bundle_policy.current_version,
3819 EXECUTION_BUNDLE_SCHEMA_VERSION
3820 );
3821 assert_eq!(
3822 bundle_policy.min_readable_version,
3823 MIN_READABLE_EXECUTION_BUNDLE_SCHEMA_VERSION
3824 );
3825 assert!(bundle_policy.automatic_migrations.is_empty());
3826 bundle_policy
3827 .validate_read_version(EXECUTION_BUNDLE_SCHEMA_VERSION, "bundle `current`")
3828 .unwrap();
3829 assert!(bundle_policy
3830 .validate_read_version(EXECUTION_BUNDLE_SCHEMA_VERSION + 1, "bundle `future`")
3831 .is_err());
3832 assert!(bundle_policy
3833 .validate_read_version(0, "bundle `zero`")
3834 .is_err());
3835
3836 let mut future_policy = SchemaMigrationPolicy {
3837 artifact: "execution_bundle".to_string(),
3838 current_version: 2,
3839 min_readable_version: 1,
3840 min_writable_version: 2,
3841 automatic_migrations: BTreeMap::new(),
3842 };
3843 assert!(future_policy
3844 .validate_read_version(1, "bundle `old-without-migration`")
3845 .is_err());
3846 future_policy.automatic_migrations.insert(1, 2);
3847 future_policy
3848 .validate_read_version(1, "bundle `old-with-migration`")
3849 .unwrap();
3850 }
3851
3852 #[test]
3853 fn prediction_cache_payload_schema_policy_rejects_unsupported_versions() {
3854 let policy = prediction_cache_payload_schema_migration_policy();
3855 assert_eq!(
3856 policy.current_version,
3857 PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION
3858 );
3859 assert!(policy.automatic_migrations.is_empty());
3860
3861 let mut payload_set = BundlePredictionCachePayloadSet {
3862 bundle_id: BundleId::new("bundle:payload.schema").unwrap(),
3863 schema_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
3864 caches: Vec::new(),
3865 };
3866 payload_set.validate().unwrap();
3867
3868 payload_set.schema_version = PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION + 1;
3869 assert!(payload_set.validate().is_err());
3870
3871 payload_set.schema_version = 0;
3872 assert!(payload_set.validate().is_err());
3873 }
3874
3875 #[test]
3876 fn replay_request_requires_predict_explain_or_refit_phase() {
3877 let bundle = build_execution_bundle(
3878 BundleId::new("bundle:demo").unwrap(),
3879 &plan(),
3880 None,
3881 BTreeMap::new(),
3882 Vec::new(),
3883 )
3884 .unwrap();
3885
3886 ReplayPhaseRequest {
3887 bundle_id: bundle.bundle_id.clone(),
3888 phase: Phase::Predict,
3889 data_envelope_keys: vec!["model:base.x".to_string()],
3890 }
3891 .validate_for_bundle(&bundle)
3892 .unwrap();
3893 ReplayPhaseRequest {
3894 bundle_id: bundle.bundle_id.clone(),
3895 phase: Phase::Refit,
3896 data_envelope_keys: vec!["model:base.x".to_string()],
3897 }
3898 .validate_for_bundle(&bundle)
3899 .unwrap();
3900 assert!(ReplayPhaseRequest {
3901 bundle_id: bundle.bundle_id.clone(),
3902 phase: Phase::FitCv,
3903 data_envelope_keys: vec!["model:base.x".to_string()],
3904 }
3905 .validate_for_bundle(&bundle)
3906 .is_err());
3907 assert!(ReplayPhaseRequest {
3908 bundle_id: bundle.bundle_id.clone(),
3909 phase: Phase::Predict,
3910 data_envelope_keys: vec!["model:base.x".to_string(), "model:base.x".to_string()],
3911 }
3912 .validate_for_bundle(&bundle)
3913 .is_err());
3914 assert!(ReplayPhaseRequest {
3915 bundle_id: bundle.bundle_id.clone(),
3916 phase: Phase::Predict,
3917 data_envelope_keys: vec!["model:base.y".to_string()],
3918 }
3919 .validate_for_bundle(&bundle)
3920 .is_err());
3921 }
3922}