Skip to main content

somatize_compiler/
compiler.rs

1//! Graph → ExecutionPlan compiler.
2//!
3//! Compilation phases: topological sort → parallelism detection →
4//! cache resolution → schema validation → distribution wrapping → simplification.
5
6use crate::plan::ExecutionPlan;
7use somatize_core::cache::{CacheKey, CacheStore};
8use somatize_core::control::LoopCondition;
9use somatize_core::error::{Result, SomaError};
10use somatize_core::filter::{Filter, FilterMeta};
11use somatize_core::graph::{Graph, NodeId};
12use somatize_core::node::NodeMeta;
13use std::collections::{HashMap, HashSet};
14
15/// Compilation mode affects caching behavior.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum CompileMode {
18    /// Full caching: skip nodes whose outputs are cached.
19    Inference,
20    /// Cache states only: re-execute forwards for gradient flow.
21    Differentiable,
22    /// No caching at all: force re-execution of everything.
23    NoCache,
24}
25
26/// Diagnostic message emitted during compilation.
27#[derive(Debug, Clone)]
28pub struct Diagnostic {
29    /// The node the diagnostic is about.
30    pub node_id: NodeId,
31    /// How seriously to take it.
32    pub level: DiagnosticLevel,
33    /// Human-readable description of what the compiler noticed.
34    pub message: String,
35}
36
37/// Severity of a [`Diagnostic`]. Nothing here fails compilation — a
38/// condition worth stopping for is returned as an error, not collected.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DiagnosticLevel {
41    /// Probably not what the author intended (e.g. a gradient path broken
42    /// by a non-differentiable node); the plan still compiles.
43    Warning,
44    /// Worth knowing, nothing to fix.
45    Info,
46}
47
48/// Compiled result: the plan plus any diagnostics.
49#[derive(Debug)]
50pub struct CompileResult {
51    /// The executable plan the runtime walks.
52    pub plan: ExecutionPlan,
53    /// What the compiler noticed along the way; never fatal (see
54    /// [`DiagnosticLevel`]).
55    pub diagnostics: Vec<Diagnostic>,
56}
57
58/// Registry that maps node IDs to their metadata.
59///
60/// The compiler needs metadata (cacheable, differentiable, schemas) but
61/// not the implementations behind it. One required accessor, answering
62/// for both kinds of node: an optional `step_meta` alongside a required
63/// `meta` is how half the schema validation came to be skipped by
64/// whichever registry forgot to override it.
65pub trait NodeRegistry: Send + Sync {
66    /// A node's contract — schemas, cacheability, effectfulness — whichever
67    /// kind it is. `None` means the graph names a node nobody registered,
68    /// which the compiler reports rather than guesses around.
69    fn node_meta(&self, node_id: &str) -> Option<NodeMeta>;
70
71    /// The node's configuration identity, folded into cache keys. Required
72    /// rather than derived because only the registry knows how a node's
73    /// configuration is canonicalized (Rust: canonical CBOR of fields;
74    /// Python: qualname + config + source hash).
75    fn config_hash(&self, node_id: &str) -> Option<CacheKey>;
76
77    /// The computational view, for the phases that only make sense for a
78    /// filter: gradient flow, differentiable collapsing.
79    ///
80    /// `None` for an effectful node — deliberately. Those phases ask
81    /// "should a gradient pass through here", and the answer for a step
82    /// is not "no, and warn about it" but "the question does not apply".
83    fn meta(&self, node_id: &str) -> Option<FilterMeta> {
84        self.node_meta(node_id)
85            .filter(|m| !m.effectful)
86            .map(|m| m.as_filter_meta())
87    }
88}
89
90/// Simple in-memory node registry for compilation.
91pub struct SimpleNodeRegistry {
92    entries: HashMap<String, (NodeMeta, CacheKey)>,
93}
94
95impl SimpleNodeRegistry {
96    /// An empty registry; populate it with [`register`](Self::register),
97    /// [`register_meta`](Self::register_meta) or
98    /// [`register_step_meta`](Self::register_step_meta).
99    pub fn new() -> Self {
100        Self {
101            entries: HashMap::new(),
102        }
103    }
104
105    /// Register a step's metadata, so its schemas take part in validation.
106    pub fn register_step_meta(
107        &mut self,
108        node_id: impl Into<String>,
109        meta: somatize_core::step::StepMeta,
110    ) {
111        let id = node_id.into();
112        // A step's config hash is not derivable from its metadata; the
113        // compiler only needs one for cache resolution, which does not
114        // apply to an effectful node.
115        let hash = CacheKey::from_parts(&[b"step-meta", id.as_bytes()]);
116        self.entries.insert(id, (meta.into(), hash));
117    }
118
119    /// Register a filter, taking metadata and config hash from the
120    /// instance itself.
121    pub fn register(&mut self, node_id: impl Into<String>, filter: &dyn Filter) {
122        let id = node_id.into();
123        self.entries
124            .insert(id, (filter.meta().into(), filter.config_hash()));
125    }
126
127    /// Register filter metadata directly, for callers that have no filter
128    /// instance to hand — a plan received over the wire, a test.
129    pub fn register_meta(
130        &mut self,
131        node_id: impl Into<String>,
132        meta: FilterMeta,
133        config_hash: CacheKey,
134    ) {
135        self.entries
136            .insert(node_id.into(), (meta.into(), config_hash));
137    }
138}
139
140impl Default for SimpleNodeRegistry {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146impl NodeRegistry for SimpleNodeRegistry {
147    fn node_meta(&self, node_id: &str) -> Option<NodeMeta> {
148        self.entries.get(node_id).map(|(m, _)| m.clone())
149    }
150
151    fn config_hash(&self, node_id: &str) -> Option<CacheKey> {
152        self.entries.get(node_id).map(|(_, h)| h.clone())
153    }
154}
155
156/// Graph-wide analysis shared by every level of plan construction.
157///
158/// Both maps are computed once over the whole graph. Sub-plans (loop bodies,
159/// branch arms) project onto them rather than recomputing, so a node's
160/// position relative to the rest of the graph is the same wherever it is
161/// emitted.
162struct PlanCtx<'b> {
163    /// Topological level per node; nodes sharing a level are independent.
164    levels: HashMap<&'b str, usize>,
165    /// `dominators[n]` — every node that lies on all paths from a root to `n`.
166    dominators: HashMap<&'b str, HashSet<&'b str>>,
167}
168
169impl<'b> PlanCtx<'b> {
170    /// Does `d` lie on every path from a root to `n`? (Reflexive: `d` dominates itself.)
171    fn dominates(&self, d: &str, n: &str) -> bool {
172        self.dominators.get(n).is_some_and(|set| set.contains(d))
173    }
174
175    fn level_of(&self, n: &str) -> usize {
176        self.levels.get(n).copied().unwrap_or(0)
177    }
178
179    /// Group nodes into ordered levels, dropping levels the subset doesn't occupy.
180    fn group_by_level(&self, nodes: &[&'b str]) -> Vec<Vec<&'b str>> {
181        let mut by_level: Vec<(usize, Vec<&'b str>)> = Vec::new();
182        for &n in nodes {
183            let lvl = self.level_of(n);
184            match by_level.iter_mut().find(|(l, _)| *l == lvl) {
185                Some((_, bucket)) => bucket.push(n),
186                None => by_level.push((lvl, vec![n])),
187            }
188        }
189        by_level.sort_by_key(|(l, _)| *l);
190        by_level.into_iter().map(|(_, ns)| ns).collect()
191    }
192
193    /// Deterministic topological order: by level, then by id.
194    fn in_topo_order(&self, set: HashSet<&'b str>) -> Vec<&'b str> {
195        let mut out: Vec<&'b str> = set.into_iter().collect();
196        out.sort_by(|a, b| self.level_of(a).cmp(&self.level_of(b)).then(a.cmp(b)));
197        out
198    }
199}
200
201/// Compiles a Graph into an ExecutionPlan.
202pub struct Compiler<'a> {
203    graph: &'a Graph,
204    registry: &'a dyn NodeRegistry,
205    mode: CompileMode,
206    diagnostics: Vec<Diagnostic>,
207}
208
209impl<'a> Compiler<'a> {
210    /// A compiler over `graph`, reading node contracts from `registry`.
211    /// Nothing happens until [`compile`](Self::compile) is called.
212    pub fn new(graph: &'a Graph, registry: &'a dyn NodeRegistry, mode: CompileMode) -> Self {
213        Self {
214            graph,
215            registry,
216            mode,
217            diagnostics: Vec::new(),
218        }
219    }
220
221    /// Compile the graph into an execution plan.
222    pub fn compile(mut self, cache: Option<&dyn CacheStore>) -> Result<CompileResult> {
223        self.graph.validate()?;
224
225        let sorted = self.graph.topological_sort()?;
226
227        if sorted.is_empty() {
228            return Ok(CompileResult {
229                plan: ExecutionPlan::Empty,
230                diagnostics: self.diagnostics,
231            });
232        }
233
234        // Check gradient flow
235        self.check_gradient_flow(&sorted);
236
237        // Check the shape of the graph itself
238        self.check_connectivity();
239
240        // Validate schema compatibility
241        self.validate_schemas(&sorted)?;
242
243        let ctx = PlanCtx {
244            levels: self.compute_levels(&sorted),
245            dominators: self.compute_dominators(&sorted),
246        };
247
248        // Reject ambiguous control flow before it can become a silent default
249        self.validate_control_flow(&sorted, &ctx)?;
250
251        // Build the structural plan (detect parallelism)
252        let plan = self.plan_subset(&sorted, &ctx)?;
253
254        // The plan carries no `Cached` nodes: cache lookups are resolved at
255        // runtime, per node. A caller passing a cache gets a note saying so
256        // — this used to be a whole "phase" that transformed nothing.
257        if cache.is_some()
258            && self.mode != CompileMode::NoCache
259            && let Some(&first) = sorted.first()
260        {
261            self.diagnostics.push(Diagnostic {
262                node_id: first.to_string(),
263                level: DiagnosticLevel::Info,
264                message: "cache lookups are resolved at runtime per node \
265                          (key = hash(config + state + input)); the compiled plan \
266                          contains no Cached nodes"
267                    .to_string(),
268            });
269        }
270
271        // Resolve distribution (wrap Remote nodes)
272        let plan = self.resolve_distribution(plan);
273
274        // Collapse consecutive differentiable nodes into Composite blocks
275        let plan = self.collapse_differentiable(plan);
276
277        let plan = plan.simplify();
278
279        Ok(CompileResult {
280            plan,
281            diagnostics: self.diagnostics,
282        })
283    }
284
285    /// Reject control flow whose meaning would otherwise be decided at
286    /// runtime by whichever node happened to finish last.
287    fn validate_control_flow<'b>(&self, sorted: &[&'b str], ctx: &PlanCtx<'b>) -> Result<()> {
288        use somatize_core::graph::NodeKind;
289
290        let all: HashSet<&str> = sorted.iter().copied().collect();
291
292        for &node_id in sorted {
293            let Some(node) = self.graph.node(node_id) else {
294                continue;
295            };
296            match &node.kind {
297                NodeKind::Loop { until, .. } => {
298                    let body = self.claimed_subset(node_id, &all, ctx);
299                    if body.is_empty() {
300                        return Err(SomaError::Compilation(format!(
301                            "loop `{node_id}` has an empty body: it needs at least one \
302                             control edge to the node that starts each iteration"
303                        )));
304                    }
305                    if matches!(until, LoopCondition::BodyTerminal) {
306                        let terminals = self.body_terminals(&body);
307                        if terminals.len() != 1 {
308                            return Err(SomaError::Compilation(format!(
309                                "loop `{node_id}` cannot infer its stop condition: its body has \
310                                 {} terminal nodes ({}). Name the deciding node explicitly with \
311                                 `LoopCondition::WhenSignaled`, or use `LoopCondition::Exhaust` \
312                                 to always run `max_iterations` times",
313                                terminals.len(),
314                                terminals.join(", ")
315                            )));
316                        }
317                    }
318                    if let LoopCondition::WhenSignaled(target) = until
319                        && !body.contains(&target.as_str())
320                    {
321                        return Err(SomaError::Compilation(format!(
322                            "loop `{node_id}` waits on `{target}`, which is not in its body \
323                             ({}) — it would never be re-evaluated",
324                            body.join(", ")
325                        )));
326                    }
327                }
328                NodeKind::Branch { arms: declared } => {
329                    let edges = self.control_targets(node_id, &all);
330                    if edges.is_empty() {
331                        return Err(SomaError::Compilation(format!(
332                            "branch `{node_id}` has no arms: arms are the control edges \
333                             leaving it, each labelled with the value that selects it"
334                        )));
335                    }
336                    let mut seen: HashSet<String> = HashSet::new();
337                    for (target, label) in &edges {
338                        let label = label.clone().unwrap_or_else(|| target.to_string());
339                        if !seen.insert(label.clone()) {
340                            return Err(SomaError::Compilation(format!(
341                                "branch `{node_id}` has two arms labelled `{label}` — \
342                                 the second could never be selected"
343                            )));
344                        }
345                    }
346
347                    // When the node declares its label set, hold the edges to
348                    // it in both directions. A mislabelled edge is otherwise
349                    // an arm that simply never fires — visible only as a
350                    // wrong answer, and only sometimes.
351                    if !declared.is_empty() {
352                        let declared_set: HashSet<&str> =
353                            declared.iter().map(String::as_str).collect();
354
355                        for label in &seen {
356                            if !declared_set.contains(label.as_str())
357                                && !somatize_core::control::is_default_arm(label)
358                            {
359                                return Err(SomaError::Compilation(format!(
360                                    "branch `{node_id}` has an edge labelled `{label}`, which \
361                                     is not among its declared arms ({}). Fix the label, or \
362                                     declare it",
363                                    declared.join(", ")
364                                )));
365                            }
366                        }
367                        for label in declared {
368                            if !seen.contains(label) {
369                                return Err(SomaError::Compilation(format!(
370                                    "branch `{node_id}` declares arm `{label}` but no control \
371                                     edge is labelled with it, so selecting it would fail at \
372                                     runtime"
373                                )));
374                            }
375                        }
376                    }
377                }
378                _ => {}
379            }
380        }
381        Ok(())
382    }
383
384    /// Nodes in `body` that no other body node depends on.
385    fn body_terminals<'b>(&self, body: &[&'b str]) -> Vec<&'b str> {
386        let member: HashSet<&str> = body.iter().copied().collect();
387        body.iter()
388            .copied()
389            .filter(|n| {
390                !self
391                    .graph
392                    .successors(n)
393                    .iter()
394                    .any(|s| member.contains(s as &str))
395            })
396            .collect()
397    }
398
399    /// Resolve `BodyTerminal` to the concrete node the executor will read.
400    ///
401    /// `validate_control_flow` has already rejected a body without exactly
402    /// one terminal, so the error arm cannot fire. It is an error rather
403    /// than a fallback because the fallback was `Exhaust`: a debug build
404    /// asserted, and a release build quietly turned "stop when the body
405    /// says so" into "run the full iteration count" — a loop that costs N
406    /// times what it should, reported as success.
407    fn resolve_loop_condition(
408        &self,
409        node_id: &str,
410        until: &LoopCondition,
411        body: &[&str],
412    ) -> Result<LoopCondition> {
413        match until {
414            LoopCondition::BodyTerminal => match self.body_terminals(body).as_slice() {
415                [only] => Ok(LoopCondition::WhenSignaled((*only).to_string())),
416                terminals => Err(SomaError::Compilation(format!(
417                    "loop `{node_id}` stops on its body terminal, but the body has {} \
418                     of them{}. Name the one that decides with \
419                     `LoopCondition::WhenSignaled`, or use `Exhaust` to always run \
420                     the full count",
421                    terminals.len(),
422                    if terminals.is_empty() {
423                        String::new()
424                    } else {
425                        format!(" ({})", terminals.join(", "))
426                    }
427                ))),
428            },
429            other => Ok(other.clone()),
430        }
431    }
432
433    /// Plan a set of nodes, emitting each exactly once.
434    ///
435    /// A `Loop` or `Branch` in the set *owns* its body / arms: those nodes are
436    /// compiled inside the construct and excluded from this level. Without
437    /// that exclusion the body would run once more after the loop finished,
438    /// and every arm would run unconditionally after the branch had already
439    /// picked one.
440    fn plan_subset<'b>(&self, nodes: &[&'b str], ctx: &PlanCtx<'b>) -> Result<ExecutionPlan> {
441        let member: HashSet<&str> = nodes.iter().copied().collect();
442
443        let mut owned: HashSet<&str> = HashSet::new();
444        for &n in nodes {
445            for m in self.owned_by(n, &member, ctx) {
446                owned.insert(m);
447            }
448        }
449
450        // Nodes not claimed by a construct, grouped by their graph-wide
451        // topological level so relative ordering survives the projection.
452        let top: Vec<&str> = nodes
453            .iter()
454            .copied()
455            .filter(|n| !owned.contains(n))
456            .collect();
457
458        let mut plan_steps: Vec<ExecutionPlan> = Vec::new();
459        for level in ctx.group_by_level(&top) {
460            if level.len() == 1 {
461                plan_steps.push(self.plan_for_node(level[0], ctx)?);
462            } else {
463                let branches: Vec<ExecutionPlan> = level
464                    .iter()
465                    .map(|id| self.plan_for_node(id, ctx))
466                    .collect::<Result<_>>()?;
467                plan_steps.push(ExecutionPlan::Parallel(branches));
468            }
469        }
470
471        Ok(match plan_steps.len() {
472            0 => ExecutionPlan::Empty,
473            1 => plan_steps.into_iter().next().unwrap(),
474            _ => ExecutionPlan::Sequence(plan_steps),
475        })
476    }
477
478    /// The nodes a control-flow construct claims from `member`.
479    ///
480    /// Both `Loop` and `Branch` reach their sub-plans through **control**
481    /// edges; a data edge leaving either is an ordinary downstream dependency.
482    /// A target claims every node it dominates, so a node reachable from two
483    /// arms is dominated by neither and stays outside — running once after the
484    /// branch, which is what a convergence point should do.
485    fn owned_by<'b>(
486        &self,
487        node_id: &'b str,
488        member: &HashSet<&'b str>,
489        ctx: &PlanCtx<'b>,
490    ) -> Vec<&'b str> {
491        use somatize_core::graph::NodeKind;
492
493        let Some(node) = self.graph.node(node_id) else {
494            return Vec::new();
495        };
496        if !matches!(
497            node.kind,
498            NodeKind::Loop { .. } | NodeKind::Branch { .. } | NodeKind::Step { .. }
499        ) {
500            return Vec::new();
501        }
502
503        let mut claimed = Vec::new();
504        for (entry, _) in self.control_targets(node_id, member) {
505            for &m in member {
506                if m != node_id && ctx.dominates(entry, m) {
507                    claimed.push(m);
508                }
509            }
510        }
511        claimed
512    }
513
514    /// Targets of control edges leaving `node_id`, restricted to `member`.
515    fn control_targets<'b>(
516        &self,
517        node_id: &str,
518        member: &HashSet<&'b str>,
519    ) -> Vec<(&'b str, Option<String>)> {
520        use somatize_core::graph::EdgeKind;
521
522        self.graph
523            .edges
524            .iter()
525            .filter(|e| e.source == node_id && e.kind == EdgeKind::Control)
526            .filter_map(|e| member.get(e.target.as_str()).map(|t| (*t, e.label.clone())))
527            .collect()
528    }
529
530    /// Generate the execution plan for a single node based on its kind.
531    fn plan_for_node<'b>(&self, node_id: &'b str, ctx: &PlanCtx<'b>) -> Result<ExecutionPlan> {
532        use somatize_core::graph::NodeKind;
533
534        let node = match self.graph.node(node_id) {
535            Some(n) => n,
536            None => {
537                return Ok(ExecutionPlan::Execute {
538                    node_id: node_id.to_string(),
539                });
540            }
541        };
542
543        Ok(match &node.kind {
544            NodeKind::Filter { .. } => ExecutionPlan::Execute {
545                node_id: node_id.to_string(),
546            },
547
548            NodeKind::Step { .. } => {
549                // Control edges leaving a step are the places it may hand
550                // control to. Claimed the same way branch arms are, so each
551                // target is compiled once, inside the step that reaches it.
552                let all: HashSet<&str> = ctx.levels.keys().copied().collect();
553                let handoffs: Vec<(NodeId, ExecutionPlan)> = self
554                    .control_targets(node_id, &all)
555                    .into_iter()
556                    .map(|(target, _)| {
557                        let nodes = self.dominated_subset(target, &all, ctx);
558                        Ok((target.to_string(), self.plan_subset(&nodes, ctx)?))
559                    })
560                    .collect::<Result<_>>()?;
561                ExecutionPlan::Step {
562                    node_id: node_id.to_string(),
563                    handoffs,
564                }
565            }
566
567            NodeKind::SubGraph { graph } => {
568                // Recursively compile the inner graph. An inner error is this
569                // graph's error: the old fallback emitted a bare `Execute` for
570                // the node, which deferred the failure to runtime under a
571                // different name — inconsistent with the unknown-kind arm
572                // below, which refuses rather than guesses.
573                Compiler::new(graph, self.registry, self.mode)
574                    .compile(None)?
575                    .plan
576            }
577
578            NodeKind::Loop {
579                max_iterations,
580                until,
581            } => {
582                let all: HashSet<&str> = ctx.levels.keys().copied().collect();
583                let body_nodes = self.claimed_subset(node_id, &all, ctx);
584                let body = if body_nodes.is_empty() {
585                    ExecutionPlan::Empty
586                } else {
587                    self.plan_subset(&body_nodes, ctx)?
588                };
589                ExecutionPlan::Loop {
590                    node_id: node_id.to_string(),
591                    body: Box::new(body),
592                    max_iterations: *max_iterations,
593                    until: self.resolve_loop_condition(node_id, until, &body_nodes)?,
594                    // Whatever the stop condition is, a single-terminal body
595                    // has one obvious thing to hand to the next pass.
596                    carry_from: match self.body_terminals(&body_nodes).as_slice() {
597                        [only] => Some((*only).to_string()),
598                        _ => None,
599                    },
600                }
601            }
602
603            NodeKind::Branch { .. } => {
604                let all: HashSet<&str> = ctx.levels.keys().copied().collect();
605                let arms: Vec<(String, ExecutionPlan)> = self
606                    .control_targets(node_id, &all)
607                    .into_iter()
608                    .map(|(target, label)| {
609                        let label = label.unwrap_or_else(|| target.to_string());
610                        let arm_nodes = self.dominated_subset(target, &all, ctx);
611                        Ok((label, self.plan_subset(&arm_nodes, ctx)?))
612                    })
613                    .collect::<Result<_>>()?;
614                ExecutionPlan::Branch {
615                    node_id: node_id.to_string(),
616                    arms,
617                }
618            }
619
620            // `NodeKind` is `#[non_exhaustive]` and lives in another crate,
621            // so this arm cannot be deleted — but it must not stay silent.
622            // Falling through to `Execute` compiled an unknown kind as a
623            // plain filter: a loop that never iterated, a step that was
624            // never driven, and no diagnostic anywhere. Refusing to plan
625            // what this compiler does not understand is the only safe
626            // answer, and it turns a future omission into a clear error.
627            other => {
628                return Err(SomaError::Compilation(format!(
629                    "node `{node_id}` has kind {other:?}, which this compiler \
630                     does not know how to plan; the runtime would have run it \
631                     as an ordinary filter"
632                )));
633            }
634        })
635    }
636
637    /// Every node claimed by `node_id`'s control edges, in topological order.
638    fn claimed_subset<'b>(
639        &self,
640        node_id: &'b str,
641        universe: &HashSet<&'b str>,
642        ctx: &PlanCtx<'b>,
643    ) -> Vec<&'b str> {
644        let mut claimed: HashSet<&str> = HashSet::new();
645        for (entry, _) in self.control_targets(node_id, universe) {
646            claimed.extend(self.dominated_subset(entry, universe, ctx));
647        }
648        ctx.in_topo_order(claimed)
649    }
650
651    /// `entry` plus everything it dominates, in topological order.
652    fn dominated_subset<'b>(
653        &self,
654        entry: &'b str,
655        universe: &HashSet<&'b str>,
656        ctx: &PlanCtx<'b>,
657    ) -> Vec<&'b str> {
658        let set: HashSet<&str> = universe
659            .iter()
660            .copied()
661            .filter(|&m| ctx.dominates(entry, m))
662            .collect();
663        ctx.in_topo_order(set)
664    }
665
666    /// Topological level of each node: `max(predecessor levels) + 1`.
667    /// Nodes sharing a level have no dependency between them.
668    fn compute_levels<'b>(&self, sorted: &[&'b str]) -> HashMap<&'b str, usize> {
669        let mut node_level: HashMap<&str, usize> = HashMap::new();
670
671        for &node in sorted {
672            let preds = self.graph.predecessors(node);
673            let level = if preds.is_empty() {
674                0
675            } else {
676                preds
677                    .iter()
678                    .map(|p| node_level.get(p).copied().unwrap_or(0) + 1)
679                    .max()
680                    .unwrap_or(0)
681            };
682            node_level.insert(node, level);
683        }
684
685        node_level
686    }
687
688    /// Dominator sets over the DAG: `d` dominates `n` when every path from a
689    /// root to `n` passes through `d`. Computed in topological order as
690    /// `dom(n) = {n} ∪ ⋂ dom(pred)`.
691    fn compute_dominators<'b>(&self, sorted: &[&'b str]) -> HashMap<&'b str, HashSet<&'b str>> {
692        let mut dom: HashMap<&str, HashSet<&str>> = HashMap::new();
693
694        for &node in sorted {
695            let preds = self.graph.predecessors(node);
696            let mut set: HashSet<&str> = HashSet::new();
697
698            let mut pred_sets = preds.iter().filter_map(|p| dom.get(p));
699            if let Some(first) = pred_sets.next() {
700                set = first.clone();
701                for other in pred_sets {
702                    set.retain(|d| other.contains(d));
703                }
704            }
705            set.insert(node);
706            dom.insert(node, set);
707        }
708
709        dom
710    }
711
712    /// Cache resolution happens at RUNTIME, not here.
713    ///
714    /// The compiler never sees the dataset, so any key it could derive
715    /// (formerly `H(config ‖ predecessor keys)`) is independent of the
716    /// input data — the same graph on two different datasets would
717    /// collide. The executor computes the real key
718    /// `hash(config + state + input)` per node with the materialized
719    /// input in hand, and skips execution on a hit.
720    /// Wrap nodes with Remote distribution in ExecutionPlan::Remote.
721    fn resolve_distribution(&self, plan: ExecutionPlan) -> ExecutionPlan {
722        match plan {
723            ExecutionPlan::Execute { ref node_id } | ExecutionPlan::Step { ref node_id, .. } => {
724                if let Some(meta) = self.registry.node_meta(node_id) {
725                    match &meta.distribution {
726                        somatize_core::filter::Distribution::Remote(target) => {
727                            ExecutionPlan::Remote {
728                                node_id: node_id.clone(),
729                                target: target.clone(),
730                                plan: Box::new(plan),
731                            }
732                        }
733                        _ => plan,
734                    }
735                } else {
736                    plan
737                }
738            }
739            ExecutionPlan::Sequence(steps) => ExecutionPlan::Sequence(
740                steps
741                    .into_iter()
742                    .map(|s| self.resolve_distribution(s))
743                    .collect(),
744            ),
745            ExecutionPlan::Parallel(branches) => ExecutionPlan::Parallel(
746                branches
747                    .into_iter()
748                    .map(|b| self.resolve_distribution(b))
749                    .collect(),
750            ),
751            ExecutionPlan::Composite { ref node_ids } => {
752                // If ALL nodes in the composite have a Remote target, wrap the
753                // entire composite in a single Remote (using the first node's
754                // target). Otherwise keep it local.
755                let targets: Vec<_> = node_ids
756                    .iter()
757                    .filter_map(|nid| {
758                        self.registry
759                            .node_meta(nid)
760                            .and_then(|m| match &m.distribution {
761                                somatize_core::filter::Distribution::Remote(t) => Some(t.clone()),
762                                _ => None,
763                            })
764                    })
765                    .collect();
766
767                if targets.len() == node_ids.len() && !targets.is_empty() {
768                    let first_id = node_ids[0].clone();
769                    ExecutionPlan::Remote {
770                        node_id: first_id,
771                        target: targets.into_iter().next().unwrap(),
772                        plan: Box::new(plan),
773                    }
774                } else {
775                    plan
776                }
777            }
778            other => other,
779        }
780    }
781
782    /// Collapse consecutive differentiable Execute nodes into Composite blocks.
783    ///
784    /// A `Composite` groups nodes that should share a PyTorch autograd session.
785    /// Only groups 2+ consecutive `Execute` nodes where `meta.differentiable == true`.
786    fn collapse_differentiable(&self, plan: ExecutionPlan) -> ExecutionPlan {
787        match plan {
788            ExecutionPlan::Sequence(steps) => {
789                let mut result: Vec<ExecutionPlan> = Vec::new();
790                let mut diff_group: Vec<String> = Vec::new();
791
792                for step in steps {
793                    if let ExecutionPlan::Execute { ref node_id } = step
794                        && self
795                            .registry
796                            .meta(node_id)
797                            .map(|m| m.differentiable)
798                            .unwrap_or(false)
799                    {
800                        diff_group.push(node_id.clone());
801                        continue;
802                    }
803                    // Flush accumulated differentiable group
804                    Self::flush_diff_group(&mut diff_group, &mut result);
805                    result.push(self.collapse_differentiable(step));
806                }
807                Self::flush_diff_group(&mut diff_group, &mut result);
808
809                if result.len() == 1 {
810                    result.pop().unwrap()
811                } else {
812                    ExecutionPlan::Sequence(result)
813                }
814            }
815            ExecutionPlan::Parallel(branches) => ExecutionPlan::Parallel(
816                branches
817                    .into_iter()
818                    .map(|b| self.collapse_differentiable(b))
819                    .collect(),
820            ),
821            ExecutionPlan::Remote {
822                node_id,
823                target,
824                plan,
825            } => ExecutionPlan::Remote {
826                node_id,
827                target,
828                plan: Box::new(self.collapse_differentiable(*plan)),
829            },
830            other => other,
831        }
832    }
833
834    fn flush_diff_group(group: &mut Vec<String>, result: &mut Vec<ExecutionPlan>) {
835        if group.len() > 1 {
836            result.push(ExecutionPlan::Composite {
837                node_ids: std::mem::take(group),
838            });
839        } else if let Some(id) = group.pop() {
840            result.push(ExecutionPlan::Execute { node_id: id });
841        }
842    }
843
844    /// Validate schema compatibility between connected filters.
845    ///
846    /// For each edge (A → B), checks that A's output_schema is compatible
847    /// with B's input_schema. Emits warnings (not errors) for mismatches,
848    /// since schemas are optional and None means "accepts anything".
849    /// What a node accepts, whether it is a filter or a step.
850    fn input_schema_of(&self, node_id: &str) -> Option<somatize_core::schema::Schema> {
851        self.registry
852            .node_meta(node_id)
853            .and_then(|m| m.input_schema)
854    }
855
856    /// What a node produces, whether it is a filter or a step.
857    fn output_schema_of(&self, node_id: &str) -> Option<somatize_core::schema::Schema> {
858        self.registry
859            .node_meta(node_id)
860            .and_then(|m| m.output_schema)
861    }
862
863    /// Check that every edge could carry what flows along it.
864    ///
865    /// Two severities, because two very different things get called a
866    /// "schema mismatch":
867    ///
868    /// - **Warning** — the dtypes differ but could plausibly line up (`f32`
869    ///   into `f64`, a fixed shape into a dynamic one). Long-standing
870    ///   behaviour; plenty of working pipelines rely on it.
871    /// - **Error** — no reading of the producer could satisfy the consumer:
872    ///   a tensor arriving where a conversation is expected. Across 1600+
873    ///   annotated multi-agent traces this class — context lost or malformed
874    ///   at a handoff — is the single largest bucket of failures after bad
875    ///   specifications. It is cheap to catch here and expensive to catch
876    ///   after a few thousand tokens.
877    fn validate_schemas(&mut self, sorted: &[&str]) -> Result<()> {
878        for &node_id in sorted {
879            // Skip if this node accepts anything
880            let Some(expected_input) = self.input_schema_of(node_id) else {
881                continue;
882            };
883
884            for pred_id in self.graph.predecessors(node_id) {
885                let Some(actual_output) = self.output_schema_of(pred_id) else {
886                    continue; // predecessor output unknown, skip
887                };
888
889                // No possible reading — refuse to build the graph.
890                if actual_output.is_incompatible_with(&expected_input) {
891                    return Err(SomaError::Compilation(format!(
892                        "`{pred_id}` outputs {actual_output} but `{node_id}` expects \
893                         {expected_input}, and there is no conversion between them. \
894                         Insert a node that adapts one to the other"
895                    )));
896                }
897
898                let same_dtype = actual_output.dtype == expected_input.dtype;
899                let both_numeric =
900                    actual_output.dtype.is_numeric() && expected_input.dtype.is_numeric();
901
902                // Warn on a shape that does not line up, or on an implicit
903                // change of numeric width. Stay quiet about the promotions the
904                // runtime performs by design (text → conversation, anything →
905                // json): those are the intended way to connect such nodes, and
906                // warning about them would train people to ignore warnings.
907                if (same_dtype && !actual_output.is_compatible_with(&expected_input))
908                    || (!same_dtype && both_numeric)
909                {
910                    self.diagnostics.push(Diagnostic {
911                        node_id: node_id.to_string(),
912                        level: DiagnosticLevel::Warning,
913                        message: format!(
914                            "schema mismatch: `{pred_id}` outputs {actual_output} \
915                             but `{node_id}` expects {expected_input}",
916                        ),
917                    });
918                }
919            }
920        }
921        Ok(())
922    }
923
924    /// Check gradient flow and emit warnings for each interruption.
925    ///
926    /// Gradient flow can restart after an opaque node (differentiable nodes
927    /// after an opaque one can still propagate gradients among themselves),
928    /// but gradients from before the interruption are lost.
929    /// Report parts of the architecture that are not wired into it.
930    ///
931    /// A node with no edges at all is silently a second root: roots receive
932    /// the graph's input, so it runs, on data it was never meant to see,
933    /// and its output goes nowhere. The DSL makes this easy to write by
934    /// accident, because Python binds `>>` tighter than `|` — the fork in
935    /// `A() | B() >> C()` is `A() | (B() >> C())`, and `A` is left
936    /// dangling. Nothing else catches it: it is not a cycle, not a
937    /// duplicate id, not a dangling edge endpoint, and the schemas of a
938    /// node nobody feeds are trivially satisfied.
939    ///
940    /// A leaf — a node whose output nobody consumes — is Info rather than
941    /// Warning, because fan-out to several leaves is a legitimate shape.
942    /// It is worth saying only when there is more than one, since `forward`
943    /// returns the leaf that actually ran and the others are computed and
944    /// dropped.
945    fn check_connectivity(&mut self) {
946        if self.graph.nodes.len() < 2 {
947            return;
948        }
949
950        let mut leaves = Vec::new();
951        for node in &self.graph.nodes {
952            let id = node.id.as_str();
953            let has_input = !self.graph.predecessors(id).is_empty();
954            let has_output = !self.graph.successors(id).is_empty();
955
956            if !has_input && !has_output {
957                self.diagnostics.push(Diagnostic {
958                    node_id: id.to_string(),
959                    level: DiagnosticLevel::Warning,
960                    message: format!(
961                        "`{id}` has no edges. It is therefore a root: it will run on the \
962                         graph's input, and its output will be discarded. If it was meant \
963                         to be part of the pipeline, connect it; if it is a spawn target, \
964                         register it with `register_step` instead of adding a node."
965                    ),
966                });
967            } else if !has_output {
968                leaves.push(id.to_string());
969            }
970        }
971
972        if leaves.len() > 1 {
973            self.diagnostics.push(Diagnostic {
974                node_id: leaves[0].clone(),
975                level: DiagnosticLevel::Info,
976                message: format!(
977                    "{} nodes produce output nobody consumes ({}). `forward` returns the \
978                     leaf that actually ran; the others are computed and dropped.",
979                    leaves.len(),
980                    leaves.join(", "),
981                ),
982            });
983        }
984    }
985
986    fn check_gradient_flow(&mut self, sorted: &[&str]) {
987        // Starts false, not true. A non-differentiable node only interrupts
988        // a gradient if there is one to interrupt — and the first node in
989        // topological order has nothing upstream of it. Starting true made
990        // every graph of ordinary filters warn about its own first node,
991        // which is most preprocessing pipelines, and a warning that fires
992        // on correct code teaches people to stop reading warnings.
993        let mut gradient_flows = false;
994
995        for &node_id in sorted {
996            if let Some(meta) = self.registry.meta(node_id) {
997                if gradient_flows && !meta.differentiable {
998                    self.diagnostics.push(Diagnostic {
999                        node_id: node_id.to_string(),
1000                        level: DiagnosticLevel::Warning,
1001                        message: format!(
1002                            "gradient flow interrupted at `{}` ({:?}). \
1003                             Gradients from upstream will not reach downstream filters \
1004                             through this node.",
1005                            node_id, meta.kind,
1006                        ),
1007                    });
1008                    gradient_flows = false;
1009                } else if !gradient_flows && meta.differentiable {
1010                    // Gradient flow restarts: differentiable nodes after the
1011                    // interruption can propagate gradients among themselves
1012                    gradient_flows = true;
1013                }
1014            }
1015        }
1016    }
1017}
1018
1019/// Convenience function: compile a graph with default settings.
1020pub fn compile(
1021    graph: &Graph,
1022    registry: &dyn NodeRegistry,
1023    mode: CompileMode,
1024    cache: Option<&dyn CacheStore>,
1025) -> Result<CompileResult> {
1026    Compiler::new(graph, registry, mode).compile(cache)
1027}
1028
1029/// Compile a graph for streaming execution.
1030///
1031/// Produces an `ExecutionPlan::Stream` wrapping the topologically sorted
1032/// node chain, which the runtime executes chunk by chunk through the
1033/// same primitives `run_node` uses.
1034///
1035/// Streaming executes a single linear chain of filters — it used to
1036/// accept any DAG and silently run it as a chain, which for a diamond
1037/// is simply the wrong answer. So this validates what the executor can
1038/// honour:
1039///
1040/// - every node has at most one predecessor and one successor;
1041/// - no node is a step (the effect journal keys by `(run, node, turn)`,
1042///   so chunk 2 would replay chunk 1's effects — no defensible
1043///   semantics);
1044/// - `chunk_size > 0`.
1045pub fn compile_stream(
1046    graph: &Graph,
1047    registry: &dyn NodeRegistry,
1048    chunk_size: usize,
1049) -> Result<CompileResult> {
1050    graph.validate()?;
1051    let sorted = graph.topological_sort()?;
1052
1053    if sorted.is_empty() {
1054        return Ok(CompileResult {
1055            plan: ExecutionPlan::Empty,
1056            diagnostics: Vec::new(),
1057        });
1058    }
1059
1060    if chunk_size == 0 {
1061        return Err(SomaError::Compilation(
1062            "stream chunk_size must be at least 1".into(),
1063        ));
1064    }
1065
1066    for id in &sorted {
1067        let (preds, succs) = (graph.predecessors(id), graph.successors(id));
1068        if preds.len() > 1 || succs.len() > 1 {
1069            return Err(SomaError::Compilation(format!(
1070                "streaming executes a single linear chain; node `{id}` has {} \
1071                 predecessors and {} successors — restructure the graph or use \
1072                 the standard forward",
1073                preds.len(),
1074                succs.len(),
1075            )));
1076        }
1077        match registry.node_meta(id) {
1078            Some(meta) if meta.effectful => {
1079                return Err(SomaError::Compilation(format!(
1080                    "step `{id}` cannot be streamed: effect journaling has no \
1081                     per-chunk semantics. Run the graph with the standard forward"
1082                )));
1083            }
1084            Some(_) => {}
1085            None => {
1086                return Err(SomaError::Compilation(format!(
1087                    "graph names node `{id}` but nothing with that id is registered"
1088                )));
1089            }
1090        }
1091    }
1092
1093    let node_ids: Vec<NodeId> = sorted.into_iter().map(|s| s.to_string()).collect();
1094    let plan = ExecutionPlan::Stream {
1095        node_ids,
1096        chunk_size,
1097    };
1098
1099    Ok(CompileResult {
1100        plan,
1101        diagnostics: Vec::new(),
1102    })
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107    use super::*;
1108    use somatize_core::cache::EntryMeta;
1109    use somatize_core::error::SomaError;
1110    use somatize_core::filter::{FilterKind, StreamMode};
1111    use somatize_core::graph::{Edge, Graph, Node, linear_pipeline};
1112    use somatize_core::value::Value;
1113    use std::collections::HashSet;
1114    use std::sync::Mutex;
1115
1116    // ── Mock cache store ──
1117
1118    struct MockCacheStore {
1119        entries: Mutex<HashSet<CacheKey>>,
1120    }
1121
1122    impl MockCacheStore {
1123        fn new() -> Self {
1124            Self {
1125                entries: Mutex::new(HashSet::new()),
1126            }
1127        }
1128
1129        fn insert(&self, key: CacheKey) {
1130            self.entries.lock().unwrap().insert(key);
1131        }
1132    }
1133
1134    impl CacheStore for MockCacheStore {
1135        fn get(&self, _key: &CacheKey) -> Result<Option<Value>> {
1136            Ok(None)
1137        }
1138        fn put(&self, _key: &CacheKey, _value: &Value) -> Result<()> {
1139            Ok(())
1140        }
1141        fn exists(&self, key: &CacheKey) -> Result<bool> {
1142            Ok(self.entries.lock().unwrap().contains(key))
1143        }
1144        fn remove(&self, _key: &CacheKey) -> Result<()> {
1145            Ok(())
1146        }
1147        fn metadata(&self, _key: &CacheKey) -> Result<Option<EntryMeta>> {
1148            Ok(None)
1149        }
1150    }
1151
1152    // ── Helpers ──
1153
1154    fn make_meta(kind: FilterKind, differentiable: bool) -> FilterMeta {
1155        FilterMeta {
1156            name: "test".into(),
1157            kind,
1158            cacheable: true,
1159            differentiable,
1160            deterministic: true,
1161            stream_mode: StreamMode::FixedState,
1162            distribution: somatize_core::filter::Distribution::Local,
1163            input_schema: None,
1164            output_schema: None,
1165        }
1166    }
1167
1168    fn register_nodes(registry: &mut SimpleNodeRegistry, ids: &[&str], meta: FilterMeta) {
1169        for (i, id) in ids.iter().enumerate() {
1170            let hash = CacheKey::from_parts(&[id.as_bytes(), &[i as u8]]);
1171            registry.register_meta(*id, meta.clone(), hash);
1172        }
1173    }
1174
1175    // ── Tests ──
1176
1177    #[test]
1178    fn compile_empty_graph() {
1179        let graph = Graph::new();
1180        let registry = SimpleNodeRegistry::new();
1181        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1182        assert!(matches!(result.plan, ExecutionPlan::Empty));
1183    }
1184
1185    #[test]
1186    fn compile_single_node() {
1187        let mut graph = Graph::new();
1188        graph.add_node(Node::new("a", "A", "F"));
1189        let mut registry = SimpleNodeRegistry::new();
1190        register_nodes(
1191            &mut registry,
1192            &["a"],
1193            make_meta(FilterKind::Trainable, true),
1194        );
1195
1196        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1197        assert!(matches!(result.plan, ExecutionPlan::Execute { .. }));
1198    }
1199
1200    #[test]
1201    fn compile_linear_pipeline_produces_sequence() {
1202        let graph = linear_pipeline(vec![
1203            Node::new("a", "Scaler", "F"),
1204            Node::new("b", "PCA", "F"),
1205            Node::new("c", "SVM", "F"),
1206        ]);
1207        let mut registry = SimpleNodeRegistry::new();
1208        register_nodes(
1209            &mut registry,
1210            &["a", "b", "c"],
1211            make_meta(FilterKind::Trainable, true),
1212        );
1213
1214        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1215
1216        // All 3 nodes are differentiable → collapsed into Composite
1217        if let ExecutionPlan::Composite { node_ids } = &result.plan {
1218            assert_eq!(node_ids, &["a", "b", "c"]);
1219        } else {
1220            panic!("expected Composite, got: {:?}", result.plan);
1221        }
1222    }
1223
1224    #[test]
1225    fn compile_diamond_detects_parallelism() {
1226        let mut graph = Graph::new();
1227        graph.add_node(Node::new("root", "Root", "F"));
1228        graph.add_node(Node::new("b1", "B1", "F"));
1229        graph.add_node(Node::new("b2", "B2", "F"));
1230        graph.add_node(Node::new("merge", "Merge", "F"));
1231        graph.add_edge(Edge::data("e1", "root", "b1"));
1232        graph.add_edge(Edge::data("e2", "root", "b2"));
1233        graph.add_edge(Edge::data("e3", "b1", "merge"));
1234        graph.add_edge(Edge::data("e4", "b2", "merge"));
1235
1236        let mut registry = SimpleNodeRegistry::new();
1237        register_nodes(
1238            &mut registry,
1239            &["root", "b1", "b2", "merge"],
1240            make_meta(FilterKind::Trainable, true),
1241        );
1242
1243        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1244
1245        // Should be: Sequence(Execute(root), Parallel(Execute(b1), Execute(b2)), Execute(merge))
1246        if let ExecutionPlan::Sequence(steps) = &result.plan {
1247            assert_eq!(steps.len(), 3);
1248            assert!(matches!(&steps[0], ExecutionPlan::Execute { node_id } if node_id == "root"));
1249            assert!(matches!(&steps[1], ExecutionPlan::Parallel(branches) if branches.len() == 2));
1250            assert!(matches!(&steps[2], ExecutionPlan::Execute { node_id } if node_id == "merge"));
1251        } else {
1252            panic!("expected Sequence, got: {:?}", result.plan);
1253        }
1254    }
1255
1256    #[test]
1257    fn compile_independent_roots_parallel() {
1258        let mut graph = Graph::new();
1259        graph.add_node(Node::new("a", "A", "F"));
1260        graph.add_node(Node::new("b", "B", "F"));
1261        // No edges: fully independent
1262
1263        let mut registry = SimpleNodeRegistry::new();
1264        register_nodes(
1265            &mut registry,
1266            &["a", "b"],
1267            make_meta(FilterKind::Trainable, true),
1268        );
1269
1270        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1271
1272        // Both at level 0 → Parallel
1273        assert!(matches!(result.plan, ExecutionPlan::Parallel(_)));
1274    }
1275
1276    #[test]
1277    fn cache_resolution_is_deferred_to_runtime() {
1278        let graph = linear_pipeline(vec![
1279            Node::new("a", "Scaler", "F"),
1280            Node::new("b", "PCA", "F"),
1281            Node::new("c", "SVM", "F"),
1282        ]);
1283
1284        let mut registry = SimpleNodeRegistry::new();
1285        register_nodes(
1286            &mut registry,
1287            &["a", "b", "c"],
1288            make_meta(FilterKind::Trainable, true),
1289        );
1290
1291        // Even with a populated cache, the compiler must never emit
1292        // Cached nodes: its keys cannot include the input data, so a
1293        // compile-time hit could serve results from a different dataset.
1294        // The executor resolves cache hits per node at runtime.
1295        let a_config = registry.config_hash("a").unwrap();
1296        let a_cache_key = CacheKey::from_parts(&[&a_config.0]);
1297        let cache = MockCacheStore::new();
1298        cache.insert(a_cache_key);
1299
1300        let result = compile(&graph, &registry, CompileMode::Inference, Some(&cache)).unwrap();
1301
1302        assert!(
1303            !format!("{:?}", result.plan).contains("Cached"),
1304            "compiler must not emit Cached nodes, got: {:?}",
1305            result.plan
1306        );
1307        assert!(
1308            result
1309                .diagnostics
1310                .iter()
1311                .any(|d| d.level == DiagnosticLevel::Info
1312                    && d.message.contains("resolved at runtime")),
1313            "expected an informational diagnostic about runtime cache resolution"
1314        );
1315    }
1316
1317    #[test]
1318    fn no_cache_mode_skips_all_caching() {
1319        let graph = linear_pipeline(vec![Node::new("a", "A", "F"), Node::new("b", "B", "F")]);
1320
1321        let mut registry = SimpleNodeRegistry::new();
1322        register_nodes(
1323            &mut registry,
1324            &["a", "b"],
1325            make_meta(FilterKind::Trainable, true),
1326        );
1327
1328        // Put everything in cache
1329        let a_config = registry.config_hash("a").unwrap();
1330        let a_key = CacheKey::from_parts(&[&a_config.0]);
1331        let cache = MockCacheStore::new();
1332        cache.insert(a_key);
1333
1334        let result = compile(&graph, &registry, CompileMode::NoCache, Some(&cache)).unwrap();
1335
1336        // Nothing should be cached
1337        assert!(!format!("{:?}", result.plan).contains("Cached"));
1338    }
1339
1340    #[test]
1341    fn differentiable_mode_skips_output_caching() {
1342        let graph = linear_pipeline(vec![Node::new("a", "A", "F"), Node::new("b", "B", "F")]);
1343
1344        let mut registry = SimpleNodeRegistry::new();
1345        register_nodes(
1346            &mut registry,
1347            &["a", "b"],
1348            make_meta(FilterKind::Trainable, true),
1349        );
1350
1351        let a_config = registry.config_hash("a").unwrap();
1352        let a_key = CacheKey::from_parts(&[&a_config.0]);
1353        let cache = MockCacheStore::new();
1354        cache.insert(a_key);
1355
1356        let result = compile(&graph, &registry, CompileMode::Differentiable, Some(&cache)).unwrap();
1357
1358        // Differentiable mode should not cache forward outputs
1359        assert!(!format!("{:?}", result.plan).contains("Cached"));
1360    }
1361
1362    #[test]
1363    fn gradient_flow_diagnostic_on_opaque() {
1364        let graph = linear_pipeline(vec![
1365            Node::new("scaler", "Scaler", "F"),
1366            Node::new("tree", "DecisionTree", "F"),
1367            Node::new("linear", "Linear", "F"),
1368        ]);
1369
1370        let mut registry = SimpleNodeRegistry::new();
1371        registry.register_meta(
1372            "scaler",
1373            make_meta(FilterKind::Trainable, true),
1374            CacheKey::hash_data(b"s"),
1375        );
1376        registry.register_meta(
1377            "tree",
1378            make_meta(FilterKind::Opaque, false), // not differentiable
1379            CacheKey::hash_data(b"t"),
1380        );
1381        registry.register_meta(
1382            "linear",
1383            make_meta(FilterKind::Trainable, true),
1384            CacheKey::hash_data(b"l"),
1385        );
1386
1387        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1388
1389        assert_eq!(result.diagnostics.len(), 1);
1390        assert_eq!(result.diagnostics[0].node_id, "tree");
1391        assert_eq!(result.diagnostics[0].level, DiagnosticLevel::Warning);
1392        assert!(
1393            result.diagnostics[0]
1394                .message
1395                .contains("gradient flow interrupted")
1396        );
1397    }
1398
1399    #[test]
1400    fn no_diagnostic_when_all_differentiable() {
1401        let graph = linear_pipeline(vec![Node::new("a", "A", "F"), Node::new("b", "B", "F")]);
1402
1403        let mut registry = SimpleNodeRegistry::new();
1404        register_nodes(
1405            &mut registry,
1406            &["a", "b"],
1407            make_meta(FilterKind::Trainable, true),
1408        );
1409
1410        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1411        assert!(result.diagnostics.is_empty());
1412    }
1413
1414    #[test]
1415    fn compile_rejects_cycle() {
1416        let mut graph = Graph::new();
1417        graph.add_node(Node::new("a", "A", "F"));
1418        graph.add_node(Node::new("b", "B", "F"));
1419        graph.add_edge(Edge::data("e1", "a", "b"));
1420        graph.add_edge(Edge::data("e2", "b", "a"));
1421
1422        let registry = SimpleNodeRegistry::new();
1423        let result = compile(&graph, &registry, CompileMode::Inference, None);
1424        assert!(matches!(result, Err(SomaError::CycleDetected)));
1425    }
1426
1427    #[test]
1428    fn plan_summary_is_accurate() {
1429        let mut graph = Graph::new();
1430        graph.add_node(Node::new("root", "Root", "F"));
1431        graph.add_node(Node::new("b1", "B1", "F"));
1432        graph.add_node(Node::new("b2", "B2", "F"));
1433        graph.add_node(Node::new("end", "End", "F"));
1434        graph.add_edge(Edge::data("e1", "root", "b1"));
1435        graph.add_edge(Edge::data("e2", "root", "b2"));
1436        graph.add_edge(Edge::data("e3", "b1", "end"));
1437        graph.add_edge(Edge::data("e4", "b2", "end"));
1438
1439        let mut registry = SimpleNodeRegistry::new();
1440        register_nodes(
1441            &mut registry,
1442            &["root", "b1", "b2", "end"],
1443            make_meta(FilterKind::Trainable, true),
1444        );
1445
1446        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1447        let summary = result.plan.summary();
1448        assert_eq!(summary.total_nodes, 4);
1449        assert_eq!(summary.parallel_branches, 2);
1450    }
1451
1452    #[test]
1453    fn distribution_wraps_remote_nodes() {
1454        let graph = linear_pipeline(vec![
1455            Node::new("preprocess", "Preprocess", "F"),
1456            Node::new("gpu_train", "GpuTrain", "F"),
1457            Node::new("evaluate", "Evaluate", "F"),
1458        ]);
1459
1460        let mut registry = SimpleNodeRegistry::new();
1461        // preprocess: local
1462        registry.register_meta(
1463            "preprocess",
1464            make_meta(FilterKind::Trainable, true),
1465            CacheKey::hash_data(b"pre"),
1466        );
1467        // gpu_train: remote on GPU tag
1468        let mut gpu_meta = make_meta(FilterKind::Trainable, true);
1469        gpu_meta.distribution = somatize_core::filter::Distribution::Remote(
1470            somatize_core::filter::RemoteTarget::Tag("gpu".into()),
1471        );
1472        registry.register_meta("gpu_train", gpu_meta, CacheKey::hash_data(b"gpu"));
1473        // evaluate: local
1474        registry.register_meta(
1475            "evaluate",
1476            make_meta(FilterKind::Trainable, true),
1477            CacheKey::hash_data(b"eval"),
1478        );
1479
1480        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1481
1482        // Should be: Sequence(Execute(preprocess), Remote(gpu_train, ...), Execute(evaluate))
1483        if let ExecutionPlan::Sequence(steps) = &result.plan {
1484            assert_eq!(steps.len(), 3);
1485            assert!(
1486                matches!(&steps[0], ExecutionPlan::Execute { node_id } if node_id == "preprocess")
1487            );
1488            assert!(
1489                matches!(&steps[1], ExecutionPlan::Remote { node_id, target, .. }
1490                    if node_id == "gpu_train"
1491                    && *target == somatize_core::filter::RemoteTarget::Tag("gpu".into())
1492                ),
1493                "expected Remote, got: {:?}",
1494                steps[1]
1495            );
1496            assert!(
1497                matches!(&steps[2], ExecutionPlan::Execute { node_id } if node_id == "evaluate")
1498            );
1499        } else {
1500            panic!("expected Sequence, got: {:?}", result.plan);
1501        }
1502    }
1503
1504    #[test]
1505    fn local_distribution_not_wrapped() {
1506        let graph = linear_pipeline(vec![Node::new("a", "A", "F"), Node::new("b", "B", "F")]);
1507
1508        let mut registry = SimpleNodeRegistry::new();
1509        register_nodes(
1510            &mut registry,
1511            &["a", "b"],
1512            make_meta(FilterKind::Trainable, true),
1513        );
1514
1515        let result = compile(&graph, &registry, CompileMode::Inference, None).unwrap();
1516
1517        // No Remote nodes
1518        let ids = result.plan.node_ids();
1519        assert_eq!(ids.len(), 2);
1520        // Should all be Execute, no Remote wrapper
1521        if let ExecutionPlan::Sequence(steps) = &result.plan {
1522            assert!(
1523                steps
1524                    .iter()
1525                    .all(|s| matches!(s, ExecutionPlan::Execute { .. }))
1526            );
1527        }
1528    }
1529
1530    // ── compile_stream ──
1531
1532    #[test]
1533    fn stream_compiles_a_linear_chain() {
1534        let graph = linear_pipeline(vec![Node::new("a", "A", "F"), Node::new("b", "B", "F")]);
1535        let mut registry = SimpleNodeRegistry::new();
1536        register_nodes(
1537            &mut registry,
1538            &["a", "b"],
1539            make_meta(FilterKind::Stateless, false),
1540        );
1541
1542        let result = compile_stream(&graph, &registry, 64).unwrap();
1543        let ExecutionPlan::Stream {
1544            node_ids,
1545            chunk_size,
1546        } = result.plan
1547        else {
1548            panic!("expected a Stream plan");
1549        };
1550        assert_eq!(node_ids, vec!["a", "b"]);
1551        assert_eq!(chunk_size, 64);
1552    }
1553
1554    #[test]
1555    fn stream_of_an_empty_graph_is_empty() {
1556        let result = compile_stream(&Graph::new(), &SimpleNodeRegistry::new(), 64).unwrap();
1557        assert!(matches!(result.plan, ExecutionPlan::Empty));
1558    }
1559
1560    #[test]
1561    fn stream_rejects_a_zero_chunk() {
1562        let graph = linear_pipeline(vec![Node::new("a", "A", "F")]);
1563        let mut registry = SimpleNodeRegistry::new();
1564        register_nodes(
1565            &mut registry,
1566            &["a"],
1567            make_meta(FilterKind::Stateless, false),
1568        );
1569
1570        let err = compile_stream(&graph, &registry, 0).unwrap_err();
1571        assert!(err.to_string().contains("chunk_size"), "{err}");
1572    }
1573
1574    /// A diamond used to stream as a chain in topological order — a
1575    /// silently wrong answer. Now it is a compile error naming the node.
1576    #[test]
1577    fn stream_rejects_a_non_linear_graph_by_name() {
1578        let mut graph = Graph::new();
1579        for id in ["a", "b", "c", "d"] {
1580            graph.add_node(Node::new(id, id, "F"));
1581        }
1582        graph.add_edge(Edge::data("e1", "a", "b"));
1583        graph.add_edge(Edge::data("e2", "a", "c"));
1584        graph.add_edge(Edge::data("e3", "b", "d"));
1585        graph.add_edge(Edge::data("e4", "c", "d"));
1586        let mut registry = SimpleNodeRegistry::new();
1587        register_nodes(
1588            &mut registry,
1589            &["a", "b", "c", "d"],
1590            make_meta(FilterKind::Stateless, false),
1591        );
1592
1593        let err = compile_stream(&graph, &registry, 64).unwrap_err();
1594        let msg = err.to_string();
1595        assert!(msg.contains("`a`"), "should name the forking node: {msg}");
1596        assert!(msg.contains("linear chain"), "{msg}");
1597    }
1598
1599    /// The effect journal keys by (run, node, turn): chunk 2 would replay
1600    /// chunk 1's effects. There is no defensible semantics, so refuse.
1601    #[test]
1602    fn stream_rejects_a_step_by_name() {
1603        let graph = linear_pipeline(vec![Node::new("a", "A", "F"), Node::new("s", "S", "Step")]);
1604        let mut registry = SimpleNodeRegistry::new();
1605        register_nodes(
1606            &mut registry,
1607            &["a"],
1608            make_meta(FilterKind::Stateless, false),
1609        );
1610        registry.register_step_meta("s", somatize_core::step::StepMeta::new("S"));
1611
1612        let err = compile_stream(&graph, &registry, 64).unwrap_err();
1613        let msg = err.to_string();
1614        assert!(msg.contains("`s`"), "{msg}");
1615        assert!(msg.contains("cannot be streamed"), "{msg}");
1616    }
1617
1618    #[test]
1619    fn stream_reports_an_unregistered_node() {
1620        let graph = linear_pipeline(vec![Node::new("ghost", "G", "F")]);
1621        let err = compile_stream(&graph, &SimpleNodeRegistry::new(), 64).unwrap_err();
1622        assert!(err.to_string().contains("`ghost`"), "{err}");
1623    }
1624}