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