1use serde::Deserialize;
7use std::collections::{BTreeMap, BTreeSet};
8use std::sync::Arc;
9
10use crate::kernel::PortTable;
11use crate::metadata::MetadataPredicate;
12use crate::status::{Error, Result};
13
14fn default_max_queue_size() -> usize {
15 100
16}
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum RouteMode {
21 #[default]
22 First,
23 All,
24}
25
26impl RouteMode {
27 pub(crate) fn as_str(self) -> &'static str {
28 match self {
29 Self::First => "first",
30 Self::All => "all",
31 }
32 }
33}
34
35impl RouteConfig {
36 fn is_implicit_default(&self) -> bool {
37 self.mode == RouteMode::First && self.unmatched == "drop" && self.routes.is_empty()
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct RouteRule {
44 pub to: String,
45 #[serde(default)]
46 pub when: Option<MetadataPredicate>,
47 #[serde(default)]
48 pub default: bool,
49}
50
51#[derive(Debug, Clone, PartialEq, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct RouteConfig {
54 #[serde(default)]
55 pub mode: RouteMode,
56 #[serde(default = "default_route_unmatched")]
57 pub unmatched: String,
58 #[serde(default)]
59 pub routes: Vec<RouteRule>,
60}
61
62fn default_route_unmatched() -> String {
63 "drop".to_string()
64}
65
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum StatsLevel {
70 Off,
72 #[default]
74 Basic,
75 Full,
77}
78
79#[derive(Debug, Clone, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ExecutorConfig {
82 #[serde(default)]
85 pub name: String,
86 #[serde(default)]
90 pub r#type: String,
91 #[serde(default)]
94 pub num_threads: usize,
95 #[serde(default)]
98 pub affinity: Vec<usize>,
99 #[serde(default)]
102 pub priority: i32,
103}
104
105#[derive(Debug, Clone, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct InputPolicyConfig {
109 #[serde(default = "default_policy_type")]
110 pub r#type: String,
111 #[serde(default)]
112 pub capacity: usize,
113 #[serde(default)]
115 pub sets: Vec<Vec<String>>,
116}
117
118fn default_policy_type() -> String {
119 "sync".to_string()
120}
121
122impl Default for InputPolicyConfig {
123 fn default() -> Self {
124 Self {
125 r#type: default_policy_type(),
126 capacity: 0,
127 sets: Vec::new(),
128 }
129 }
130}
131
132#[derive(Debug, Clone, Default, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct InputQueueLimitConfig {
135 #[serde(default)]
137 pub packets: Option<usize>,
138}
139
140#[derive(Debug, Clone, Default, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct InputQueuesConfig {
143 #[serde(default)]
145 pub packets: usize,
146 #[serde(default)]
148 pub ports: std::collections::BTreeMap<String, InputQueueLimitConfig>,
149}
150
151#[derive(Debug, Clone, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct NodeConfig {
154 #[serde(skip)]
156 pub(crate) source_path: String,
157 #[serde(default)]
158 pub name: String,
159 #[serde(default)]
161 pub kernel: String,
162 #[serde(default)]
163 pub input_ports: Vec<String>,
164 #[serde(default)]
165 pub output_ports: Vec<String>,
166 #[serde(default)]
168 pub executor: String,
169 #[serde(default)]
172 pub max_in_flight: usize,
173 #[serde(default)]
174 pub options: serde_yaml::Value,
175 #[serde(default)]
176 pub input_policy: InputPolicyConfig,
177 #[serde(default)]
183 pub input_queues: InputQueuesConfig,
184 #[serde(default)]
186 pub r#type: String,
187 #[serde(default)]
191 pub back_edges: Vec<String>,
192 #[serde(default)]
201 pub on_error: String,
202 #[serde(default)]
211 pub rate: f64,
212 #[serde(flatten)]
215 pub route: Option<RouteConfig>,
216}
217
218#[derive(Debug, Clone, Deserialize)]
219#[serde(deny_unknown_fields)]
220pub struct GraphConfig {
221 #[serde(default)]
222 pub executors: Vec<ExecutorConfig>,
223 #[serde(default)]
224 pub nodes: Vec<NodeConfig>,
225 #[serde(default)]
227 pub include: Vec<String>,
228 #[serde(default)]
230 pub subgraphs: std::collections::BTreeMap<String, SubgraphConfig>,
231 #[serde(default)]
233 pub input_ports: Vec<String>,
234 #[serde(default)]
236 pub output_ports: Vec<String>,
237 #[serde(default = "default_max_queue_size")]
239 pub max_queue_size: usize,
240 #[serde(default)]
242 pub max_queued_packets: usize,
243 #[serde(default)]
245 pub watchdog_ms: u64,
246 #[serde(default)]
248 pub buffer_pool_max_bytes: usize,
249 #[serde(default)]
254 pub stats: Option<StatsLevel>,
255 #[serde(default)]
259 pub stats_timing: Option<bool>,
260 #[serde(default)]
269 pub trace_capacity: usize,
270}
271
272impl Default for GraphConfig {
275 fn default() -> Self {
276 Self {
277 executors: Vec::new(),
278 nodes: Vec::new(),
279 include: Vec::new(),
280 subgraphs: std::collections::BTreeMap::new(),
281 input_ports: Vec::new(),
282 output_ports: Vec::new(),
283 max_queue_size: default_max_queue_size(),
284 max_queued_packets: 0,
285 watchdog_ms: 0,
286 buffer_pool_max_bytes: 0,
287 stats: None,
288 stats_timing: None,
289 trace_capacity: 0,
290 }
291 }
292}
293
294impl Default for NodeConfig {
298 fn default() -> Self {
299 Self {
300 source_path: String::new(),
301 name: String::new(),
302 kernel: String::new(),
303 input_ports: Vec::new(),
304 output_ports: Vec::new(),
305 executor: String::new(),
306 max_in_flight: 0,
307 options: serde_yaml::Value::default(),
308 input_policy: InputPolicyConfig::default(),
309 input_queues: InputQueuesConfig::default(),
310 r#type: String::new(),
311 back_edges: Vec::new(),
312 on_error: String::new(),
313 rate: 0.0,
314 route: None,
315 }
316 }
317}
318
319#[derive(Debug, Clone, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct SubgraphConfig {
324 #[serde(default)]
325 pub nodes: Vec<NodeConfig>,
326 #[serde(default)]
328 pub input_ports: Vec<String>,
329 #[serde(default)]
331 pub output_ports: Vec<String>,
332}
333
334impl GraphConfig {
335 pub fn preflight_from_yaml(text: &str) -> Result<Self> {
340 let config = Self::from_yaml(text)?;
341 Ok(GraphPlan::build(config)?.config)
342 }
343
344 pub fn preflight_from_yaml_file(path: &str) -> Result<Self> {
346 let config = Self::from_yaml_file(path)?;
347 Ok(GraphPlan::build(config)?.config)
348 }
349
350 fn validate_preflight(&self) -> Result<()> {
351 validate_preflight_executors(self)?;
352 Ok(())
353 }
354
355 pub fn effective_stats_level(&self) -> StatsLevel {
356 if self.watchdog_ms > 0 || self.trace_capacity > 0 {
357 return StatsLevel::Full;
359 }
360 self.stats.unwrap_or_else(|| {
361 self.stats_timing.map_or(StatsLevel::Basic, |enabled| {
362 if enabled {
363 StatsLevel::Full
364 } else {
365 StatsLevel::Basic
366 }
367 })
368 })
369 }
370
371 pub fn parse(text: &str) -> Result<Self> {
373 serde_yaml::from_str(text).map_err(|e| Error::InvalidArg(format!("YAML parse failed: {e}")))
374 }
375
376 pub fn from_yaml(text: &str) -> Result<Self> {
379 let cfg = Self::parse(text)?;
380 if !cfg.include.is_empty() {
381 return Err(Error::InvalidArg(
382 "`include` is only supported when loading from a file (from_yaml_file); \
383 relative paths cannot be resolved from a text string"
384 .into(),
385 ));
386 }
387 let flat = crate::expand::expand(cfg)?;
388 flat.check_supported()?;
389 Ok(flat)
390 }
391
392 pub fn from_yaml_file(path: &str) -> Result<Self> {
395 let text = std::fs::read_to_string(path)
396 .map_err(|e| Error::InvalidArg(format!("failed to read `{path}`: {e}")))?;
397 let cfg = Self::parse(&text).map_err(|error| error.context(format!("file `{path}`")))?;
398 let merged = crate::expand::resolve_includes(cfg, std::path::Path::new(path))?;
399 let flat = crate::expand::expand(merged)?;
400 flat.check_supported()?;
401 Ok(flat)
402 }
403
404 fn check_supported(&self) -> Result<()> {
406 if self.stats.is_some() && self.stats_timing.is_some() {
407 return Err(Error::InvalidArg(
408 "`stats` and legacy `stats_timing` cannot be used together".into(),
409 ));
410 }
411 for (node_index, n) in self.nodes.iter().enumerate() {
412 let node_path = format!("nodes[{node_index}]");
413 let who = if n.name.is_empty() {
414 n.kernel.clone()
415 } else {
416 n.name.clone()
417 };
418 if n.r#type == "route" {
419 let route = n.route.as_ref().ok_or_else(|| {
420 Error::InvalidArg(format!(
421 "{node_path}: route node `{who}` requires route fields (`routes` or `unmatched`)"
422 ))
423 })?;
424 if !n.kernel.is_empty() {
425 return Err(Error::InvalidArg(format!(
426 "{node_path}: route node `{who}` must not declare `kernel`"
427 )));
428 }
429 if n.input_ports.len() != 1 {
430 return Err(Error::InvalidArg(format!(
431 "{node_path}.input_ports (node `{who}`): route nodes require exactly one input port"
432 )));
433 }
434 if n.output_ports.is_empty() {
435 return Err(Error::InvalidArg(format!(
436 "{node_path}.output_ports (node `{who}`): route nodes require at least one output port"
437 )));
438 }
439 let output_names: BTreeSet<String> = n
440 .output_ports
441 .iter()
442 .map(|decl| parse_port_spec(decl).map(|spec| spec.name))
443 .collect::<Result<_>>()?;
444 let mut defaults = 0usize;
445 for (rule_index, rule) in route.routes.iter().enumerate() {
446 if !output_names.contains(&rule.to) {
447 return Err(Error::InvalidArg(format!(
448 "{node_path}.routes[{rule_index}].to (node `{who}`): unknown output port `{}`",
449 rule.to
450 )));
451 }
452 if rule.default {
453 defaults += 1;
454 if rule.when.is_some() {
455 return Err(Error::InvalidArg(format!(
456 "{node_path}.routes[{rule_index}] (node `{who}`): default route must not declare `when`"
457 )));
458 }
459 } else {
460 let predicate = rule.when.as_ref().ok_or_else(|| {
461 Error::InvalidArg(format!(
462 "{node_path}.routes[{rule_index}] (node `{who}`): non-default route requires `when`"
463 ))
464 })?;
465 predicate.validate().map_err(|error| {
466 error.context(format!("{node_path}.routes[{rule_index}].when"))
467 })?;
468 }
469 }
470 if defaults > 1 {
471 return Err(Error::InvalidArg(format!(
472 "{node_path}.routes (node `{who}`): at most one default route is allowed"
473 )));
474 }
475 match route.unmatched.as_str() {
476 "drop" | "error" => {}
477 target if output_names.contains(target) => {}
478 other => {
479 return Err(Error::InvalidArg(format!(
480 "{node_path}.unmatched (node `{who}`): expected `drop`, `error`, or an output port, got `{other}`"
481 )));
482 }
483 }
484 if route.routes.is_empty() && route.unmatched == "drop" {
485 return Err(Error::InvalidArg(format!(
486 "{node_path}.routes (node `{who}`): at least one route rule is required"
487 )));
488 }
489 } else if n
490 .route
491 .as_ref()
492 .is_some_and(|route| !route.is_implicit_default())
493 {
494 return Err(Error::InvalidArg(format!(
495 "{node_path} (node `{who}`): route fields are only valid when `type: route`"
496 )));
497 }
498 if n.rate != 0.0 {
503 if !n.input_ports.is_empty() {
504 return Err(Error::InvalidArg(format!(
505 "{node_path}.rate (node `{who}`): rate only applies to source nodes (no input ports); \
506 a non-source is driven by upstream data"
507 )));
508 }
509 if !(n.rate.is_finite() && n.rate > 0.0) {
512 return Err(Error::InvalidArg(format!(
513 "{node_path}.rate (node `{who}`): rate must be a positive, finite number (Hz), got {}",
514 n.rate
515 )));
516 }
517 source_rate_period(n.rate)
518 .map_err(|error| error.context(format!("{node_path}.rate (node `{who}`)")))?;
519 }
520 match n.input_policy.r#type.as_str() {
521 "sync" | "immediate" => {}
522 "fixed_size" => {
523 if n.input_policy.capacity == 0 {
524 return Err(Error::InvalidArg(format!(
526 "{node_path}.input_policy.capacity (node `{who}`): fixed_size policy capacity must be >= 1"
527 )));
528 }
529 }
530 "batch" => {
531 if n.input_policy.capacity == 0 {
533 return Err(Error::InvalidArg(format!(
534 "{node_path}.input_policy.capacity (node `{who}`): batch policy capacity (the batch size) must be >= 1"
535 )));
536 }
537 }
538 "sync_set" => {
541 if n.input_policy.sets.is_empty() {
542 return Err(Error::InvalidArg(format!(
543 "{node_path}.input_policy.sets (node `{who}`): sync_set policy must provide sets (input port groups)"
544 )));
545 }
546 }
547 other => {
548 return Err(Error::InvalidArg(format!(
549 "{node_path}.input_policy.type (node `{who}`): unknown input_policy `{other}`{} (valid: sync / immediate / fixed_size / sync_set / batch)",
550 crate::diagnostic::did_you_mean(
551 other,
552 ["sync", "immediate", "fixed_size", "sync_set", "batch"]
553 )
554 )))
555 }
556 }
557 let port_names: Vec<String> = n
558 .input_ports
559 .iter()
560 .enumerate()
561 .map(|(port_index, declaration)| {
562 parse_port_spec(declaration)
563 .map(|spec| spec.name)
564 .map_err(|error| {
565 error.context(format!("{node_path}.input_ports[{port_index}]"))
566 })
567 })
568 .collect::<Result<_>>()?;
569 for port in n.input_queues.ports.keys() {
570 if !port_names.contains(port) {
571 return Err(Error::InvalidArg(format!(
572 "{node_path}.input_queues.ports.{port} (node `{who}`): input queue capacity override references unknown input port `{port}`{}",
573 crate::diagnostic::did_you_mean(
574 port,
575 port_names.iter().map(String::as_str)
576 )
577 )));
578 }
579 }
580 for (port, limits) in &n.input_queues.ports {
581 if limits.packets.is_some_and(|capacity| capacity != 0)
582 && n.back_edges.contains(port)
583 {
584 return Err(Error::InvalidArg(format!(
585 "{node_path}.input_queues.ports.{port} (node `{who}`): input queue capacity override for back-edge input `{port}` is not supported; \
586 back-edges always use their capacity-1 latest-value register"
587 )));
588 }
589 }
590 let has_lossless_capacity = n.input_queues.packets != 0
591 || n.input_queues
592 .ports
593 .values()
594 .any(|limits| limits.packets.is_some_and(|capacity| capacity != 0));
595 if has_lossless_capacity && n.input_policy.r#type == "fixed_size" {
596 return Err(Error::InvalidArg(format!(
597 "{node_path}.input_queues (node `{who}`): lossless input queue capacities cannot be combined with \
598 input_policy=fixed_size (lossy drop-oldest)"
599 )));
600 }
601
602 if !n.on_error.is_empty() && n.on_error != "abort" && n.on_error != "skip" {
604 return Err(Error::InvalidArg(format!(
605 "{node_path}.on_error (node `{who}`): unknown on_error `{}`{} (expected \"abort\" or \"skip\")",
606 n.on_error,
607 crate::diagnostic::did_you_mean(&n.on_error, ["abort", "skip"])
608 )));
609 }
610
611 if !n.back_edges.is_empty() {
613 for be in &n.back_edges {
614 if !port_names.contains(be) {
615 return Err(Error::InvalidArg(format!(
616 "{node_path}.back_edges (node `{who}`): back_edge `{be}` is not one of this node's input ports{}",
617 crate::diagnostic::did_you_mean(
618 be,
619 port_names.iter().map(String::as_str)
620 )
621 )));
622 }
623 }
624 let forward = port_names
625 .iter()
626 .filter(|p| !n.back_edges.contains(p))
627 .count();
628 if forward == 0 {
629 return Err(Error::InvalidArg(format!(
630 "{node_path}.back_edges (node `{who}`): every input port is a back_edge -- a node needs at least one forward input to ever fire"
631 )));
632 }
633 if n.input_policy.r#type == "sync_set" {
634 for set in &n.input_policy.sets {
635 if let Some(name) = set.iter().find(|p| n.back_edges.contains(p)) {
636 return Err(Error::InvalidArg(format!(
637 "{node_path}.input_policy.sets (node `{who}`): back_edge `{name}` must not appear in a sync_set group"
638 )));
639 }
640 }
641 }
642 }
643 }
644 for (executor_index, e) in self.executors.iter().enumerate() {
645 if !matches!(
648 e.r#type.as_str(),
649 "" | "ThreadPoolExecutor" | "DelegatingExecutor"
650 ) {
651 return Err(Error::InvalidArg(format!(
652 "executors[{executor_index}].type: unknown executor type `{}`{} (supported: ThreadPoolExecutor, DelegatingExecutor)",
653 e.r#type,
654 crate::diagnostic::did_you_mean(
655 &e.r#type,
656 ["ThreadPoolExecutor", "DelegatingExecutor"]
657 )
658 )));
659 }
660 }
661 Ok(())
662 }
663}
664
665#[derive(Debug, Clone)]
670pub struct GraphPlan {
671 pub config: GraphConfig,
672 pub nodes: Vec<GraphPlanNode>,
673 pub edges: Vec<GraphPlanEdge>,
674 pub(crate) graph_inputs: Vec<usize>,
675 pub(crate) graph_outputs: Vec<usize>,
676 pub(crate) input_by_name: BTreeMap<String, usize>,
677 pub(crate) output_by_name: BTreeMap<String, usize>,
678 pub(crate) edge_by_name: BTreeMap<String, usize>,
679 pub(crate) back_edge_mask: Vec<Vec<bool>>,
680}
681
682#[derive(Debug, Clone)]
683pub struct GraphPlanNode {
684 pub index: usize,
685 pub name: String,
686 pub kernel: String,
687 pub executor: String,
688 pub inputs: Vec<String>,
689 pub outputs: Vec<String>,
690 pub route: Option<RouteConfig>,
691 pub(crate) input_ports: Arc<PortTable>,
692 pub(crate) output_ports: Arc<PortTable>,
693 pub(crate) input_edges: Vec<usize>,
694 pub(crate) output_edges: Vec<usize>,
695 pub(crate) executor_index: usize,
696}
697
698#[derive(Debug, Clone)]
699pub struct GraphPlanEdge {
700 pub name: String,
701 pub producer: Option<usize>,
702 pub consumers: Vec<usize>,
703 pub graph_input: bool,
704 pub graph_output: bool,
705 pub(crate) consumer_ports: Vec<(usize, usize)>,
706}
707
708#[derive(Debug, Clone, PartialEq, Eq)]
709pub struct GraphPlanDiagnostic {
710 pub code: String,
711 pub message: String,
712}
713
714impl GraphPlan {
715 pub fn build(config: GraphConfig) -> Result<Self> {
716 config.validate_preflight()?;
717 let mut known_edges = BTreeSet::new();
718 let mut producers = BTreeMap::new();
719 let mut graph_input_names = BTreeSet::new();
720 for (index, declaration) in config.input_ports.iter().enumerate() {
721 let spec = parse_port_spec(declaration)
722 .map_err(|error| error.context(format!("input_ports[{index}]")))?;
723 if !known_edges.insert(spec.name.clone()) {
724 return Err(Error::InvalidArg(format!(
725 "input_ports[{index}]: graph input port `{}` is declared more than once",
726 spec.name
727 )));
728 }
729 graph_input_names.insert(spec.name);
730 }
731 let mut port_tables = Vec::with_capacity(config.nodes.len());
732 for (index, node) in config.nodes.iter().enumerate() {
733 let node_path = diagnostic_node_path(node, index);
734 let inputs = Arc::new(
735 PortTable::build(&node.input_ports, &format!("{node_path}.input_ports"))
736 .map_err(|error| error.context(format!("{node_path}.input_ports")))?,
737 );
738 let outputs = Arc::new(
739 PortTable::build(&node.output_ports, &format!("{node_path}.output_ports"))
740 .map_err(|error| error.context(format!("{node_path}.output_ports")))?,
741 );
742 for name in outputs.names() {
743 if graph_input_names.contains(name) {
744 return Err(Error::InvalidArg(format!(
745 "{node_path}.output_ports: port `{name}` is also a graph input"
746 )));
747 }
748 if let Some(previous) = producers.insert(name.clone(), index) {
749 let previous_path = diagnostic_node_path(&config.nodes[previous], previous);
750 return Err(Error::InvalidArg(format!(
751 "{node_path}.output_ports: port `{name}` has multiple producers \
752 ({previous_path} and {node_path})"
753 )));
754 }
755 known_edges.insert(name.clone());
756 }
757 port_tables.push((inputs, outputs));
758 }
759 let mut adjacency = vec![Vec::new(); config.nodes.len()];
760 for (index, (inputs, _)) in port_tables.iter().enumerate() {
761 let node_path = diagnostic_node_path(&config.nodes[index], index);
762 for (port, name) in inputs.names().iter().enumerate() {
763 if !graph_input_names.contains(name) && !producers.contains_key(name) {
764 let suggestion = crate::diagnostic::did_you_mean(
765 name,
766 known_edges.iter().map(String::as_str),
767 );
768 return Err(Error::InvalidArg(format!(
769 "{node_path}.input_ports[{port}]: port `{name}` has no producer{suggestion}"
770 )));
771 }
772 if let Some(&producer) = producers.get(name) {
773 if !config.nodes[index]
774 .back_edges
775 .iter()
776 .any(|edge| edge == name)
777 {
778 adjacency[producer].push(index);
779 }
780 }
781 }
782 }
783 for (index, declaration) in config.output_ports.iter().enumerate() {
784 let spec = parse_port_spec(declaration)
785 .map_err(|error| error.context(format!("output_ports[{index}]")))?;
786 if !known_edges.contains(&spec.name) {
787 let suggestion = crate::diagnostic::did_you_mean(
788 &spec.name,
789 known_edges.iter().map(String::as_str),
790 );
791 return Err(Error::InvalidArg(format!(
792 "output_ports[{index}]: graph output `{}` has no producer{suggestion}",
793 spec.name,
794 )));
795 }
796 }
797 check_preflight_acyclic(&adjacency, &config)?;
798 let mut edges = Vec::<GraphPlanEdge>::new();
799 let mut edge_by_name = BTreeMap::<String, usize>::new();
800
801 let mut graph_inputs = Vec::new();
802 let mut input_by_name = BTreeMap::new();
803 for declaration in &config.input_ports {
804 let name = parse_port_spec(declaration)?.name;
805 let edge = plan_edge(&name, &mut edges, &mut edge_by_name);
806 edges[edge].graph_input = true;
807 graph_inputs.push(edge);
808 input_by_name.insert(name, edge);
809 }
810
811 let executor_index_by_name: BTreeMap<&str, usize> = std::iter::once(("default", 0))
812 .chain(
813 config
814 .executors
815 .iter()
816 .enumerate()
817 .map(|(index, executor)| (executor.name.as_str(), index + 1)),
818 )
819 .collect();
820
821 let mut nodes = Vec::with_capacity(config.nodes.len());
822 for (index, node) in config.nodes.iter().enumerate() {
823 let (input_ports, output_ports) = port_tables[index].clone();
824 let inputs = input_ports.names().to_vec();
825 let outputs = output_ports.names().to_vec();
826 let mut output_edges = Vec::with_capacity(outputs.len());
827 for name in &outputs {
828 let edge = plan_edge(name, &mut edges, &mut edge_by_name);
829 edges[edge].producer = Some(index);
830 output_edges.push(edge);
831 }
832 let mut input_edges = Vec::with_capacity(inputs.len());
833 for (port, name) in inputs.iter().enumerate() {
834 let edge = plan_edge(name, &mut edges, &mut edge_by_name);
835 edges[edge].consumers.push(index);
836 edges[edge].consumer_ports.push((index, port));
837 input_edges.push(edge);
838 }
839 let executor = if node.executor.is_empty() {
840 "default"
841 } else {
842 node.executor.as_str()
843 };
844 nodes.push(GraphPlanNode {
845 index,
846 name: if node.name.is_empty() {
847 let kind = if node.r#type == "route" {
848 "route"
849 } else {
850 node.kernel.as_str()
851 };
852 format!("{kind}#{index}")
853 } else {
854 node.name.clone()
855 },
856 kernel: node.kernel.clone(),
857 executor: executor.into(),
858 inputs,
859 outputs,
860 route: (node.r#type == "route")
861 .then(|| node.route.clone())
862 .flatten(),
863 input_ports,
864 output_ports,
865 input_edges,
866 output_edges,
867 executor_index: executor_index_by_name[executor],
868 });
869 }
870
871 let mut graph_outputs = Vec::new();
872 let mut output_by_name = BTreeMap::new();
873 for declaration in &config.output_ports {
874 let name = parse_port_spec(declaration)?.name;
875 let edge = edge_by_name[&name];
876 edges[edge].graph_output = true;
877 graph_outputs.push(edge);
878 output_by_name.insert(name, edge);
879 }
880 let back_edge_mask = config
881 .nodes
882 .iter()
883 .zip(&nodes)
884 .map(|(node, planned)| {
885 planned
886 .inputs
887 .iter()
888 .map(|name| node.back_edges.contains(name))
889 .collect()
890 })
891 .collect();
892 Ok(Self {
893 config,
894 nodes,
895 edges,
896 graph_inputs,
897 graph_outputs,
898 input_by_name,
899 output_by_name,
900 edge_by_name,
901 back_edge_mask,
902 })
903 }
904
905 pub fn to_dot(&self) -> String {
907 crate::dot::render_plan(self)
908 }
909
910 pub fn diagnostics(&self) -> Vec<GraphPlanDiagnostic> {
911 let mut diagnostics = Vec::new();
912 for edge in &self.edges {
913 if !edge.consumers.is_empty() || edge.graph_output {
914 continue;
915 }
916 if edge.graph_input {
917 diagnostics.push(GraphPlanDiagnostic {
918 code: "unconsumed_graph_input".into(),
919 message: format!(
920 "graph input port `{}` is consumed by no node; packets sent in will be dropped",
921 edge.name
922 ),
923 });
924 } else if let Some(producer) = edge.producer {
925 diagnostics.push(GraphPlanDiagnostic {
926 code: "unconsumed_node_output".into(),
927 message: format!(
928 "node `{}` output port `{}` has no downstream consumer and is not a graph output; output will be dropped",
929 self.nodes[producer].name, edge.name
930 ),
931 });
932 }
933 }
934 for executor in &self.config.executors {
935 if executor.r#type == "DelegatingExecutor"
936 || self.nodes.iter().any(|node| node.executor == executor.name)
937 {
938 continue;
939 }
940 diagnostics.push(GraphPlanDiagnostic {
941 code: "unused_executor".into(),
942 message: format!(
943 "executor `{}` is defined but not used by any node ({} threads will idle)",
944 executor.name,
945 executor.num_threads.max(1)
946 ),
947 });
948 }
949 for node in &self.nodes {
950 let Some(route) = &node.route else { continue };
951 if route.routes.iter().any(|rule| rule.default) && route.unmatched != "drop" {
952 diagnostics.push(GraphPlanDiagnostic {
953 code: "route_unreachable_unmatched".into(),
954 message: format!(
955 "route node `{}` has a default rule, so unmatched policy `{}` is unreachable",
956 node.name, route.unmatched
957 ),
958 });
959 }
960 if route.mode == RouteMode::First {
961 for (index, rule) in route.routes.iter().enumerate() {
962 if rule.default {
963 continue;
964 }
965 if let Some(previous) = route.routes[..index]
966 .iter()
967 .position(|candidate| !candidate.default && candidate.when == rule.when)
968 {
969 diagnostics.push(GraphPlanDiagnostic {
970 code: "route_shadowed_rule".into(),
971 message: format!(
972 "route node `{}` rule {} duplicates earlier rule {}; first mode makes it unreachable",
973 node.name,
974 index + 1,
975 previous + 1
976 ),
977 });
978 }
979 }
980 }
981 if route.mode == RouteMode::All {
982 for (index, rule) in route.routes.iter().enumerate() {
983 if route.routes[..index].iter().any(|previous| {
984 !rule.default
985 && !previous.default
986 && previous.to == rule.to
987 && previous.when == rule.when
988 }) {
989 diagnostics.push(GraphPlanDiagnostic {
990 code: "route_duplicate_emit".into(),
991 message: format!(
992 "route node `{}` rule {} duplicates an earlier rule and may emit the same packet twice to `{}`",
993 node.name,
994 index + 1,
995 rule.to
996 ),
997 });
998 }
999 }
1000 }
1001 }
1002 diagnostics
1003 }
1004}
1005
1006fn plan_edge(
1007 name: &str,
1008 edges: &mut Vec<GraphPlanEdge>,
1009 edge_by_name: &mut BTreeMap<String, usize>,
1010) -> usize {
1011 if let Some(&index) = edge_by_name.get(name) {
1012 return index;
1013 }
1014 let index = edges.len();
1015 edges.push(GraphPlanEdge {
1016 name: name.to_string(),
1017 producer: None,
1018 consumers: Vec::new(),
1019 graph_input: false,
1020 graph_output: false,
1021 consumer_ports: Vec::new(),
1022 });
1023 edge_by_name.insert(name.to_string(), index);
1024 index
1025}
1026
1027impl StatsLevel {
1028 pub(crate) fn as_str(self) -> &'static str {
1029 match self {
1030 Self::Off => "off",
1031 Self::Basic => "basic",
1032 Self::Full => "full",
1033 }
1034 }
1035}
1036
1037fn validate_preflight_executors(config: &GraphConfig) -> Result<()> {
1038 let mut names = BTreeSet::new();
1039 for (index, executor) in config.executors.iter().enumerate() {
1040 if executor.name.is_empty() {
1041 return Err(Error::InvalidArg(format!(
1042 "executors[{index}].name: executor entry must have a name; nodes select an executor by it"
1043 )));
1044 }
1045 if executor.name == "default" {
1046 return Err(Error::InvalidArg(format!(
1047 "executors[{index}].name: `default` is reserved for the implicit executor"
1048 )));
1049 }
1050 if !names.insert(&executor.name) {
1051 return Err(Error::InvalidArg(format!(
1052 "executors[{index}].name: executor `{}` defined more than once",
1053 executor.name
1054 )));
1055 }
1056 if !matches!(
1057 executor.r#type.as_str(),
1058 "" | "ThreadPoolExecutor" | "DelegatingExecutor"
1059 ) {
1060 return Err(Error::InvalidArg(format!(
1061 "executors[{index}].type: unknown executor type `{}`",
1062 executor.r#type
1063 )));
1064 }
1065 if executor.r#type == "DelegatingExecutor"
1066 && (executor.num_threads != 0
1067 || !executor.affinity.is_empty()
1068 || executor.priority != 0)
1069 {
1070 return Err(Error::InvalidArg(format!(
1071 "executors[{index}]: DelegatingExecutor owns no threads, so `num_threads`, \
1072 `affinity`, and `priority` are meaningless; drop those fields or use \
1073 type: \"ThreadPoolExecutor\""
1074 )));
1075 }
1076 }
1077 for (index, node) in config.nodes.iter().enumerate() {
1078 let node_path = diagnostic_node_path(node, index);
1079 if !node.executor.is_empty() && !names.contains(&node.executor) {
1080 let suggestion = crate::diagnostic::did_you_mean(
1081 &node.executor,
1082 names.iter().map(|name| name.as_str()),
1083 );
1084 return Err(Error::InvalidArg(format!(
1085 "{node_path}.executor: undefined executor `{}`{suggestion}",
1086 node.executor,
1087 )));
1088 }
1089 if node.max_in_flight > 1 {
1090 let threads = if node.executor.is_empty() {
1091 std::thread::available_parallelism()
1092 .map(|value| value.get())
1093 .unwrap_or(1)
1094 } else {
1095 config
1096 .executors
1097 .iter()
1098 .find(|executor| executor.name == node.executor)
1099 .map(|executor| executor.num_threads.max(1))
1100 .unwrap_or(1)
1101 };
1102 if threads < 2 {
1103 return Err(Error::InvalidArg(format!(
1104 "{node_path}.max_in_flight={} needs an executor with more than one thread, \
1105 but the selected executor provides {} -- there would be no parallelism",
1106 node.max_in_flight, threads
1107 )));
1108 }
1109 }
1110 }
1111 for (index, node) in config.nodes.iter().enumerate() {
1112 let node_path = diagnostic_node_path(node, index);
1113 if node.input_ports.is_empty()
1114 && !node.executor.is_empty()
1115 && config.executors.iter().any(|executor| {
1116 executor.name == node.executor && executor.r#type == "DelegatingExecutor"
1117 })
1118 {
1119 return Err(Error::InvalidArg(format!(
1120 "{node_path}.executor: source nodes cannot run on DelegatingExecutor"
1121 )));
1122 }
1123 if node.input_policy.r#type == "sync_set" {
1124 let ports: BTreeSet<String> = node
1125 .input_ports
1126 .iter()
1127 .map(|declaration| parse_port_spec(declaration).map(|spec| spec.name))
1128 .collect::<Result<_>>()?;
1129 let mut seen = BTreeSet::new();
1130 for (group_index, group) in node.input_policy.sets.iter().enumerate() {
1131 if group.is_empty() {
1132 return Err(Error::InvalidArg(format!(
1133 "{node_path}.input_policy.sets[{group_index}]: group must not be empty"
1134 )));
1135 }
1136 for name in group {
1137 if !ports.contains(name) {
1138 return Err(Error::InvalidArg(format!(
1139 "{node_path}.input_policy.sets[{group_index}]: unknown input port `{name}`"
1140 )));
1141 }
1142 if !seen.insert(name) {
1143 return Err(Error::InvalidArg(format!(
1144 "{node_path}.input_policy.sets[{group_index}]: input port `{name}` appears in multiple groups"
1145 )));
1146 }
1147 }
1148 }
1149 if let Some(name) = ports.iter().find(|name| !seen.contains(*name)) {
1150 return Err(Error::InvalidArg(format!(
1151 "{node_path}.input_policy.sets: input port `{name}` is not assigned to a group"
1152 )));
1153 }
1154 }
1155 }
1156 Ok(())
1157}
1158
1159pub(crate) fn source_rate_period(rate: f64) -> Result<std::time::Duration> {
1162 std::time::Duration::try_from_secs_f64(1.0 / rate).map_err(|_| {
1163 Error::InvalidArg(format!(
1164 "source rate {rate} Hz has an unrepresentable period (1 / rate seconds)"
1165 ))
1166 })
1167}
1168
1169pub(crate) fn diagnostic_node_path(node: &NodeConfig, index: usize) -> String {
1170 if node.source_path.is_empty() {
1171 format!("nodes[{index}]")
1172 } else if node.name.is_empty() {
1173 node.source_path.clone()
1174 } else {
1175 format!("{} (expanded node `{}`)", node.source_path, node.name)
1176 }
1177}
1178
1179fn check_preflight_acyclic(adjacency: &[Vec<usize>], config: &GraphConfig) -> Result<()> {
1180 let mut marks = vec![0u8; adjacency.len()];
1181 fn visit(
1182 node: usize,
1183 adjacency: &[Vec<usize>],
1184 marks: &mut [u8],
1185 config: &GraphConfig,
1186 ) -> Result<()> {
1187 marks[node] = 1;
1188 for &next in &adjacency[node] {
1189 if marks[next] == 1 {
1190 let current = preflight_node_label(&config.nodes[node], node);
1191 let target = preflight_node_label(&config.nodes[next], next);
1192 return Err(Error::InvalidArg(format!(
1193 "topology cycle: node `{target}` -> ... -> `{current}`; mark a feedback input with `back_edges`"
1194 )));
1195 }
1196 if marks[next] == 0 {
1197 visit(next, adjacency, marks, config)?;
1198 }
1199 }
1200 marks[node] = 2;
1201 Ok(())
1202 }
1203 for node in 0..adjacency.len() {
1204 if marks[node] == 0 {
1205 visit(node, adjacency, &mut marks, config)?;
1206 }
1207 }
1208 Ok(())
1209}
1210
1211fn preflight_node_label(node: &NodeConfig, index: usize) -> String {
1212 if node.name.is_empty() {
1213 format!("{}#{index}", node.kernel)
1214 } else {
1215 node.name.clone()
1216 }
1217}
1218
1219#[derive(Debug, Clone, PartialEq, Eq)]
1221pub struct PortSpec {
1222 pub tag: String,
1223 pub index: Option<usize>,
1225 pub name: String,
1226}
1227
1228pub fn parse_port_spec(decl: &str) -> Result<PortSpec> {
1230 let parts: Vec<&str> = decl.split(':').collect();
1231 let spec = match parts.len() {
1232 1 => PortSpec {
1233 tag: String::new(),
1234 index: None,
1235 name: parts[0].to_string(),
1236 },
1237 2 => PortSpec {
1238 tag: parts[0].to_string(),
1239 index: None,
1240 name: parts[1].to_string(),
1241 },
1242 3 => {
1243 let idx: usize = parts[1].parse().map_err(|_| {
1244 Error::InvalidArg(format!(
1245 "port declaration `{decl}`: index `{}` is not a non-negative integer",
1246 parts[1]
1247 ))
1248 })?;
1249 PortSpec {
1250 tag: parts[0].to_string(),
1251 index: Some(idx),
1252 name: parts[2].to_string(),
1253 }
1254 }
1255 _ => {
1256 return Err(Error::InvalidArg(format!(
1257 "port declaration `{decl}` has an invalid number of segments (expected name / TAG:name / TAG:index:name)"
1258 )))
1259 }
1260 };
1261 if spec.name.is_empty() {
1262 return Err(Error::InvalidArg(format!(
1263 "port declaration `{decl}` has an empty name"
1264 )));
1265 }
1266 Ok(spec)
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271 use super::*;
1272
1273 #[test]
1274 fn parses_minimal_graph() {
1275 let cfg = GraphConfig::from_yaml(
1276 r#"
1277nodes:
1278 - name: "n1"
1279 kernel: "PassThroughKernel"
1280 input_ports: ["a"]
1281 output_ports: ["b"]
1282input_ports: ["a"]
1283output_ports: ["b"]
1284"#,
1285 )
1286 .unwrap();
1287 assert_eq!(cfg.nodes.len(), 1);
1288 assert_eq!(cfg.nodes[0].kernel, "PassThroughKernel");
1289 assert_eq!(cfg.input_ports, vec!["a"]);
1290 assert_eq!(cfg.max_queue_size, 100, "default queue limit");
1291 assert_eq!(cfg.effective_stats_level(), StatsLevel::Basic);
1292 }
1293
1294 #[test]
1295 fn parses_and_plans_conditional_route() {
1296 let cfg = GraphConfig::from_yaml(
1297 r#"
1298input_ports: [detections]
1299output_ports: [high, medium, low]
1300nodes:
1301 - name: confidence_route
1302 type: route
1303 input_ports: [detections]
1304 output_ports: [high, medium, low]
1305 mode: first
1306 unmatched: error
1307 routes:
1308 - to: high
1309 when: { metadata: confidence, op: gte, value: 0.8 }
1310 - to: medium
1311 when:
1312 all:
1313 - { metadata: confidence, op: gte, value: 0.5 }
1314 - not: { metadata: suppressed, op: eq, value: true }
1315 - to: low
1316 default: true
1317"#,
1318 )
1319 .unwrap();
1320 let plan = GraphPlan::build(cfg).unwrap();
1321 let route = plan.nodes[0].route.as_ref().unwrap();
1322 assert_eq!(route.mode, RouteMode::First);
1323 assert_eq!(route.unmatched, "error");
1324 assert_eq!(route.routes.len(), 3);
1325 assert!(route.routes[2].default);
1326 }
1327
1328 #[test]
1329 fn parses_route_all_mode_and_timestamp_condition() {
1330 let cfg = GraphConfig::from_yaml(
1331 r#"
1332input_ports: [input]
1333output_ports: [recent]
1334nodes:
1335 - type: route
1336 input_ports: [input]
1337 output_ports: [recent]
1338 mode: all
1339 unmatched: drop
1340 routes:
1341 - to: recent
1342 when: { timestamp: { op: gte, value: 1000 } }
1343"#,
1344 )
1345 .unwrap();
1346 let route = GraphPlan::build(cfg).unwrap().nodes[0]
1347 .route
1348 .clone()
1349 .unwrap();
1350 assert_eq!(route.mode, RouteMode::All);
1351 }
1352
1353 #[test]
1354 fn rejects_invalid_route_configuration() {
1355 for (yaml, needle) in [
1356 (
1357 r#"
1358nodes:
1359 - type: route
1360 input_ports: [a, b]
1361 output_ports: [out]
1362 routes: [{ to: out, default: true }]
1363"#,
1364 "exactly one input",
1365 ),
1366 (
1367 r#"
1368nodes:
1369 - type: route
1370 input_ports: [in]
1371 output_ports: [out]
1372 routes: [{ to: typo, default: true }]
1373"#,
1374 "unknown output port",
1375 ),
1376 (
1377 r#"
1378nodes:
1379 - type: route
1380 input_ports: [in]
1381 output_ports: [out]
1382 routes:
1383 - { to: out, default: true }
1384 - { to: out, default: true }
1385"#,
1386 "at most one default",
1387 ),
1388 (
1389 r#"
1390nodes:
1391 - type: route
1392 input_ports: [in]
1393 output_ports: [out]
1394 routes: [{ to: out }]
1395"#,
1396 "requires `when`",
1397 ),
1398 (
1399 r#"
1400nodes:
1401 - kernel: K
1402 input_ports: [in]
1403 output_ports: [out]
1404 routes: [{ to: out, default: true }]
1405"#,
1406 "only valid when `type: route`",
1407 ),
1408 ] {
1409 let error = GraphConfig::from_yaml(yaml)
1410 .and_then(GraphPlan::build)
1411 .unwrap_err();
1412 assert!(error.to_string().contains(needle), "{error}");
1413 }
1414 }
1415
1416 #[test]
1417 fn diagnoses_shadowed_and_duplicate_route_rules() {
1418 let first = GraphConfig::from_yaml(
1419 r#"
1420nodes:
1421 - name: router
1422 type: route
1423 input_ports: [in]
1424 output_ports: [a, b]
1425 mode: first
1426 unmatched: error
1427 routes:
1428 - { to: a, when: { metadata: score, op: gte, value: 0.5 } }
1429 - { to: b, when: { metadata: score, op: gte, value: 0.5 } }
1430 - { to: b, default: true }
1431input_ports: [in]
1432output_ports: [a, b]
1433"#,
1434 )
1435 .and_then(GraphPlan::build)
1436 .unwrap();
1437 let codes: Vec<_> = first
1438 .diagnostics()
1439 .into_iter()
1440 .map(|diagnostic| diagnostic.code)
1441 .collect();
1442 assert!(codes.contains(&"route_shadowed_rule".to_string()));
1443 assert!(codes.contains(&"route_unreachable_unmatched".to_string()));
1444
1445 let all = GraphConfig::from_yaml(
1446 r#"
1447nodes:
1448 - name: router
1449 type: route
1450 input_ports: [in]
1451 output_ports: [out]
1452 mode: all
1453 routes:
1454 - { to: out, when: { metadata: score, op: gte, value: 0.5 } }
1455 - { to: out, when: { metadata: score, op: gte, value: 0.5 } }
1456input_ports: [in]
1457output_ports: [out]
1458"#,
1459 )
1460 .and_then(GraphPlan::build)
1461 .unwrap();
1462 assert!(all
1463 .diagnostics()
1464 .iter()
1465 .any(|diagnostic| diagnostic.code == "route_duplicate_emit"));
1466 }
1467
1468 #[test]
1469 fn parses_stats_levels_and_legacy_timing() {
1470 for (yaml, expected) in [
1471 ("nodes: []\nstats: off", StatsLevel::Off),
1472 ("nodes: []\nstats: basic", StatsLevel::Basic),
1473 ("nodes: []\nstats: full", StatsLevel::Full),
1474 ("nodes: []\nstats_timing: false", StatsLevel::Basic),
1475 ("nodes: []\nstats_timing: true", StatsLevel::Full),
1476 ] {
1477 let cfg = GraphConfig::from_yaml(yaml).unwrap();
1478 assert_eq!(cfg.effective_stats_level(), expected, "{yaml}");
1479 }
1480 }
1481
1482 #[test]
1483 fn rejects_new_and_legacy_stats_together() {
1484 let error =
1485 GraphConfig::from_yaml("nodes: []\nstats: basic\nstats_timing: false").unwrap_err();
1486 assert!(error.to_string().contains("cannot be used together"));
1487 }
1488
1489 #[test]
1490 fn watchdog_forces_full_stats() {
1491 let cfg = GraphConfig::from_yaml("nodes: []\nstats: off\nwatchdog_ms: 1").unwrap();
1492 assert_eq!(cfg.effective_stats_level(), StatsLevel::Full);
1493 }
1494
1495 #[test]
1496 fn trace_capacity_forces_full_stats() {
1497 let cfg = GraphConfig::from_yaml("nodes: []\nstats: off\ntrace_capacity: 8").unwrap();
1499 assert_eq!(cfg.effective_stats_level(), StatsLevel::Full);
1500 let off = GraphConfig::from_yaml("nodes: []\ntrace_capacity: 0").unwrap();
1502 assert_eq!(off.effective_stats_level(), StatsLevel::Basic);
1503 }
1504
1505 #[test]
1510 fn schema_and_graph_config_agree_on_top_level_fields() {
1511 let GraphConfig {
1514 executors: _,
1515 nodes: _,
1516 include: _,
1517 subgraphs: _,
1518 input_ports: _,
1519 output_ports: _,
1520 max_queue_size: _,
1521 max_queued_packets: _,
1522 watchdog_ms: _,
1523 buffer_pool_max_bytes: _,
1524 stats: _,
1525 stats_timing: _,
1526 trace_capacity: _,
1527 } = GraphConfig::default();
1528 const FIELDS: &[&str] = &[
1530 "executors",
1531 "nodes",
1532 "include",
1533 "subgraphs",
1534 "input_ports",
1535 "output_ports",
1536 "max_queue_size",
1537 "max_queued_packets",
1538 "watchdog_ms",
1539 "buffer_pool_max_bytes",
1540 "stats",
1541 "stats_timing",
1542 "trace_capacity",
1543 ];
1544
1545 let text = include_str!("../../../docs/lmflow-config.schema.json");
1547 let schema: serde_yaml::Value = serde_yaml::from_str(text).expect("schema 应为合法 JSON");
1548 let props = schema
1549 .get("properties")
1550 .and_then(|p| p.as_mapping())
1551 .expect("schema 应有顶层 properties");
1552 let keys: Vec<&str> = props.keys().filter_map(|k| k.as_str()).collect();
1553
1554 for field in FIELDS {
1555 assert!(
1556 keys.contains(field),
1557 "schema 缺 `{field}` —— 文档里教用户配的键会被 additionalProperties:false 拒掉"
1558 );
1559 }
1560 for key in &keys {
1561 assert!(
1562 FIELDS.contains(key),
1563 "schema 有 `{key}` 但 GraphConfig 没有 —— deny_unknown_fields 会拒掉它"
1564 );
1565 }
1566 }
1567
1568 #[test]
1569 fn parses_options_and_executors() {
1570 let cfg = GraphConfig::from_yaml(
1571 r#"
1572executors:
1573 - name: "cpu"
1574 type: "ThreadPoolExecutor"
1575 num_threads: 4
1576nodes:
1577 - name: "s"
1578 kernel: "ScaleKernel"
1579 executor: "cpu"
1580 input_ports: ["a"]
1581 output_ports: ["b"]
1582 options:
1583 factor: 3
1584 mean: [0.1, 0.2]
1585 roi: { x: 5 }
1586watchdog_ms: 5000
1587max_queued_packets: 500
1588buffer_pool_max_bytes: 268435456
1589"#,
1590 )
1591 .unwrap();
1592 assert_eq!(cfg.executors[0].num_threads, 4);
1593 assert_eq!(cfg.nodes[0].executor, "cpu");
1594 assert_eq!(cfg.watchdog_ms, 5000);
1595 assert_eq!(cfg.max_queued_packets, 500);
1596 assert_eq!(cfg.buffer_pool_max_bytes, 268435456);
1597 assert!(cfg.nodes[0].options.get("factor").is_some());
1598 }
1599
1600 #[test]
1601 fn parses_executor_affinity() {
1602 let cfg = GraphConfig::from_yaml(
1603 r#"
1604executors:
1605 - { name: "rt", type: "ThreadPoolExecutor", num_threads: 2, affinity: [2, 3], priority: 10 }
1606nodes:
1607 - { name: "n", kernel: "K", input_ports: ["a"], output_ports: ["b"] }
1608"#,
1609 )
1610 .unwrap();
1611 assert_eq!(
1612 cfg.executors[0].affinity,
1613 vec![2, 3],
1614 "affinity list should be parsed"
1615 );
1616 assert_eq!(
1617 cfg.executors[0].priority, 10,
1618 "realtime priority should be parsed"
1619 );
1620 let cfg2 =
1622 GraphConfig::from_yaml("executors:\n - { name: c, type: ThreadPoolExecutor }\n")
1623 .unwrap();
1624 assert!(
1625 cfg2.executors[0].affinity.is_empty(),
1626 "no CPU pinning by default"
1627 );
1628 assert_eq!(
1629 cfg2.executors[0].priority, 0,
1630 "ordinary time-sharing by default"
1631 );
1632 }
1633
1634 #[test]
1635 fn rejects_unsupported_features_loudly() {
1636 let err = GraphConfig::from_yaml(
1638 r#"
1639executors:
1640 - name: "nope"
1641 type: "FiberExecutor"
1642nodes:
1643 - name: "n"
1644 kernel: "K"
1645"#,
1646 )
1647 .unwrap_err();
1648 assert_eq!(err.code(), crate::status::code::INVALID_ARG);
1649 assert!(err.to_string().contains("unknown executor type"), "{err}");
1650
1651 }
1656
1657 #[test]
1658 fn rejects_unknown_fields_and_bad_policy() {
1659 assert!(
1660 GraphConfig::from_yaml("nodes:\n - kernel: K\n typo_field: 1\n").is_err(),
1661 "a misspelled field name must error rather than be silently ignored"
1662 );
1663 let err =
1664 GraphConfig::from_yaml("nodes:\n - kernel: K\n input_policy: { type: nonsense }\n")
1665 .unwrap_err();
1666 assert_eq!(err.code(), crate::status::code::INVALID_ARG);
1667 }
1668
1669 #[test]
1670 fn port_spec_three_forms() {
1671 assert_eq!(
1672 parse_port_spec("frames").unwrap(),
1673 PortSpec {
1674 tag: String::new(),
1675 index: None,
1676 name: "frames".into()
1677 }
1678 );
1679 assert_eq!(
1680 parse_port_spec("VIDEO:cam0").unwrap(),
1681 PortSpec {
1682 tag: "VIDEO".into(),
1683 index: None,
1684 name: "cam0".into()
1685 }
1686 );
1687 assert_eq!(
1688 parse_port_spec("MASK:1:m1").unwrap(),
1689 PortSpec {
1690 tag: "MASK".into(),
1691 index: Some(1),
1692 name: "m1".into()
1693 }
1694 );
1695 }
1696
1697 #[test]
1698 fn port_spec_rejects_malformed() {
1699 assert!(parse_port_spec("a:b:c:d").is_err(), "too many segments");
1700 assert!(
1701 parse_port_spec("TAG:x:name").is_err(),
1702 "index is not a number"
1703 );
1704 assert!(parse_port_spec("").is_err(), "empty name");
1705 assert!(parse_port_spec("TAG:").is_err(), "empty name");
1706 }
1707}