1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{DagMlError, Result};
6use crate::ids::NodeId;
7use crate::relation::EntityUnitLevel;
8
9pub const GRAPH_SPEC_SCHEMA_VERSION: u32 = 1;
10pub const GRAPH_SPEC_SCHEMA_ID: &str =
11 "https://github.com/GBeurier/dag-ml/schemas/graph_spec.v1.schema.json";
12
13#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum NodeKind {
16 Transform,
17 YTransform,
18 Split,
19 Model,
20 Fork,
21 Map,
22 FeatureJoin,
23 PredictionJoin,
24 MixedJoin,
25 SourceJoin,
26 Tag,
27 Exclude,
28 Augmentation,
29 Adapter,
30 Aggregator,
31 Generator,
32 Restructure,
33 Tuner,
34 Subgraph,
35 Chart,
36}
37
38#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum PortKind {
41 Data,
42 Target,
43 Prediction,
44 Artifact,
45 Metric,
46 Control,
47}
48
49#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum PortCardinality {
52 One,
53 Many,
54 Optional,
55}
56
57#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
58pub struct PortSpec {
59 pub name: String,
60 pub kind: PortKind,
61 pub representation: Option<String>,
62 pub cardinality: PortCardinality,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub unit_level: Option<EntityUnitLevel>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub alignment_key: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub target_level: Option<EntityUnitLevel>,
69 #[serde(default)]
70 pub description: String,
71}
72
73#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
74pub struct PortSchema {
75 #[serde(default)]
76 pub inputs: Vec<PortSpec>,
77 #[serde(default)]
78 pub outputs: Vec<PortSpec>,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
82pub struct PortRef {
83 pub node_id: NodeId,
84 pub port_name: String,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
88pub struct EdgeContract {
89 pub kind: PortKind,
90 pub representation: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub unit_level: Option<EntityUnitLevel>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub alignment_key: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub target_level: Option<EntityUnitLevel>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub relation_contract: Option<RelationContract>,
99 #[serde(default, skip_serializing_if = "is_false")]
100 pub allows_broadcast: bool,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub missingness_policy: Option<MissingnessPolicy>,
103 #[serde(default)]
104 pub requires_oof: bool,
105 #[serde(default)]
106 pub requires_fold_alignment: bool,
107 #[serde(default = "default_true")]
108 pub propagates_lineage: bool,
109}
110
111#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
112pub struct RelationContract {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub relation_fingerprint: Option<String>,
115 #[serde(default, skip_serializing_if = "is_false")]
116 pub required: bool,
117}
118
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum MissingnessPolicy {
122 Strict,
123 Warn,
124 ImputeDeclared,
125 Mask,
126 PartialModel,
127 PadRepresentation,
128}
129
130fn default_true() -> bool {
131 true
132}
133
134fn is_false(value: &bool) -> bool {
135 !*value
136}
137
138impl EdgeContract {
139 pub fn new(kind: PortKind, representation: Option<String>) -> Self {
140 Self {
141 kind,
142 representation,
143 unit_level: None,
144 alignment_key: None,
145 target_level: None,
146 relation_contract: None,
147 allows_broadcast: false,
148 missingness_policy: None,
149 requires_oof: false,
150 requires_fold_alignment: false,
151 propagates_lineage: true,
152 }
153 }
154}
155
156#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
157pub struct EdgeSpec {
158 pub source: PortRef,
159 pub target: PortRef,
160 pub contract: EdgeContract,
161}
162
163#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
164pub struct GraphInterface {
165 #[serde(default)]
166 pub inputs: Vec<PortSpec>,
167 #[serde(default)]
168 pub outputs: Vec<PortSpec>,
169}
170
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172pub struct NodeSpec {
173 pub id: NodeId,
174 pub kind: NodeKind,
175 pub operator: Option<serde_json::Value>,
176 #[serde(default)]
177 pub params: BTreeMap<String, serde_json::Value>,
178 #[serde(default)]
179 pub ports: PortSchema,
180 #[serde(default)]
181 pub metadata: BTreeMap<String, serde_json::Value>,
182 #[serde(default)]
183 pub seed_label: Option<String>,
184}
185
186#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
187pub struct GraphSpec {
188 pub id: String,
189 #[serde(default)]
190 pub interface: GraphInterface,
191 #[serde(default)]
192 pub nodes: Vec<NodeSpec>,
193 #[serde(default)]
194 pub edges: Vec<EdgeSpec>,
195 #[serde(default)]
196 pub search_space_fingerprint: Option<String>,
197 #[serde(default)]
198 pub metadata: BTreeMap<String, serde_json::Value>,
199}
200
201impl GraphSpec {
202 pub fn validate(&self) -> Result<()> {
203 if self.id.trim().is_empty() {
204 return Err(DagMlError::GraphValidation(
205 "graph id must not be empty".to_string(),
206 ));
207 }
208 if self.nodes.is_empty() {
209 return Err(DagMlError::GraphValidation(
210 "graph must contain at least one node".to_string(),
211 ));
212 }
213 if let Some(fingerprint) = &self.search_space_fingerprint {
214 if fingerprint.trim().is_empty() {
215 return Err(DagMlError::GraphValidation(format!(
216 "graph `{}` has empty search_space_fingerprint",
217 self.id
218 )));
219 }
220 }
221
222 let mut nodes = BTreeMap::new();
223 validate_unique_ports(
224 &NodeId::new("graph:interface").expect("static identifier is valid"),
225 "interface input",
226 &self.interface.inputs,
227 )?;
228 validate_unique_ports(
229 &NodeId::new("graph:interface").expect("static identifier is valid"),
230 "interface output",
231 &self.interface.outputs,
232 )?;
233 for node in &self.nodes {
234 if nodes.insert(node.id.clone(), node).is_some() {
235 return Err(DagMlError::GraphValidation(format!(
236 "duplicate node id `{}`",
237 node.id
238 )));
239 }
240 crate::oof::StackingOofRefitContract::from_metadata(&node.metadata).map_err(
241 |error| {
242 DagMlError::GraphValidation(format!(
243 "node `{}` carries invalid `{}` metadata: {}",
244 node.id,
245 crate::oof::STACKING_OOF_REFIT_CONTRACT_METADATA_KEY,
246 error
247 ))
248 },
249 )?;
250 validate_unique_ports(&node.id, "input", &node.ports.inputs)?;
251 validate_unique_ports(&node.id, "output", &node.ports.outputs)?;
252 }
253
254 let mut adjacency: BTreeMap<NodeId, Vec<NodeId>> = nodes
255 .keys()
256 .cloned()
257 .map(|id| (id, Vec::new()))
258 .collect::<BTreeMap<_, _>>();
259 let mut indegree: BTreeMap<NodeId, usize> =
260 nodes.keys().cloned().map(|id| (id, 0)).collect();
261
262 for edge in &self.edges {
263 let source = nodes.get(&edge.source.node_id).ok_or_else(|| {
264 DagMlError::GraphValidation(format!(
265 "edge source node `{}` does not exist",
266 edge.source.node_id
267 ))
268 })?;
269 let target = nodes.get(&edge.target.node_id).ok_or_else(|| {
270 DagMlError::GraphValidation(format!(
271 "edge target node `{}` does not exist",
272 edge.target.node_id
273 ))
274 })?;
275
276 let source_port =
277 find_port(&source.ports.outputs, &edge.source.port_name).ok_or_else(|| {
278 DagMlError::GraphValidation(format!(
279 "source port `{}.{}` does not exist",
280 edge.source.node_id, edge.source.port_name
281 ))
282 })?;
283 let target_port =
284 find_port(&target.ports.inputs, &edge.target.port_name).ok_or_else(|| {
285 DagMlError::GraphValidation(format!(
286 "target port `{}.{}` does not exist",
287 edge.target.node_id, edge.target.port_name
288 ))
289 })?;
290
291 if source_port.kind != edge.contract.kind || target_port.kind != edge.contract.kind {
292 return Err(DagMlError::GraphValidation(format!(
293 "edge `{}.{}` -> `{}.{}` has kind {:?}, but ports are {:?} and {:?}",
294 edge.source.node_id,
295 edge.source.port_name,
296 edge.target.node_id,
297 edge.target.port_name,
298 edge.contract.kind,
299 source_port.kind,
300 target_port.kind
301 )));
302 }
303 validate_edge_contract(edge, source_port, target_port)?;
304 if edge.contract.requires_oof && edge.contract.kind != PortKind::Prediction {
305 return Err(DagMlError::GraphValidation(format!(
306 "edge `{}.{}` -> `{}.{}` requires OOF but is not a prediction edge",
307 edge.source.node_id,
308 edge.source.port_name,
309 edge.target.node_id,
310 edge.target.port_name
311 )));
312 }
313
314 adjacency
315 .get_mut(&edge.source.node_id)
316 .expect("source exists")
317 .push(edge.target.node_id.clone());
318 *indegree
319 .get_mut(&edge.target.node_id)
320 .expect("target exists") += 1;
321 }
322
323 ensure_acyclic(adjacency, indegree)
324 }
325
326 pub fn topological_order(&self) -> Result<Vec<NodeId>> {
327 self.validate()?;
328 let nodes = self
329 .nodes
330 .iter()
331 .map(|node| node.id.clone())
332 .collect::<BTreeSet<_>>();
333 let mut adjacency = nodes
334 .iter()
335 .cloned()
336 .map(|id| (id, Vec::new()))
337 .collect::<BTreeMap<_, _>>();
338 let mut indegree: BTreeMap<NodeId, usize> =
339 nodes.iter().cloned().map(|id| (id, 0usize)).collect();
340 for edge in &self.edges {
341 adjacency
342 .get_mut(&edge.source.node_id)
343 .expect("source exists after validate")
344 .push(edge.target.node_id.clone());
345 *indegree
346 .get_mut(&edge.target.node_id)
347 .expect("target exists after validate") += 1;
348 }
349 topological_order(adjacency, indegree)
350 }
351
352 pub fn parallel_levels(&self) -> Result<Vec<Vec<NodeId>>> {
353 self.validate()?;
354 let nodes = self
355 .nodes
356 .iter()
357 .map(|node| node.id.clone())
358 .collect::<BTreeSet<_>>();
359 let mut adjacency = nodes
360 .iter()
361 .cloned()
362 .map(|id| (id, Vec::new()))
363 .collect::<BTreeMap<_, _>>();
364 let mut indegree: BTreeMap<NodeId, usize> =
365 nodes.iter().cloned().map(|id| (id, 0usize)).collect();
366 for edge in &self.edges {
367 adjacency
368 .get_mut(&edge.source.node_id)
369 .expect("source exists after validate")
370 .push(edge.target.node_id.clone());
371 *indegree
372 .get_mut(&edge.target.node_id)
373 .expect("target exists after validate") += 1;
374 }
375 topological_levels(adjacency, indegree)
376 }
377
378 pub fn upstream_nodes(&self, node_id: &NodeId) -> Vec<NodeId> {
379 let mut upstream = self
380 .edges
381 .iter()
382 .filter_map(|edge| {
383 (edge.target.node_id == *node_id).then_some(edge.source.node_id.clone())
384 })
385 .collect::<Vec<_>>();
386 upstream.sort();
387 upstream.dedup();
388 upstream
389 }
390
391 pub fn downstream_nodes(&self, node_id: &NodeId) -> Vec<NodeId> {
392 let mut downstream = self
393 .edges
394 .iter()
395 .filter_map(|edge| {
396 (edge.source.node_id == *node_id).then_some(edge.target.node_id.clone())
397 })
398 .collect::<Vec<_>>();
399 downstream.sort();
400 downstream.dedup();
401 downstream
402 }
403}
404
405fn validate_unique_ports(node_id: &NodeId, direction: &str, ports: &[PortSpec]) -> Result<()> {
406 let mut seen = BTreeSet::new();
407 for port in ports {
408 if port.name.trim().is_empty() {
409 return Err(DagMlError::GraphValidation(format!(
410 "{} port on node `{}` has an empty name",
411 direction, node_id
412 )));
413 }
414 if !seen.insert(port.name.as_str()) {
415 return Err(DagMlError::GraphValidation(format!(
416 "duplicate {} port `{}` on node `{}`",
417 direction, port.name, node_id
418 )));
419 }
420 validate_port_contract(node_id, direction, port)?;
421 }
422 Ok(())
423}
424
425fn find_port<'a>(ports: &'a [PortSpec], name: &str) -> Option<&'a PortSpec> {
426 ports.iter().find(|port| port.name == name)
427}
428
429fn validate_port_contract(node_id: &NodeId, direction: &str, port: &PortSpec) -> Result<()> {
430 validate_optional_non_empty(
431 &format!("{direction} port `{}` representation", port.name),
432 port.representation.as_deref(),
433 )?;
434 validate_optional_non_empty(
435 &format!("{direction} port `{}` alignment_key", port.name),
436 port.alignment_key.as_deref(),
437 )?;
438 if port
439 .alignment_key
440 .as_deref()
441 .is_some_and(|key| !is_identifier(key))
442 {
443 return Err(DagMlError::GraphValidation(format!(
444 "{direction} port `{}` on node `{node_id}` has invalid alignment_key",
445 port.name
446 )));
447 }
448 Ok(())
449}
450
451fn validate_edge_contract(
452 edge: &EdgeSpec,
453 source_port: &PortSpec,
454 target_port: &PortSpec,
455) -> Result<()> {
456 let label = format!(
457 "edge `{}.{}` -> `{}.{}`",
458 edge.source.node_id, edge.source.port_name, edge.target.node_id, edge.target.port_name
459 );
460 validate_optional_non_empty(
461 &format!("{label} representation"),
462 edge.contract.representation.as_deref(),
463 )?;
464 validate_optional_non_empty(
465 &format!("{label} alignment_key"),
466 edge.contract.alignment_key.as_deref(),
467 )?;
468 if edge
469 .contract
470 .alignment_key
471 .as_deref()
472 .is_some_and(|key| !is_identifier(key))
473 {
474 return Err(DagMlError::GraphValidation(format!(
475 "{label} has invalid alignment_key"
476 )));
477 }
478 if let Some(relation_contract) = &edge.contract.relation_contract {
479 validate_relation_contract(&label, relation_contract)?;
480 }
481
482 validate_edge_unit_alignment(&label, edge, source_port, target_port)?;
483
484 if relation_aware_edge(edge, source_port, target_port) {
485 let relation_fingerprint = edge
486 .contract
487 .relation_contract
488 .as_ref()
489 .and_then(|contract| contract.relation_fingerprint.as_deref());
490 if relation_fingerprint.is_none() {
491 return Err(DagMlError::GraphValidation(format!(
492 "{label} is relation-aware but has no relation_fingerprint"
493 )));
494 }
495 if !has_effective_unit_level(edge, source_port, target_port) {
496 return Err(DagMlError::GraphValidation(format!(
497 "{label} is relation-aware but has no unit_level metadata"
498 )));
499 }
500 if !has_effective_alignment_key(edge, source_port, target_port) {
501 return Err(DagMlError::GraphValidation(format!(
502 "{label} is relation-aware but has no alignment_key"
503 )));
504 }
505 }
506 Ok(())
507}
508
509fn validate_relation_contract(label: &str, contract: &RelationContract) -> Result<()> {
510 if let Some(fingerprint) = &contract.relation_fingerprint {
511 validate_sha256(label, "relation_fingerprint", fingerprint)?;
512 } else if contract.required {
513 return Err(DagMlError::GraphValidation(format!(
514 "{label} relation_contract is required but has no relation_fingerprint"
515 )));
516 }
517 Ok(())
518}
519
520fn validate_edge_unit_alignment(
521 label: &str,
522 edge: &EdgeSpec,
523 source_port: &PortSpec,
524 target_port: &PortSpec,
525) -> Result<()> {
526 if let Some(contract_unit) = edge.contract.unit_level {
527 for (endpoint, unit) in [
528 ("source", source_port.unit_level),
529 ("target", target_port.unit_level),
530 ] {
531 if let Some(unit) = unit {
532 if unit != contract_unit && !edge.contract.allows_broadcast {
533 return Err(DagMlError::GraphValidation(format!(
534 "{label} {endpoint} unit {:?} does not match edge unit {:?}",
535 unit, contract_unit
536 )));
537 }
538 }
539 }
540 }
541
542 if let (Some(source_unit), Some(target_unit)) = (source_port.unit_level, target_port.unit_level)
543 {
544 if source_unit != target_unit && !edge.contract.allows_broadcast {
545 return Err(DagMlError::GraphValidation(format!(
546 "{label} joins incompatible unit levels {:?} and {:?}",
547 source_unit, target_unit
548 )));
549 }
550 }
551
552 if let (Some(source_target), Some(target_target)) =
553 (source_port.target_level, target_port.target_level)
554 {
555 if source_target != target_target {
556 return Err(DagMlError::GraphValidation(format!(
557 "{label} joins incompatible target levels {:?} and {:?}",
558 source_target, target_target
559 )));
560 }
561 }
562 if let Some(contract_target) = edge.contract.target_level {
563 for (endpoint, target_level) in [
564 ("source", source_port.target_level),
565 ("target", target_port.target_level),
566 ] {
567 if let Some(target_level) = target_level {
568 if target_level != contract_target {
569 return Err(DagMlError::GraphValidation(format!(
570 "{label} {endpoint} target level {:?} does not match edge target_level {:?}",
571 target_level, contract_target
572 )));
573 }
574 }
575 }
576 }
577
578 if let (Some(source_alignment), Some(target_alignment)) = (
579 source_port.alignment_key.as_deref(),
580 target_port.alignment_key.as_deref(),
581 ) {
582 if source_alignment != target_alignment && !edge.contract.allows_broadcast {
583 return Err(DagMlError::GraphValidation(format!(
584 "{label} joins incompatible alignment keys `{source_alignment}` and `{target_alignment}`"
585 )));
586 }
587 }
588
589 if let Some(edge_alignment) = edge.contract.alignment_key.as_deref() {
590 for (endpoint, alignment) in [
591 ("source", source_port.alignment_key.as_deref()),
592 ("target", target_port.alignment_key.as_deref()),
593 ] {
594 if let Some(alignment) = alignment {
595 if alignment != edge_alignment && !edge.contract.allows_broadcast {
596 return Err(DagMlError::GraphValidation(format!(
597 "{label} {endpoint} alignment `{alignment}` does not match edge alignment `{edge_alignment}`"
598 )));
599 }
600 }
601 }
602 }
603
604 if edge.contract.allows_broadcast
605 && edge.contract.alignment_key.is_none()
606 && source_port.alignment_key.is_none()
607 && target_port.alignment_key.is_none()
608 {
609 return Err(DagMlError::GraphValidation(format!(
610 "{label} allows broadcast but declares no alignment_key"
611 )));
612 }
613 Ok(())
614}
615
616fn relation_aware_edge(edge: &EdgeSpec, source_port: &PortSpec, target_port: &PortSpec) -> bool {
617 edge.contract.relation_contract.is_some()
618 || edge.contract.allows_broadcast
619 || edge.contract.alignment_key.is_some()
620 || non_physical(edge.contract.unit_level)
621 || non_physical(edge.contract.target_level)
622 || non_physical(source_port.unit_level)
623 || non_physical(source_port.target_level)
624 || non_physical(target_port.unit_level)
625 || non_physical(target_port.target_level)
626 || source_port.alignment_key.is_some()
627 || target_port.alignment_key.is_some()
628}
629
630fn has_effective_unit_level(
631 edge: &EdgeSpec,
632 source_port: &PortSpec,
633 target_port: &PortSpec,
634) -> bool {
635 edge.contract.unit_level.is_some()
636 || source_port.unit_level.is_some()
637 || target_port.unit_level.is_some()
638}
639
640fn has_effective_alignment_key(
641 edge: &EdgeSpec,
642 source_port: &PortSpec,
643 target_port: &PortSpec,
644) -> bool {
645 edge.contract.alignment_key.is_some()
646 || source_port.alignment_key.is_some()
647 || target_port.alignment_key.is_some()
648}
649
650fn non_physical(unit_level: Option<EntityUnitLevel>) -> bool {
651 unit_level.is_some_and(|level| level != EntityUnitLevel::PhysicalSample)
652}
653
654fn validate_optional_non_empty(label: &str, value: Option<&str>) -> Result<()> {
655 if value.is_some_and(|value| value.trim().is_empty()) {
656 return Err(DagMlError::GraphValidation(format!(
657 "{label} must not be empty"
658 )));
659 }
660 Ok(())
661}
662
663fn validate_sha256(owner: &str, field: &str, value: &str) -> Result<()> {
664 if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
665 Ok(())
666 } else {
667 Err(DagMlError::GraphValidation(format!(
668 "{owner} has invalid {field}"
669 )))
670 }
671}
672
673fn is_identifier(value: &str) -> bool {
674 !value.is_empty()
675 && value.len() <= 128
676 && value
677 .bytes()
678 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':'))
679}
680
681fn ensure_acyclic(
682 adjacency: BTreeMap<NodeId, Vec<NodeId>>,
683 indegree: BTreeMap<NodeId, usize>,
684) -> Result<()> {
685 topological_order(adjacency, indegree).map(|_| ())
686}
687
688fn topological_order(
689 adjacency: BTreeMap<NodeId, Vec<NodeId>>,
690 mut indegree: BTreeMap<NodeId, usize>,
691) -> Result<Vec<NodeId>> {
692 let mut queue = indegree
693 .iter()
694 .filter_map(|(id, degree)| (*degree == 0).then_some(id.clone()))
695 .collect::<BTreeSet<_>>();
696 let mut order = Vec::with_capacity(indegree.len());
697
698 while let Some(node) = queue.pop_first() {
699 order.push(node.clone());
700 if let Some(next_nodes) = adjacency.get(&node) {
701 for next in next_nodes {
702 let degree = indegree.get_mut(next).expect("node exists");
703 *degree -= 1;
704 if *degree == 0 {
705 queue.insert(next.clone());
706 }
707 }
708 }
709 }
710
711 if order.len() == indegree.len() {
712 Ok(order)
713 } else {
714 Err(DagMlError::GraphValidation(
715 "graph contains at least one cycle".to_string(),
716 ))
717 }
718}
719
720fn topological_levels(
721 adjacency: BTreeMap<NodeId, Vec<NodeId>>,
722 mut indegree: BTreeMap<NodeId, usize>,
723) -> Result<Vec<Vec<NodeId>>> {
724 let mut queue = indegree
725 .iter()
726 .filter_map(|(id, degree)| (*degree == 0).then_some(id.clone()))
727 .collect::<BTreeSet<_>>();
728 let mut levels = Vec::new();
729 let mut visited = 0usize;
730
731 while !queue.is_empty() {
732 let level = queue.iter().cloned().collect::<Vec<_>>();
733 queue.clear();
734 for node in &level {
735 visited += 1;
736 if let Some(next_nodes) = adjacency.get(node) {
737 for next in next_nodes {
738 let degree = indegree.get_mut(next).expect("node exists");
739 *degree -= 1;
740 if *degree == 0 {
741 queue.insert(next.clone());
742 }
743 }
744 }
745 }
746 levels.push(level);
747 }
748
749 if visited == indegree.len() {
750 Ok(levels)
751 } else {
752 Err(DagMlError::GraphValidation(
753 "graph contains at least one cycle".to_string(),
754 ))
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761
762 fn port(name: &str, kind: PortKind) -> PortSpec {
763 PortSpec {
764 name: name.to_string(),
765 kind,
766 representation: None,
767 cardinality: PortCardinality::One,
768 unit_level: None,
769 alignment_key: None,
770 target_level: None,
771 description: String::new(),
772 }
773 }
774
775 fn node(id: &str, inputs: Vec<PortSpec>, outputs: Vec<PortSpec>) -> NodeSpec {
776 NodeSpec {
777 id: NodeId::new(id).unwrap(),
778 kind: NodeKind::Model,
779 operator: None,
780 params: BTreeMap::new(),
781 ports: PortSchema { inputs, outputs },
782 metadata: BTreeMap::new(),
783 seed_label: None,
784 }
785 }
786
787 fn edge(source: &str, source_port: &str, target: &str, target_port: &str) -> EdgeSpec {
788 EdgeSpec {
789 source: PortRef {
790 node_id: NodeId::new(source).unwrap(),
791 port_name: source_port.to_string(),
792 },
793 target: PortRef {
794 node_id: NodeId::new(target).unwrap(),
795 port_name: target_port.to_string(),
796 },
797 contract: EdgeContract {
798 requires_oof: true,
799 requires_fold_alignment: true,
800 ..EdgeContract::new(PortKind::Prediction, None)
801 },
802 }
803 }
804
805 #[test]
806 fn validates_simple_graph() {
807 let graph = GraphSpec {
808 id: "g".to_string(),
809 interface: GraphInterface::default(),
810 nodes: vec![
811 node("model:a", vec![], vec![port("pred", PortKind::Prediction)]),
812 node("model:b", vec![port("pred", PortKind::Prediction)], vec![]),
813 ],
814 edges: vec![edge("model:a", "pred", "model:b", "pred")],
815 search_space_fingerprint: None,
816 metadata: BTreeMap::new(),
817 };
818
819 assert!(graph.validate().is_ok());
820 }
821
822 #[test]
823 fn computes_deterministic_parallel_levels() {
824 let graph = GraphSpec {
825 id: "g".to_string(),
826 interface: GraphInterface::default(),
827 nodes: vec![
828 node("model:a", vec![], vec![port("pred", PortKind::Prediction)]),
829 node(
830 "model:b",
831 vec![port("pred", PortKind::Prediction)],
832 vec![port("pred", PortKind::Prediction)],
833 ),
834 node(
835 "model:c",
836 vec![port("pred", PortKind::Prediction)],
837 vec![port("pred", PortKind::Prediction)],
838 ),
839 node("model:d", vec![port("pred", PortKind::Prediction)], vec![]),
840 ],
841 edges: vec![
842 edge("model:a", "pred", "model:b", "pred"),
843 edge("model:a", "pred", "model:c", "pred"),
844 edge("model:b", "pred", "model:d", "pred"),
845 edge("model:c", "pred", "model:d", "pred"),
846 ],
847 search_space_fingerprint: None,
848 metadata: BTreeMap::new(),
849 };
850
851 let levels = graph.parallel_levels().unwrap();
852
853 assert_eq!(
854 levels,
855 vec![
856 vec![NodeId::new("model:a").unwrap()],
857 vec![
858 NodeId::new("model:b").unwrap(),
859 NodeId::new("model:c").unwrap()
860 ],
861 vec![NodeId::new("model:d").unwrap()]
862 ]
863 );
864 }
865
866 #[test]
867 fn rejects_missing_edge_endpoint() {
868 let graph = GraphSpec {
869 id: "g".to_string(),
870 interface: GraphInterface::default(),
871 nodes: vec![node(
872 "model:a",
873 vec![],
874 vec![port("pred", PortKind::Prediction)],
875 )],
876 edges: vec![edge("model:a", "pred", "model:b", "pred")],
877 search_space_fingerprint: None,
878 metadata: BTreeMap::new(),
879 };
880
881 assert!(graph.validate().is_err());
882 }
883
884 #[test]
885 fn rejects_oof_contract_on_non_prediction_edge() {
886 let graph = GraphSpec {
887 id: "g".to_string(),
888 interface: GraphInterface::default(),
889 nodes: vec![
890 node("model:a", vec![], vec![port("x", PortKind::Data)]),
891 node("model:b", vec![port("x", PortKind::Data)], vec![]),
892 ],
893 edges: vec![EdgeSpec {
894 source: PortRef {
895 node_id: NodeId::new("model:a").unwrap(),
896 port_name: "x".to_string(),
897 },
898 target: PortRef {
899 node_id: NodeId::new("model:b").unwrap(),
900 port_name: "x".to_string(),
901 },
902 contract: EdgeContract {
903 requires_oof: true,
904 requires_fold_alignment: true,
905 ..EdgeContract::new(PortKind::Data, None)
906 },
907 }],
908 search_space_fingerprint: None,
909 metadata: BTreeMap::new(),
910 };
911
912 let error = graph.validate().unwrap_err().to_string();
913
914 assert!(error.contains("requires OOF"));
915 }
916
917 fn unit_port(name: &str, kind: PortKind, unit_level: EntityUnitLevel) -> PortSpec {
918 let mut port = port(name, kind);
919 port.unit_level = Some(unit_level);
920 port.alignment_key = Some("sample_id".to_string());
921 port
922 }
923
924 fn data_edge_contract() -> EdgeContract {
925 EdgeContract::new(PortKind::Data, Some("tabular".to_string()))
926 }
927
928 fn relation_contract() -> RelationContract {
929 RelationContract {
930 relation_fingerprint: Some("a".repeat(64)),
931 required: true,
932 }
933 }
934
935 #[test]
936 fn rejects_unit_mismatch_without_explicit_broadcast() {
937 let graph = GraphSpec {
938 id: "g".to_string(),
939 interface: GraphInterface::default(),
940 nodes: vec![
941 node(
942 "transform:obs",
943 vec![],
944 vec![unit_port("x", PortKind::Data, EntityUnitLevel::Observation)],
945 ),
946 node(
947 "join:sample",
948 vec![unit_port(
949 "x",
950 PortKind::Data,
951 EntityUnitLevel::PhysicalSample,
952 )],
953 vec![],
954 ),
955 ],
956 edges: vec![EdgeSpec {
957 source: PortRef {
958 node_id: NodeId::new("transform:obs").unwrap(),
959 port_name: "x".to_string(),
960 },
961 target: PortRef {
962 node_id: NodeId::new("join:sample").unwrap(),
963 port_name: "x".to_string(),
964 },
965 contract: EdgeContract {
966 relation_contract: Some(relation_contract()),
967 ..data_edge_contract()
968 },
969 }],
970 search_space_fingerprint: None,
971 metadata: BTreeMap::new(),
972 };
973
974 let error = graph.validate().unwrap_err().to_string();
975
976 assert!(error.contains("incompatible unit levels"));
977 }
978
979 #[test]
980 fn relation_aware_edge_requires_relation_fingerprint() {
981 let graph = GraphSpec {
982 id: "g".to_string(),
983 interface: GraphInterface::default(),
984 nodes: vec![
985 node(
986 "source:a",
987 vec![],
988 vec![unit_port("x", PortKind::Data, EntityUnitLevel::Observation)],
989 ),
990 node(
991 "model:a",
992 vec![unit_port("x", PortKind::Data, EntityUnitLevel::Observation)],
993 vec![],
994 ),
995 ],
996 edges: vec![EdgeSpec {
997 source: PortRef {
998 node_id: NodeId::new("source:a").unwrap(),
999 port_name: "x".to_string(),
1000 },
1001 target: PortRef {
1002 node_id: NodeId::new("model:a").unwrap(),
1003 port_name: "x".to_string(),
1004 },
1005 contract: data_edge_contract(),
1006 }],
1007 search_space_fingerprint: None,
1008 metadata: BTreeMap::new(),
1009 };
1010
1011 let error = graph.validate().unwrap_err().to_string();
1012
1013 assert!(error.contains("relation-aware"));
1014 }
1015
1016 #[test]
1017 fn relation_aware_edge_requires_alignment_key() {
1018 let mut source_port = port("x", PortKind::Data);
1019 source_port.unit_level = Some(EntityUnitLevel::Observation);
1020 let mut target_port = port("x", PortKind::Data);
1021 target_port.unit_level = Some(EntityUnitLevel::Observation);
1022
1023 let graph = GraphSpec {
1024 id: "g".to_string(),
1025 interface: GraphInterface::default(),
1026 nodes: vec![
1027 node("source:a", vec![], vec![source_port]),
1028 node("model:a", vec![target_port], vec![]),
1029 ],
1030 edges: vec![EdgeSpec {
1031 source: PortRef {
1032 node_id: NodeId::new("source:a").unwrap(),
1033 port_name: "x".to_string(),
1034 },
1035 target: PortRef {
1036 node_id: NodeId::new("model:a").unwrap(),
1037 port_name: "x".to_string(),
1038 },
1039 contract: EdgeContract {
1040 relation_contract: Some(relation_contract()),
1041 ..data_edge_contract()
1042 },
1043 }],
1044 search_space_fingerprint: None,
1045 metadata: BTreeMap::new(),
1046 };
1047
1048 let error = graph.validate().unwrap_err().to_string();
1049
1050 assert!(error.contains("alignment_key"));
1051 }
1052
1053 #[test]
1054 fn explicit_broadcast_allows_sample_to_observation_edge() {
1055 let mut contract = data_edge_contract();
1056 contract.allows_broadcast = true;
1057 contract.alignment_key = Some("sample_id".to_string());
1058 contract.relation_contract = Some(relation_contract());
1059
1060 let graph = GraphSpec {
1061 id: "g".to_string(),
1062 interface: GraphInterface::default(),
1063 nodes: vec![
1064 node(
1065 "source:sample",
1066 vec![],
1067 vec![unit_port(
1068 "x",
1069 PortKind::Data,
1070 EntityUnitLevel::PhysicalSample,
1071 )],
1072 ),
1073 node(
1074 "adapter:broadcast",
1075 vec![unit_port("x", PortKind::Data, EntityUnitLevel::Observation)],
1076 vec![],
1077 ),
1078 ],
1079 edges: vec![EdgeSpec {
1080 source: PortRef {
1081 node_id: NodeId::new("source:sample").unwrap(),
1082 port_name: "x".to_string(),
1083 },
1084 target: PortRef {
1085 node_id: NodeId::new("adapter:broadcast").unwrap(),
1086 port_name: "x".to_string(),
1087 },
1088 contract,
1089 }],
1090 search_space_fingerprint: None,
1091 metadata: BTreeMap::new(),
1092 };
1093
1094 graph.validate().unwrap();
1095 }
1096
1097 #[test]
1098 fn rejects_cycles() {
1099 let graph = GraphSpec {
1100 id: "g".to_string(),
1101 interface: GraphInterface::default(),
1102 nodes: vec![
1103 node(
1104 "model:a",
1105 vec![port("pred", PortKind::Prediction)],
1106 vec![port("pred", PortKind::Prediction)],
1107 ),
1108 node(
1109 "model:b",
1110 vec![port("pred", PortKind::Prediction)],
1111 vec![port("pred", PortKind::Prediction)],
1112 ),
1113 ],
1114 edges: vec![
1115 edge("model:a", "pred", "model:b", "pred"),
1116 edge("model:b", "pred", "model:a", "pred"),
1117 ],
1118 search_space_fingerprint: None,
1119 metadata: BTreeMap::new(),
1120 };
1121
1122 assert!(graph.validate().is_err());
1123 }
1124
1125 #[test]
1126 fn published_graph_spec_schema_declares_current_contract() {
1127 let schema: serde_json::Value = serde_json::from_str(include_str!(
1128 "../../../docs/contracts/graph_spec.schema.json"
1129 ))
1130 .unwrap();
1131
1132 assert_eq!(schema["$id"], GRAPH_SPEC_SCHEMA_ID);
1133 assert!(schema["required"]
1134 .as_array()
1135 .unwrap()
1136 .iter()
1137 .any(|field| field.as_str() == Some("nodes")));
1138 assert_eq!(
1139 schema["$defs"]["node_kind"]["enum"]
1140 .as_array()
1141 .unwrap()
1142 .len(),
1143 20
1144 );
1145 assert!(schema["$defs"]["port_kind"]["enum"]
1146 .as_array()
1147 .unwrap()
1148 .iter()
1149 .any(|kind| kind.as_str() == Some("prediction")));
1150 assert!(schema["$defs"]["entity_unit_level"]["enum"]
1151 .as_array()
1152 .unwrap()
1153 .iter()
1154 .any(|level| level.as_str() == Some("combo")));
1155 assert!(schema["$defs"]["edge_contract"]["properties"]
1156 .as_object()
1157 .unwrap()
1158 .contains_key("relation_contract"));
1159 }
1160}