1use super::*;
3
4#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum HandleKind {
7 Data,
8 DataView,
9 Model,
10 Artifact,
11 Prediction,
12 Relation,
13}
14
15#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct HandleRef {
18 pub handle: u64,
19 pub kind: HandleKind,
20 pub owner_controller: ControllerId,
21}
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ArtifactBackend {
26 Joblib,
27 Torch,
28 Tensorflow,
29 Onnx,
30 Safetensors,
31 Json,
32 Raw,
33}
34
35#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
36pub struct ArtifactRef {
37 pub id: ArtifactId,
38 pub kind: String,
39 pub controller_id: ControllerId,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub backend: Option<ArtifactBackend>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub uri: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub content_fingerprint: Option<String>,
46 pub size_bytes: Option<u64>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub plugin: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub plugin_version: Option<String>,
51}
52
53impl ArtifactRef {
54 pub fn validate(&self) -> Result<()> {
55 if self.kind.trim().is_empty() {
56 return Err(DagMlError::RuntimeValidation(format!(
57 "artifact `{}` has empty kind",
58 self.id
59 )));
60 }
61 validate_artifact_optional_text("uri", &self.uri, &self.id)?;
62 validate_artifact_optional_text("plugin", &self.plugin, &self.id)?;
63 validate_artifact_optional_text("plugin_version", &self.plugin_version, &self.id)?;
64 if self.plugin_version.is_some() && self.plugin.is_none() {
65 return Err(DagMlError::RuntimeValidation(format!(
66 "artifact `{}` has plugin_version without plugin",
67 self.id
68 )));
69 }
70 if let Some(content_fingerprint) = &self.content_fingerprint {
71 validate_runtime_fingerprint("artifact content", content_fingerprint)?;
72 }
73 if self.uri.is_some() && self.backend.is_none() {
74 return Err(DagMlError::RuntimeValidation(format!(
75 "artifact `{}` has uri without backend",
76 self.id
77 )));
78 }
79 if self.uri.is_some() && self.content_fingerprint.is_none() {
80 return Err(DagMlError::RuntimeValidation(format!(
81 "artifact `{}` has uri without content_fingerprint",
82 self.id
83 )));
84 }
85 Ok(())
86 }
87
88 pub fn validate_portable(&self) -> Result<()> {
93 self.validate()?;
94 let Some(uri) = self.uri.as_deref() else {
95 return Err(DagMlError::RuntimeValidation(format!(
96 "artifact `{}` is not portable: requires backend, uri and content_fingerprint",
97 self.id
98 )));
99 };
100 validate_relative_artifact_uri(&self.id, uri)
103 }
104}
105
106pub fn refit_artifact_input_key(artifact_id: &ArtifactId) -> String {
107 format!("artifact:{artifact_id}")
108}
109
110#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
111pub struct ArtifactMaterializationRequest {
112 pub run_id: RunId,
113 pub bundle_id: BundleId,
114 pub node_id: NodeId,
115 pub phase: Phase,
116 pub variant_id: Option<VariantId>,
117 pub controller_id: ControllerId,
118 pub artifact: ArtifactRef,
119 pub params_fingerprint: String,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub training_loss_fingerprint: Option<String>,
122}
123
124#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
125pub struct ArtifactHandleRecord {
126 pub handle: HandleRef,
127 pub node_id: NodeId,
128 pub controller_id: ControllerId,
129 pub artifact: ArtifactRef,
130 pub params_fingerprint: String,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub training_loss_fingerprint: Option<String>,
133}
134
135impl ArtifactHandleRecord {
136 pub fn validate(&self) -> Result<()> {
137 self.artifact.validate()?;
138 if !matches!(self.handle.kind, HandleKind::Model | HandleKind::Artifact) {
139 return Err(DagMlError::RuntimeValidation(format!(
140 "artifact `{}` is registered with non-artifact/model handle kind {:?}",
141 self.artifact.id, self.handle.kind
142 )));
143 }
144 if self.handle.owner_controller != self.controller_id {
145 return Err(DagMlError::RuntimeValidation(format!(
146 "artifact `{}` handle owner `{}` does not match controller `{}`",
147 self.artifact.id, self.handle.owner_controller, self.controller_id
148 )));
149 }
150 if self.artifact.controller_id != self.controller_id {
151 return Err(DagMlError::RuntimeValidation(format!(
152 "artifact `{}` controller `{}` does not match record controller `{}`",
153 self.artifact.id, self.artifact.controller_id, self.controller_id
154 )));
155 }
156 if self.params_fingerprint.trim().is_empty() {
157 return Err(DagMlError::RuntimeValidation(format!(
158 "artifact `{}` has empty params fingerprint",
159 self.artifact.id
160 )));
161 }
162 if let Some(fingerprint) = &self.training_loss_fingerprint {
163 validate_runtime_fingerprint("artifact training loss", fingerprint)?;
164 }
165 Ok(())
166 }
167}
168
169pub trait RuntimeArtifactStore {
170 fn materialize(&self, request: &ArtifactMaterializationRequest) -> Result<HandleRef>;
171}
172
173#[derive(Clone, Debug, Default)]
174pub struct InMemoryArtifactStore {
175 records: BTreeMap<ArtifactId, ArtifactHandleRecord>,
176 refit_artifacts: BTreeMap<ArtifactId, RefitArtifactRecord>,
177}
178
179impl InMemoryArtifactStore {
180 pub fn new() -> Self {
181 Self::default()
182 }
183
184 pub fn register(&mut self, artifact: &RefitArtifactRecord, handle: HandleRef) -> Result<()> {
185 artifact.validate()?;
186 let record = ArtifactHandleRecord {
187 handle,
188 node_id: artifact.node_id.clone(),
189 controller_id: artifact.controller_id.clone(),
190 artifact: artifact.artifact.clone(),
191 params_fingerprint: artifact.params_fingerprint.clone(),
192 training_loss_fingerprint: artifact.training_loss_fingerprint.clone(),
193 };
194 record.validate()?;
195 if self.records.contains_key(&record.artifact.id)
196 || self.refit_artifacts.contains_key(&record.artifact.id)
197 {
198 return Err(DagMlError::RuntimeValidation(format!(
199 "duplicate artifact handle for `{}`",
200 artifact.artifact.id
201 )));
202 }
203 let previous_record = self.records.insert(record.artifact.id.clone(), record);
204 debug_assert!(previous_record.is_none());
205 let previous_artifact = self
206 .refit_artifacts
207 .insert(artifact.artifact.id.clone(), artifact.clone());
208 debug_assert!(previous_artifact.is_none());
209 Ok(())
210 }
211
212 pub fn capture_refit_artifacts(
213 &mut self,
214 task: &NodeTask,
215 result: &NodeResult,
216 ) -> Result<Vec<RefitArtifactRecord>> {
217 if task.phase != Phase::Refit {
218 return Err(DagMlError::RuntimeValidation(format!(
219 "cannot capture refit artifacts from phase {:?}",
220 task.phase
221 )));
222 }
223 let mut records = Vec::new();
224 for artifact in &result.artifacts {
225 let handle = result.artifact_handles.get(&artifact.id).ok_or_else(|| {
226 DagMlError::RuntimeValidation(format!(
227 "node `{}` emitted artifact `{}` without artifact handle",
228 task.node_plan.node_id, artifact.id
229 ))
230 })?;
231 let record = RefitArtifactRecord {
232 node_id: task.node_plan.node_id.clone(),
233 controller_id: task.node_plan.controller_id.clone(),
234 artifact: artifact.clone(),
235 params_fingerprint: task.node_plan.params_fingerprint.clone(),
236 training_loss_fingerprint: task
237 .node_plan
238 .training_loss_fingerprint(Phase::Refit)?,
239 data_requirement_keys: task
240 .node_plan
241 .data_bindings
242 .iter()
243 .map(|binding| {
244 data_binding_requirement_key(&binding.node_id, &binding.input_name)
245 })
246 .collect(),
247 prediction_requirement_keys: task
254 .prediction_inputs
255 .values()
256 .filter(|spec| spec.partition == PredictionPartition::Validation)
257 .map(|spec| {
258 bundle_prediction_requirement_key(
259 &spec.producer_node,
260 &spec.source_port,
261 &task.node_plan.node_id,
262 &spec.target_port,
263 )
264 })
265 .collect(),
266 };
267 self.register(&record, handle.clone())?;
268 records.push(record);
269 }
270 Ok(records)
271 }
272
273 pub fn get(&self, artifact_id: &ArtifactId) -> Option<&ArtifactHandleRecord> {
274 self.records.get(artifact_id)
275 }
276
277 pub fn len(&self) -> usize {
278 self.records.len()
279 }
280
281 pub fn is_empty(&self) -> bool {
282 self.records.is_empty()
283 }
284
285 pub fn refit_artifacts(&self) -> Vec<RefitArtifactRecord> {
286 self.refit_artifacts.values().cloned().collect()
287 }
288}
289
290impl RuntimeArtifactStore for InMemoryArtifactStore {
291 fn materialize(&self, request: &ArtifactMaterializationRequest) -> Result<HandleRef> {
292 let record = self.records.get(&request.artifact.id).ok_or_else(|| {
293 DagMlError::RuntimeValidation(format!(
294 "artifact store is missing refit artifact `{}` for bundle `{}`",
295 request.artifact.id, request.bundle_id
296 ))
297 })?;
298 if record.node_id != request.node_id {
299 return Err(DagMlError::RuntimeValidation(format!(
300 "artifact `{}` is registered for node `{}` but requested for `{}`",
301 request.artifact.id, record.node_id, request.node_id
302 )));
303 }
304 if record.controller_id != request.controller_id {
305 return Err(DagMlError::RuntimeValidation(format!(
306 "artifact `{}` is registered for controller `{}` but requested for `{}`",
307 request.artifact.id, record.controller_id, request.controller_id
308 )));
309 }
310 if record.artifact != request.artifact {
311 return Err(DagMlError::RuntimeValidation(format!(
312 "artifact `{}` metadata does not match bundle record",
313 request.artifact.id
314 )));
315 }
316 if record.params_fingerprint != request.params_fingerprint {
317 return Err(DagMlError::RuntimeValidation(format!(
318 "artifact `{}` params fingerprint does not match bundle record",
319 request.artifact.id
320 )));
321 }
322 if record.training_loss_fingerprint != request.training_loss_fingerprint {
323 return Err(DagMlError::RuntimeValidation(format!(
324 "artifact `{}` training loss fingerprint does not match bundle record",
325 request.artifact.id
326 )));
327 }
328 record.validate()?;
329 Ok(record.handle.clone())
330 }
331}
332
333pub const FILE_ARTIFACT_MANIFEST_SCHEMA_VERSION: u32 = 1;
334pub const FILE_ARTIFACT_MANIFEST_FILE: &str = "artifact_manifest.json";
335
336pub(crate) fn default_file_artifact_manifest_schema_version() -> u32 {
337 FILE_ARTIFACT_MANIFEST_SCHEMA_VERSION
338}
339
340#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
345pub struct FileArtifactManifestEntry {
346 pub node_id: NodeId,
347 pub controller_id: ControllerId,
348 pub artifact: ArtifactRef,
349 pub params_fingerprint: String,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub training_loss_fingerprint: Option<String>,
352}
353
354impl FileArtifactManifestEntry {
355 fn from_refit_record(record: &RefitArtifactRecord) -> Result<Self> {
356 let entry = Self {
357 node_id: record.node_id.clone(),
358 controller_id: record.controller_id.clone(),
359 artifact: record.artifact.clone(),
360 params_fingerprint: record.params_fingerprint.clone(),
361 training_loss_fingerprint: record.training_loss_fingerprint.clone(),
362 };
363 entry.validate()?;
364 Ok(entry)
365 }
366
367 pub fn validate(&self) -> Result<()> {
368 self.artifact.validate_portable()?;
369 if self.artifact.controller_id != self.controller_id {
370 return Err(DagMlError::RuntimeValidation(format!(
371 "artifact manifest entry `{}` controller `{}` does not match artifact controller `{}`",
372 self.artifact.id, self.controller_id, self.artifact.controller_id
373 )));
374 }
375 validate_runtime_fingerprint("artifact manifest params", &self.params_fingerprint)?;
376 if let Some(fingerprint) = &self.training_loss_fingerprint {
377 validate_runtime_fingerprint("artifact manifest training loss", fingerprint)?;
378 }
379 Ok(())
380 }
381
382 fn matches_refit_record(&self, record: &RefitArtifactRecord) -> bool {
383 self.node_id == record.node_id
384 && self.controller_id == record.controller_id
385 && self.artifact == record.artifact
386 && self.params_fingerprint == record.params_fingerprint
387 && self.training_loss_fingerprint == record.training_loss_fingerprint
388 }
389}
390
391#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
396pub struct FileArtifactManifest {
397 pub bundle_id: BundleId,
398 #[serde(default = "default_file_artifact_manifest_schema_version")]
399 pub schema_version: u32,
400 #[serde(default)]
401 pub artifacts: Vec<FileArtifactManifestEntry>,
402}
403
404impl FileArtifactManifest {
405 pub fn validate(&self) -> Result<()> {
406 if self.schema_version != FILE_ARTIFACT_MANIFEST_SCHEMA_VERSION {
407 return Err(DagMlError::RuntimeValidation(format!(
408 "file artifact manifest for bundle `{}` uses unsupported schema_version {}, expected {}",
409 self.bundle_id, self.schema_version, FILE_ARTIFACT_MANIFEST_SCHEMA_VERSION
410 )));
411 }
412 let mut artifact_ids = BTreeSet::new();
413 let mut uris = BTreeSet::new();
414 for entry in &self.artifacts {
415 entry.validate()?;
416 if !artifact_ids.insert(entry.artifact.id.as_str()) {
417 return Err(DagMlError::RuntimeValidation(format!(
418 "file artifact manifest for bundle `{}` has duplicate artifact id `{}`",
419 self.bundle_id, entry.artifact.id
420 )));
421 }
422 if let Some(uri) = entry.artifact.uri.as_deref() {
424 if !uris.insert(uri) {
425 return Err(DagMlError::RuntimeValidation(format!(
426 "file artifact manifest for bundle `{}` has duplicate artifact uri `{}`",
427 self.bundle_id, uri
428 )));
429 }
430 }
431 }
432 Ok(())
433 }
434
435 pub fn validate_against_bundle(&self, bundle: &ExecutionBundle) -> Result<()> {
436 self.validate()?;
437 bundle.validate()?;
438 if self.bundle_id != bundle.bundle_id {
439 return Err(DagMlError::RuntimeValidation(format!(
440 "file artifact manifest bundle `{}` does not match bundle `{}`",
441 self.bundle_id, bundle.bundle_id
442 )));
443 }
444 if self.artifacts.len() != bundle.refit_artifacts.len() {
445 return Err(DagMlError::RuntimeValidation(format!(
446 "file artifact manifest for bundle `{}` has {} artifact(s) for {} bundle refit artifact(s)",
447 self.bundle_id,
448 self.artifacts.len(),
449 bundle.refit_artifacts.len()
450 )));
451 }
452 let entries_by_id = self
453 .artifacts
454 .iter()
455 .map(|entry| (entry.artifact.id.as_str(), entry))
456 .collect::<BTreeMap<_, _>>();
457 for record in &bundle.refit_artifacts {
458 let entry = entries_by_id
459 .get(record.artifact.id.as_str())
460 .ok_or_else(|| {
461 DagMlError::RuntimeValidation(format!(
462 "file artifact manifest for bundle `{}` is missing refit artifact `{}`",
463 self.bundle_id, record.artifact.id
464 ))
465 })?;
466 if !entry.matches_refit_record(record) {
467 return Err(DagMlError::RuntimeValidation(format!(
468 "file artifact manifest entry `{}` does not match bundle refit artifact",
469 entry.artifact.id
470 )));
471 }
472 }
473 Ok(())
474 }
475}
476
477#[derive(Clone, Debug)]
484pub struct FileArtifactManifestStore {
485 root: PathBuf,
486 manifest: FileArtifactManifest,
487}
488
489impl FileArtifactManifestStore {
490 pub fn write(root: impl AsRef<Path>, bundle: &ExecutionBundle) -> Result<FileArtifactManifest> {
491 bundle.validate()?;
492 let root = root.as_ref();
493 fs::create_dir_all(root).map_err(|err| {
494 DagMlError::RuntimeValidation(format!(
495 "failed to create artifact manifest store `{}`: {err}",
496 root.display()
497 ))
498 })?;
499 let mut entries = Vec::with_capacity(bundle.refit_artifacts.len());
500 for record in &bundle.refit_artifacts {
501 entries.push(FileArtifactManifestEntry::from_refit_record(record)?);
502 }
503 entries.sort_by(|left, right| left.artifact.id.cmp(&right.artifact.id));
504 let manifest = FileArtifactManifest {
505 bundle_id: bundle.bundle_id.clone(),
506 schema_version: FILE_ARTIFACT_MANIFEST_SCHEMA_VERSION,
507 artifacts: entries,
508 };
509 manifest.validate_against_bundle(bundle)?;
510 write_runtime_json(
511 &root.join(FILE_ARTIFACT_MANIFEST_FILE),
512 &manifest,
513 "artifact manifest",
514 )?;
515 Ok(manifest)
516 }
517
518 pub fn open(root: impl Into<PathBuf>, bundle: &ExecutionBundle) -> Result<Self> {
519 bundle.validate()?;
520 let root = root.into();
521 let manifest: FileArtifactManifest =
522 read_runtime_json(&root.join(FILE_ARTIFACT_MANIFEST_FILE), "artifact manifest")?;
523 manifest.validate_against_bundle(bundle)?;
524 Ok(Self { root, manifest })
525 }
526
527 pub fn root(&self) -> &Path {
528 &self.root
529 }
530
531 pub fn manifest(&self) -> &FileArtifactManifest {
532 &self.manifest
533 }
534}
535
536#[derive(Clone, Debug, Eq, PartialEq)]
537pub struct ArtifactPayloadMaterializationRecord {
538 pub run_id: RunId,
539 pub bundle_id: BundleId,
540 pub node_id: NodeId,
541 pub phase: Phase,
542 pub variant_id: Option<VariantId>,
543 pub artifact_id: ArtifactId,
544 pub training_loss_fingerprint: Option<String>,
545 pub payload_uri: String,
546 pub content_fingerprint: String,
547 pub size_bytes: u64,
548 pub handle: HandleRef,
549}
550
551#[derive(Clone, Debug, Eq, PartialEq)]
552pub(crate) struct ArtifactPayloadMetadata {
553 pub(crate) uri: String,
554 pub(crate) content_fingerprint: String,
555 pub(crate) size_bytes: u64,
556}
557
558#[derive(Clone, Debug)]
559pub struct FileArtifactPayloadStore {
560 root: PathBuf,
561 manifest: FileArtifactManifest,
562 records_by_artifact_id: BTreeMap<ArtifactId, RefitArtifactRecord>,
563 materialization_records: RefCell<Vec<ArtifactPayloadMaterializationRecord>>,
564}
565
566impl FileArtifactPayloadStore {
567 pub fn write_from_source(
568 output_root: impl AsRef<Path>,
569 source_root: impl AsRef<Path>,
570 bundle: &ExecutionBundle,
571 ) -> Result<Self> {
572 bundle.validate()?;
573 let output_root = output_root.as_ref();
574 let source_root = source_root.as_ref();
575 fs::create_dir_all(output_root).map_err(|err| {
576 DagMlError::RuntimeValidation(format!(
577 "failed to create artifact payload store `{}`: {err}",
578 output_root.display()
579 ))
580 })?;
581 for record in &bundle.refit_artifacts {
582 record.artifact.validate_portable()?;
583 validate_artifact_payload_file(source_root, &record.artifact)?;
584 let source_path = artifact_payload_path(source_root, &record.artifact)?;
585 let output_path = artifact_payload_path(output_root, &record.artifact)?;
586 if let Some(parent) = output_path.parent() {
587 fs::create_dir_all(parent).map_err(|err| {
588 DagMlError::RuntimeValidation(format!(
589 "failed to create artifact payload directory `{}`: {err}",
590 parent.display()
591 ))
592 })?;
593 }
594 if source_path != output_path {
595 fs::copy(&source_path, &output_path).map_err(|err| {
596 DagMlError::RuntimeValidation(format!(
597 "failed to copy artifact payload `{}` from {} to {}: {err}",
598 record.artifact.id,
599 source_path.display(),
600 output_path.display()
601 ))
602 })?;
603 }
604 }
605 FileArtifactManifestStore::write(output_root, bundle)?;
606 Self::open(output_root.to_path_buf(), bundle)
607 }
608
609 pub fn open(root: impl Into<PathBuf>, bundle: &ExecutionBundle) -> Result<Self> {
610 bundle.validate()?;
611 let root = root.into();
612 let manifest_store = FileArtifactManifestStore::open(root.clone(), bundle)?;
613 let records_by_artifact_id = bundle
614 .refit_artifacts
615 .iter()
616 .cloned()
617 .map(|record| (record.artifact.id.clone(), record))
618 .collect::<BTreeMap<_, _>>();
619 let store = Self {
620 root,
621 manifest: manifest_store.manifest().clone(),
622 records_by_artifact_id,
623 materialization_records: RefCell::new(Vec::new()),
624 };
625 store.validate_payloads()?;
626 Ok(store)
627 }
628
629 pub fn root(&self) -> &Path {
630 &self.root
631 }
632
633 pub fn manifest(&self) -> &FileArtifactManifest {
634 &self.manifest
635 }
636
637 pub fn payload_count(&self) -> usize {
638 self.manifest.artifacts.len()
639 }
640
641 pub fn materialization_records(&self) -> Vec<ArtifactPayloadMaterializationRecord> {
642 self.materialization_records.borrow().clone()
643 }
644
645 pub fn validate_payloads(&self) -> Result<()> {
646 self.manifest.validate()?;
647 for entry in &self.manifest.artifacts {
648 let record = self
649 .records_by_artifact_id
650 .get(&entry.artifact.id)
651 .ok_or_else(|| {
652 DagMlError::RuntimeValidation(format!(
653 "artifact payload store for bundle `{}` has no bundle record for `{}`",
654 self.manifest.bundle_id, entry.artifact.id
655 ))
656 })?;
657 if !entry.matches_refit_record(record) {
658 return Err(DagMlError::RuntimeValidation(format!(
659 "artifact payload store entry `{}` does not match bundle refit artifact",
660 entry.artifact.id
661 )));
662 }
663 validate_artifact_payload_file(&self.root, &entry.artifact)?;
664 }
665 Ok(())
666 }
667}
668
669impl RuntimeArtifactStore for FileArtifactPayloadStore {
670 fn materialize(&self, request: &ArtifactMaterializationRequest) -> Result<HandleRef> {
671 request.artifact.validate_portable()?;
672 let record = self
673 .records_by_artifact_id
674 .get(&request.artifact.id)
675 .ok_or_else(|| {
676 DagMlError::RuntimeValidation(format!(
677 "artifact payload store is missing refit artifact `{}` for bundle `{}`",
678 request.artifact.id, request.bundle_id
679 ))
680 })?;
681 if record.node_id != request.node_id {
682 return Err(DagMlError::RuntimeValidation(format!(
683 "artifact `{}` is registered for node `{}` but requested for `{}`",
684 request.artifact.id, record.node_id, request.node_id
685 )));
686 }
687 if record.controller_id != request.controller_id {
688 return Err(DagMlError::RuntimeValidation(format!(
689 "artifact `{}` is registered for controller `{}` but requested for `{}`",
690 request.artifact.id, record.controller_id, request.controller_id
691 )));
692 }
693 if record.artifact != request.artifact {
694 return Err(DagMlError::RuntimeValidation(format!(
695 "artifact `{}` metadata does not match bundle record",
696 request.artifact.id
697 )));
698 }
699 if record.params_fingerprint != request.params_fingerprint {
700 return Err(DagMlError::RuntimeValidation(format!(
701 "artifact `{}` params fingerprint does not match bundle record",
702 request.artifact.id
703 )));
704 }
705 if record.training_loss_fingerprint != request.training_loss_fingerprint {
706 return Err(DagMlError::RuntimeValidation(format!(
707 "artifact `{}` training loss fingerprint does not match bundle record",
708 request.artifact.id
709 )));
710 }
711 let metadata = validate_artifact_payload_file(&self.root, &request.artifact)?;
712 let fingerprint = stable_json_fingerprint(&(
713 &request.run_id,
714 &request.bundle_id,
715 &request.node_id,
716 request.phase,
717 &request.variant_id,
718 &request.artifact.id,
719 &metadata.content_fingerprint,
720 &request.params_fingerprint,
721 &request.training_loss_fingerprint,
722 ))?;
723 let handle = HandleRef {
724 handle: u64::from_str_radix(&fingerprint[..16], 16)
725 .expect("sha256 hex prefix should fit into u64"),
726 kind: HandleKind::Artifact,
727 owner_controller: request.controller_id.clone(),
728 };
729 self.materialization_records
730 .borrow_mut()
731 .push(ArtifactPayloadMaterializationRecord {
732 run_id: request.run_id.clone(),
733 bundle_id: request.bundle_id.clone(),
734 node_id: request.node_id.clone(),
735 phase: request.phase,
736 variant_id: request.variant_id.clone(),
737 artifact_id: request.artifact.id.clone(),
738 training_loss_fingerprint: request.training_loss_fingerprint.clone(),
739 payload_uri: metadata.uri,
740 content_fingerprint: metadata.content_fingerprint,
741 size_bytes: metadata.size_bytes,
742 handle: handle.clone(),
743 });
744 Ok(handle)
745 }
746}
747
748#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
749#[serde(deny_unknown_fields)]
750pub struct LineageRecord {
751 pub record_id: LineageId,
752 pub run_id: RunId,
753 pub node_id: NodeId,
754 pub phase: Phase,
755 pub controller_id: ControllerId,
756 pub controller_version: String,
757 pub variant_id: Option<VariantId>,
758 pub fold_id: Option<FoldId>,
759 #[serde(default)]
760 pub branch_path: Vec<BranchId>,
761 #[serde(default)]
762 pub input_lineage: Vec<LineageId>,
763 #[serde(default)]
764 pub artifact_refs: Vec<ArtifactRef>,
765 pub params_fingerprint: String,
766 pub data_model_shape_fingerprint: Option<String>,
767 pub aggregation_policy_fingerprint: Option<String>,
768 pub seed: Option<u64>,
769 #[serde(default)]
770 pub unsafe_flags: BTreeSet<String>,
771 #[serde(default)]
772 pub metrics: BTreeMap<String, f64>,
773 #[serde(default, skip_serializing_if = "Vec::is_empty")]
774 pub loss_attestations: Vec<LossExecutionAttestation>,
775 #[serde(default, skip_serializing_if = "Vec::is_empty")]
776 pub early_stopping_records: Vec<EarlyStoppingRecord>,
777}
778
779impl LineageRecord {
780 pub fn validate(&self) -> Result<()> {
781 if self.params_fingerprint.trim().is_empty() {
782 return Err(DagMlError::RuntimeValidation(format!(
783 "lineage `{}` has empty params fingerprint",
784 self.record_id
785 )));
786 }
787 for artifact in &self.artifact_refs {
788 artifact.validate()?;
789 }
790 for attestation in &self.loss_attestations {
791 attestation.validate()?;
792 if attestation.node_id != self.node_id || attestation.phase != self.phase {
793 return Err(DagMlError::RuntimeValidation(format!(
794 "lineage `{}` contains a loss attestation outside its node/phase scope",
795 self.record_id
796 )));
797 }
798 }
799 let mut early_stopping_roles = BTreeSet::new();
800 for record in &self.early_stopping_records {
801 record.validate_against(&self.node_id, self.phase, self.fold_id.as_ref())?;
802 if !early_stopping_roles.insert(record.metric_role.role_id.as_str()) {
803 return Err(DagMlError::RuntimeValidation(format!(
804 "lineage `{}` contains duplicate early-stopping role `{}`",
805 self.record_id, record.metric_role.role_id
806 )));
807 }
808 }
809 Ok(())
810 }
811}
812
813#[derive(Clone, Debug, Default)]
814pub struct InMemoryLineageRecorder {
815 records: BTreeMap<LineageId, LineageRecord>,
816}
817
818impl InMemoryLineageRecorder {
819 pub fn new() -> Self {
820 Self::default()
821 }
822
823 pub fn record(&mut self, record: LineageRecord) -> Result<()> {
824 record.validate()?;
825 if self
826 .records
827 .insert(record.record_id.clone(), record)
828 .is_some()
829 {
830 return Err(DagMlError::RuntimeValidation(
831 "duplicate lineage record id".to_string(),
832 ));
833 }
834 Ok(())
835 }
836
837 pub fn get(&self, id: &LineageId) -> Option<&LineageRecord> {
838 self.records.get(id)
839 }
840
841 pub fn len(&self) -> usize {
842 self.records.len()
843 }
844
845 pub fn is_empty(&self) -> bool {
846 self.records.is_empty()
847 }
848
849 pub fn records(&self) -> impl Iterator<Item = &LineageRecord> {
850 self.records.values()
851 }
852}