1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum CompileMode {
18 Inference,
20 Differentiable,
22 NoCache,
24}
25
26#[derive(Debug, Clone)]
28pub struct Diagnostic {
29 pub node_id: NodeId,
31 pub level: DiagnosticLevel,
33 pub message: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DiagnosticLevel {
41 Warning,
44 Info,
46}
47
48#[derive(Debug)]
50pub struct CompileResult {
51 pub plan: ExecutionPlan,
53 pub diagnostics: Vec<Diagnostic>,
56}
57
58pub trait NodeRegistry: Send + Sync {
66 fn node_meta(&self, node_id: &str) -> Option<NodeMeta>;
70
71 fn config_hash(&self, node_id: &str) -> Option<CacheKey>;
76
77 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
90pub struct SimpleNodeRegistry {
92 entries: HashMap<String, (NodeMeta, CacheKey)>,
93}
94
95impl SimpleNodeRegistry {
96 pub fn new() -> Self {
100 Self {
101 entries: HashMap::new(),
102 }
103 }
104
105 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 let hash = CacheKey::from_parts(&[b"step-meta", id.as_bytes()]);
116 self.entries.insert(id, (meta.into(), hash));
117 }
118
119 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 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
156struct PlanCtx<'b> {
163 levels: HashMap<&'b str, usize>,
165 dominators: HashMap<&'b str, HashSet<&'b str>>,
167}
168
169impl<'b> PlanCtx<'b> {
170 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 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 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
201pub 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 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 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 self.check_gradient_flow(&sorted);
236
237 self.check_connectivity();
239
240 self.validate_schemas(&sorted)?;
242
243 let ctx = PlanCtx {
244 levels: self.compute_levels(&sorted),
245 dominators: self.compute_dominators(&sorted),
246 };
247
248 self.validate_control_flow(&sorted, &ctx)?;
250
251 let plan = self.plan_subset(&sorted, &ctx)?;
253
254 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 let plan = self.resolve_distribution(plan);
273
274 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn validate_schemas(&mut self, sorted: &[&str]) -> Result<()> {
878 for &node_id in sorted {
879 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; };
888
889 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 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 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 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_flows = true;
1013 }
1014 }
1015 }
1016 }
1017}
1018
1019pub 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
1029pub 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 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 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 #[test]
1178 fn compile_empty_graph() {
1179 let graph = Graph::new();
1180 let registry = SimpleNodeRegistry::new();
1181 let result = compile(&graph, ®istry, 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, ®istry, 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, ®istry, CompileMode::Inference, None).unwrap();
1215
1216 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, ®istry, CompileMode::Inference, None).unwrap();
1244
1245 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 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, ®istry, CompileMode::Inference, None).unwrap();
1271
1272 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 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, ®istry, 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 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, ®istry, CompileMode::NoCache, Some(&cache)).unwrap();
1335
1336 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, ®istry, CompileMode::Differentiable, Some(&cache)).unwrap();
1357
1358 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), 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, ®istry, 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, ®istry, 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, ®istry, 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, ®istry, 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 registry.register_meta(
1463 "preprocess",
1464 make_meta(FilterKind::Trainable, true),
1465 CacheKey::hash_data(b"pre"),
1466 );
1467 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 registry.register_meta(
1475 "evaluate",
1476 make_meta(FilterKind::Trainable, true),
1477 CacheKey::hash_data(b"eval"),
1478 );
1479
1480 let result = compile(&graph, ®istry, CompileMode::Inference, None).unwrap();
1481
1482 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, ®istry, CompileMode::Inference, None).unwrap();
1516
1517 let ids = result.plan.node_ids();
1519 assert_eq!(ids.len(), 2);
1520 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 #[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, ®istry, 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, ®istry, 0).unwrap_err();
1571 assert!(err.to_string().contains("chunk_size"), "{err}");
1572 }
1573
1574 #[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, ®istry, 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 #[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, ®istry, 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}