Skip to main content

tatara_engine/domain/
dag_executor.rs

1//! DAG runtime executor — walks typed convergence DAGs through boundary phases.
2//!
3//! This is the convergence engine's core. Given a ConvergenceGraph and a
4//! ConvergencePlan, the executor traverses points in topological order,
5//! driving each through: Prepare → Execute → Verify → Attest.
6//!
7//! Independent points at the same topological level run in parallel.
8//! The attestation hash from each point chains to the next.
9
10use std::collections::{BTreeMap, HashMap};
11use std::time::Instant;
12
13use anyhow::Result;
14use chrono::Duration;
15use tracing::{debug, error, info, warn};
16
17use tatara_core::domain::convergence_graph::ConvergenceGraph;
18use tatara_core::domain::convergence_state::{BoundaryPhase, ConvergenceOutcome, ConvergencePoint};
19use tatara_core::domain::point_id::PointId;
20
21use super::attestation::pillar_hash;
22
23/// Result of executing a single convergence point through its boundary.
24#[derive(Debug, Clone)]
25pub struct PointExecutionResult {
26    /// Which point was executed.
27    pub point_id: PointId,
28    /// What happened.
29    pub outcome: ConvergenceOutcome,
30    /// The attestation hash produced (if attested).
31    pub attestation: Option<String>,
32    /// How long execution took.
33    pub duration: Duration,
34    /// Final boundary phase.
35    pub phase: BoundaryPhase,
36}
37
38/// Result of executing an entire convergence DAG.
39#[derive(Debug)]
40pub struct DagExecutionResult {
41    /// Per-point outcomes.
42    pub outcomes: BTreeMap<PointId, PointExecutionResult>,
43    /// Total execution duration.
44    pub total_duration: Duration,
45    /// Final attestation hash (from the last point in topological order).
46    pub final_attestation: Option<String>,
47    /// Points that converged successfully.
48    pub converged_count: usize,
49    /// Points that failed.
50    pub failed_count: usize,
51    /// Points that degraded.
52    pub degraded_count: usize,
53}
54
55/// The DAG executor drives a ConvergenceGraph through its boundary phases.
56pub struct DagExecutor;
57
58impl DagExecutor {
59    /// Execute an entire convergence graph in topological order.
60    ///
61    /// Each point goes through: Prepare → Execute → Verify → Attest.
62    /// The attestation from each point feeds into the next point's
63    /// input_attestation.
64    pub async fn execute_graph(
65        graph: &ConvergenceGraph,
66        execution_order: &[PointId],
67    ) -> Result<DagExecutionResult> {
68        let start = Instant::now();
69        let mut outcomes = BTreeMap::new();
70        let mut attestation_chain: HashMap<PointId, String> = HashMap::new();
71        let mut converged = 0usize;
72        let mut failed = 0usize;
73        let mut degraded = 0usize;
74
75        // Track which points have failed — their downstream dependents are blocked.
76        let mut failed_points: std::collections::HashSet<PointId> =
77            std::collections::HashSet::new();
78
79        for point_id in execution_order {
80            let point = graph
81                .points
82                .get(point_id)
83                .ok_or_else(|| anyhow::anyhow!("point {point_id} not in graph"))?;
84
85            // Check if any upstream dependency failed — if so, skip this point.
86            let has_failed_upstream = graph
87                .edges
88                .iter()
89                .filter(|e| e.to == *point_id)
90                .any(|e| failed_points.contains(&e.from));
91
92            if has_failed_upstream {
93                warn!(
94                    point = %point.name,
95                    "skipping — upstream dependency failed"
96                );
97                failed_points.insert(*point_id);
98                let result = PointExecutionResult {
99                    point_id: *point_id,
100                    outcome: ConvergenceOutcome::Failed {
101                        reason: "upstream dependency failed".into(),
102                    },
103                    attestation: None,
104                    duration: Duration::milliseconds(0),
105                    phase: BoundaryPhase::Failed {
106                        reason: "blocked by upstream failure".into(),
107                    },
108                };
109                failed += 1;
110                outcomes.insert(*point_id, result);
111                continue;
112            }
113
114            // Collect input attestations from upstream points
115            let input_attestations: Vec<String> = graph
116                .edges
117                .iter()
118                .filter(|e| e.to == *point_id)
119                .filter_map(|e| attestation_chain.get(&e.from).cloned())
120                .collect();
121
122            let input_attestation = if input_attestations.is_empty() {
123                None
124            } else {
125                let combined = input_attestations.join(":");
126                Some(combined)
127            };
128
129            let result = Self::execute_point(point, input_attestation.as_deref()).await;
130
131            // Store attestation for downstream points
132            if let Some(ref att) = result.attestation {
133                attestation_chain.insert(*point_id, att.clone());
134            }
135
136            match &result.outcome {
137                ConvergenceOutcome::Converged => converged += 1,
138                ConvergenceOutcome::Failed { reason } => {
139                    error!(
140                        point = %point.name,
141                        reason = %reason,
142                        "convergence point failed — downstream points blocked"
143                    );
144                    failed_points.insert(*point_id);
145                    failed += 1;
146                }
147                ConvergenceOutcome::Degraded { .. } => degraded += 1,
148            }
149
150            outcomes.insert(*point_id, result);
151        }
152
153        let elapsed = start.elapsed();
154        let final_attestation = execution_order
155            .last()
156            .and_then(|id| attestation_chain.get(id).cloned());
157
158        Ok(DagExecutionResult {
159            outcomes,
160            total_duration: Duration::milliseconds(elapsed.as_millis() as i64),
161            final_attestation,
162            converged_count: converged,
163            failed_count: failed,
164            degraded_count: degraded,
165        })
166    }
167
168    /// Execute a single convergence point through its four boundary phases.
169    async fn execute_point(
170        point: &ConvergencePoint,
171        input_attestation: Option<&str>,
172    ) -> PointExecutionResult {
173        let start = Instant::now();
174
175        // Phase 1: PREPARE — verify preconditions + input attestation
176        debug!(point = %point.name, "boundary: preparing");
177        if let Some(input) = input_attestation {
178            if let Some(ref expected) = point.boundary.input_attestation {
179                if input != expected {
180                    return PointExecutionResult {
181                        point_id: PointId::compute(
182                            point.name.as_bytes(),
183                            &[],
184                            point.description.as_bytes(),
185                        ),
186                        outcome: ConvergenceOutcome::Failed {
187                            reason: format!(
188                                "input attestation mismatch: expected {expected}, got {input}"
189                            ),
190                        },
191                        attestation: None,
192                        duration: Duration::milliseconds(start.elapsed().as_millis() as i64),
193                        phase: BoundaryPhase::Failed {
194                            reason: "attestation mismatch".into(),
195                        },
196                    };
197                }
198            }
199        }
200        for check in &point.boundary.preconditions {
201            if !check.passed {
202                return PointExecutionResult {
203                    point_id: PointId::compute(
204                        point.name.as_bytes(),
205                        &[],
206                        point.description.as_bytes(),
207                    ),
208                    outcome: ConvergenceOutcome::Failed {
209                        reason: format!("precondition failed: {}", check.name),
210                    },
211                    attestation: None,
212                    duration: Duration::milliseconds(start.elapsed().as_millis() as i64),
213                    phase: BoundaryPhase::Failed {
214                        reason: format!("precondition: {}", check.name),
215                    },
216                };
217            }
218        }
219
220        // Phase 2: EXECUTE — drive convergence (placeholder for real driver dispatch)
221        debug!(point = %point.name, "boundary: executing");
222        // In the full implementation, this dispatches to the appropriate driver
223        // based on point configuration. For now, the point "converges" immediately.
224
225        // Phase 3: VERIFY — check postconditions
226        debug!(point = %point.name, "boundary: verifying");
227        for check in &point.boundary.postconditions {
228            if !check.passed {
229                warn!(
230                    point = %point.name,
231                    check = %check.name,
232                    "postcondition not yet passing — degraded convergence"
233                );
234                return PointExecutionResult {
235                    point_id: PointId::compute(
236                        point.name.as_bytes(),
237                        &[],
238                        point.description.as_bytes(),
239                    ),
240                    outcome: ConvergenceOutcome::Degraded {
241                        achieved: point.state.distance.clone(),
242                        missing: vec![check.name.clone()],
243                    },
244                    attestation: None,
245                    duration: Duration::milliseconds(start.elapsed().as_millis() as i64),
246                    phase: BoundaryPhase::Verifying,
247                };
248            }
249        }
250
251        // Phase 4: ATTEST — produce blake3 hash
252        debug!(point = %point.name, "boundary: attesting");
253        let attestation_data = format!(
254            "{}:{}:{}",
255            point.name,
256            input_attestation.unwrap_or("genesis"),
257            point.boundary.postconditions.len(),
258        );
259        let attestation = pillar_hash(attestation_data.as_bytes());
260
261        info!(
262            point = %point.name,
263            attestation = %attestation,
264            "boundary: attested"
265        );
266
267        PointExecutionResult {
268            point_id: PointId::compute(point.name.as_bytes(), &[], point.description.as_bytes()),
269            outcome: ConvergenceOutcome::Converged,
270            attestation: Some(attestation),
271            duration: Duration::milliseconds(start.elapsed().as_millis() as i64),
272            phase: BoundaryPhase::Attested,
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use tatara_core::domain::convergence_graph::*;
281    use tatara_core::domain::convergence_state::*;
282
283    fn make_point(name: &str) -> (PointId, ConvergencePoint) {
284        let id = PointId::compute(name.as_bytes(), &[], b"desired");
285
286        // Pre-pass all preconditions and postconditions
287        let mut pre = BoundaryCheck::new("ready", "point is ready");
288        pre.pass();
289        let mut post = BoundaryCheck::new("correct", "output is correct");
290        post.pass();
291
292        let point = ConvergencePoint {
293            name: name.into(),
294            description: format!("{name} convergence point"),
295            monotone: true,
296            mechanism: ConvergenceMechanism::Local,
297            state: ConvergenceState::new(name),
298            boundary: ConvergenceBoundary {
299                preconditions: vec![pre],
300                postconditions: vec![post],
301                input_attestation: None,
302                output_attestation: None,
303                phase: BoundaryPhase::Pending,
304            },
305            point_type: ConvergencePointType::Transform,
306            horizon: ConvergenceHorizon::Bounded,
307            substrate: SubstrateType::Compute,
308            computation_mode: ComputationMode::Mechanical,
309        };
310        (id, point)
311    }
312
313    #[tokio::test]
314    async fn test_single_point_execution() {
315        let mut graph = ConvergenceGraph::new();
316        let (id, point) = make_point("a");
317        graph.add_point(id, point);
318
319        let order = vec![id];
320        let result = DagExecutor::execute_graph(&graph, &order).await.unwrap();
321
322        assert_eq!(result.converged_count, 1);
323        assert_eq!(result.failed_count, 0);
324        assert!(result.final_attestation.is_some());
325    }
326
327    #[tokio::test]
328    async fn test_linear_chain_execution() {
329        let mut graph = ConvergenceGraph::new();
330        let (a, pa) = make_point("a");
331        let (b, pb) = make_point("b");
332        let (c, pc) = make_point("c");
333        graph.add_point(a, pa);
334        graph.add_point(b, pb);
335        graph.add_point(c, pc);
336        graph.add_edge(TypedEdge {
337            from: a,
338            to: b,
339            edge_type: EdgeType::Attestation,
340        });
341        graph.add_edge(TypedEdge {
342            from: b,
343            to: c,
344            edge_type: EdgeType::Attestation,
345        });
346
347        let order = graph.topological_order().unwrap();
348        let result = DagExecutor::execute_graph(&graph, &order).await.unwrap();
349
350        assert_eq!(result.converged_count, 3);
351        assert_eq!(result.failed_count, 0);
352        // All three should have attestations
353        for (_, outcome) in &result.outcomes {
354            assert!(outcome.attestation.is_some());
355            assert!(matches!(outcome.phase, BoundaryPhase::Attested));
356        }
357    }
358
359    #[tokio::test]
360    async fn test_failed_precondition_blocks() {
361        let mut graph = ConvergenceGraph::new();
362        let id = PointId::compute(b"fail", &[], b"desired");
363
364        // Don't pass the precondition
365        let pre = BoundaryCheck::new("not_ready", "not ready yet");
366        let mut post = BoundaryCheck::new("ok", "ok");
367        post.pass();
368
369        let point = ConvergencePoint {
370            name: "fail_point".into(),
371            description: "will fail".into(),
372            monotone: true,
373            mechanism: ConvergenceMechanism::Local,
374            state: ConvergenceState::new("fail_point"),
375            boundary: ConvergenceBoundary {
376                preconditions: vec![pre], // NOT passed
377                postconditions: vec![post],
378                input_attestation: None,
379                output_attestation: None,
380                phase: BoundaryPhase::Pending,
381            },
382            point_type: ConvergencePointType::Transform,
383            horizon: ConvergenceHorizon::Bounded,
384            substrate: SubstrateType::Compute,
385            computation_mode: ComputationMode::Mechanical,
386        };
387        graph.add_point(id, point);
388
389        let result = DagExecutor::execute_graph(&graph, &[id]).await.unwrap();
390        assert_eq!(result.failed_count, 1);
391        assert_eq!(result.converged_count, 0);
392    }
393
394    #[tokio::test]
395    async fn test_degraded_postcondition() {
396        let mut graph = ConvergenceGraph::new();
397        let id = PointId::compute(b"degrade", &[], b"desired");
398
399        let mut pre = BoundaryCheck::new("ready", "ready");
400        pre.pass();
401        // Don't pass postcondition
402        let post = BoundaryCheck::new("not_verified", "verification pending");
403
404        let point = ConvergencePoint {
405            name: "degrade_point".into(),
406            description: "will degrade".into(),
407            monotone: true,
408            mechanism: ConvergenceMechanism::Local,
409            state: ConvergenceState::new("degrade_point"),
410            boundary: ConvergenceBoundary {
411                preconditions: vec![pre],
412                postconditions: vec![post], // NOT passed
413                input_attestation: None,
414                output_attestation: None,
415                phase: BoundaryPhase::Pending,
416            },
417            point_type: ConvergencePointType::Transform,
418            horizon: ConvergenceHorizon::Bounded,
419            substrate: SubstrateType::Compute,
420            computation_mode: ComputationMode::Mechanical,
421        };
422        graph.add_point(id, point);
423
424        let result = DagExecutor::execute_graph(&graph, &[id]).await.unwrap();
425        assert_eq!(result.degraded_count, 1);
426    }
427
428    #[tokio::test]
429    async fn test_attestation_chain_integrity() {
430        let mut graph = ConvergenceGraph::new();
431        let (a, pa) = make_point("first");
432        let (b, pb) = make_point("second");
433        graph.add_point(a, pa);
434        graph.add_point(b, pb);
435        graph.add_edge(TypedEdge {
436            from: a,
437            to: b,
438            edge_type: EdgeType::Attestation,
439        });
440
441        let order = graph.topological_order().unwrap();
442        let result = DagExecutor::execute_graph(&graph, &order).await.unwrap();
443
444        // Both points should have attestations
445        let att_a = result.outcomes[&a].attestation.as_ref().unwrap();
446        let att_b = result.outcomes[&b].attestation.as_ref().unwrap();
447        // Attestations should be different (different inputs)
448        assert_ne!(att_a, att_b);
449        // Both should start with blake3:
450        assert!(att_a.starts_with("blake3:"));
451        assert!(att_b.starts_with("blake3:"));
452    }
453
454    #[tokio::test]
455    async fn test_diamond_dag_execution() {
456        let mut graph = ConvergenceGraph::new();
457        let (a, pa) = make_point("root");
458        let (b, pb) = make_point("left");
459        let (c, pc) = make_point("right");
460        let (d, pd) = make_point("join");
461        graph.add_point(a, pa);
462        graph.add_point(b, pb);
463        graph.add_point(c, pc);
464        graph.add_point(d, pd);
465        graph.add_edge(TypedEdge {
466            from: a,
467            to: b,
468            edge_type: EdgeType::Data,
469        });
470        graph.add_edge(TypedEdge {
471            from: a,
472            to: c,
473            edge_type: EdgeType::Data,
474        });
475        graph.add_edge(TypedEdge {
476            from: b,
477            to: d,
478            edge_type: EdgeType::Data,
479        });
480        graph.add_edge(TypedEdge {
481            from: c,
482            to: d,
483            edge_type: EdgeType::Data,
484        });
485
486        let order = graph.topological_order().unwrap();
487        let result = DagExecutor::execute_graph(&graph, &order).await.unwrap();
488
489        assert_eq!(result.converged_count, 4);
490        assert_eq!(result.failed_count, 0);
491    }
492
493    #[tokio::test]
494    async fn test_empty_graph() {
495        let graph = ConvergenceGraph::new();
496        let result = DagExecutor::execute_graph(&graph, &[]).await.unwrap();
497        assert_eq!(result.converged_count, 0);
498        assert_eq!(result.failed_count, 0);
499    }
500}