Skip to main content

lmflow/
config.rs

1//! 图配置的 YAML 表示。
2//!
3//! 原则:**用到本版本未实现的字段就报错,不静默忽略** —— 否则用户以为开了并行
4//! (或丢帧策略),实际没有,而且无从察觉。见 docs/design.md §0.2。
5
6use 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/// 运行时统计级别。它只控制诊断记账,不改变调度与终止语义。
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum StatsLevel {
70    /// 仅保留调度正确性和错误处理必需的状态。
71    Off,
72    /// 保留低成本吞吐、队列与背压统计。
73    #[default]
74    Basic,
75    /// 额外记录耗时、百分位、CoW 和执行器诊断。
76    Full,
77}
78
79#[derive(Debug, Clone, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ExecutorConfig {
82    /// 执行器名,节点用 `executor:` 按名引用。**必填**,且 `"default"` 是引擎保留名
83    /// (那是隐式默认执行器,不写 `executor` 的节点归它)—— 两者都会在建图期报错。
84    #[serde(default)]
85    pub name: String,
86    /// `"ThreadPoolExecutor"`(默认,空也算)= 自有工作线程的线程池;
87    /// `"DelegatingExecutor"` = 不拥有线程,把就绪节点交还**宿主线程**跑
88    /// (零并发、顺序确定、Python 算子不抢 GIL,但要宿主进入引擎才推进)。
89    #[serde(default)]
90    pub r#type: String,
91    /// 工作线程数。`0` 视作 1。仅 `ThreadPoolExecutor` 有意义 ——
92    /// 配在 `DelegatingExecutor` 上会报错而不是静默忽略。
93    #[serde(default)]
94    pub num_threads: usize,
95    /// CPU 亲和力:worker `i` 绑到 `affinity[i % len]` 号核。空 = 不绑(默认)。
96    /// 仅 Linux/Android 生效;其它平台忽略。用于实时/NUMA 场景固定核、减少迁移抖动。
97    #[serde(default)]
98    pub affinity: Vec<usize>,
99    /// 实时优先级(SCHED_FIFO,1..=99)。0 = 不动(默认,普通分时)。
100    /// 尽力而为:设实时调度需 CAP_SYS_NICE/root,拿不到就静默降级。仅 Linux/Android。
101    #[serde(default)]
102    pub priority: i32,
103}
104
105/// 输入策略(节点级可插拔)。见 docs/design.md §7.10。
106#[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    /// `sync_set` 用:输入口分组(端口名),须完整划分全部输入口。
114    #[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    /// `None` = 继承节点默认值;`Some(0)` = 该端口不限包数。
136    #[serde(default)]
137    pub packets: Option<usize>,
138}
139
140#[derive(Debug, Clone, Default, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct InputQueuesConfig {
143    /// 所有正向输入口的默认包数容量。0 = 不限。
144    #[serde(default)]
145    pub packets: usize,
146    /// 按输入口名覆盖;省略时继承上面的默认值,显式 0 表示不限。
147    #[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    /// 展开前的配置来源路径,仅用于诊断;不参与 YAML 序列化。
155    #[serde(skip)]
156    pub(crate) source_path: String,
157    #[serde(default)]
158    pub name: String,
159    /// 算子名。与 `type` 二选一:算子节点填 `kernel`,子图实例节点填 `type`(见 expand)。
160    #[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    /// 本节点跑在哪个执行器上(按 `executors[].name` 引用)。**空 = 默认执行器**。
167    #[serde(default)]
168    pub executor: String,
169    /// 该节点允许的并发 `process` 数。`0`/`1` = 串行(默认);`>1` 要求所属执行器
170    /// 有**多于一个线程**(委托执行器 0 线程、单线程池并行度恒为 1,均会在建图期报错)。
171    #[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    /// 正向输入口的无损包数容量。
178    ///
179    /// 内部生产者遇满不会阻塞 worker,而是保留本次 staging、释放执行线程,
180    /// 等下游弹包后协作式恢复刷新。`packets` 为节点默认值,`ports` 按端口覆盖;
181    /// 与有损 `fixed_size` 互斥。
182    #[serde(default)]
183    pub input_queues: InputQueuesConfig,
184    /// 子图名(ADR #27):非空 = 本节点是该子图的实例,建图期展开内联;与 `kernel` 二选一。
185    #[serde(default)]
186    pub r#type: String,
187    /// 反馈环(back-edge):本节点哪些**输入口名**是「最新值反馈寄存器」—— 容量 1、留最新
188    /// 反馈值、消费一次,且**不参与就绪 / 终止 / 时间戳对齐**(见 docs/design.md)。
189    /// 用它才能让边成环:未被 back_edge 打断的拓扑环仍在建图期报错。
190    #[serde(default)]
191    pub back_edges: Vec<String>,
192    /// 本节点算子失败时怎么办:`"abort"`(默认)或 `"skip"`。
193    ///
194    /// * `abort` —— 记录首个错误、终止全图(历史行为)。
195    /// * `skip` —— **丢掉出错的那一个包**、推进下游时间戳边界、计数并打 WARN,然后继续跑。
196    ///   用于长跑实时管线:一帧坏数据不该杀掉整条流水线。
197    ///
198    /// 只有这两个值(没有单独的 `log`:`skip` 本身一定会计数并打日志 ——
199    /// 本项目不接受静默的有损行为,见 §7.6)。
200    #[serde(default)]
201    pub on_error: String,
202    /// **源节点(0 输入)**的产出速率上限,单位 Hz(每秒次数)。`0`(默认)= 不限速。
203    ///
204    /// 声明式定速:设了它,引擎保证每两次 `process` 之间至少间隔 `1/rate` 秒 ——
205    /// 算子不必自己写 sleep。用于「30fps 合成源」这类场景,也顺带避免非自定速的源
206    /// 灌爆下游(内部边不背压,见 §7.5)。
207    ///
208    /// 只对源节点有意义;非源节点由上游数据驱动,设了会在建图期报错。
209    /// 实现:本次调用完成后进入执行器延迟队列,不占用等待中的 worker。
210    #[serde(default)]
211    pub rate: f64,
212    /// Conditional output routing configuration. Route fields are flattened so
213    /// YAML can declare `type: route`, `mode`, `routes`, and `unmatched` directly.
214    #[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    /// 其它 YAML 文件路径(相对本文件目录),引入其 `subgraphs` 定义。仅 `from_yaml_file` 生效。
226    #[serde(default)]
227    pub include: Vec<String>,
228    /// 可复用子图库:名字 → 一张小图;节点用 `type: <名字>` 实例化,建图期展开内联。
229    #[serde(default)]
230    pub subgraphs: std::collections::BTreeMap<String, SubgraphConfig>,
231    /// 图输入口(外部送包的入口)
232    #[serde(default)]
233    pub input_ports: Vec<String>,
234    /// 图输出口(poller / observer 挂载点)
235    #[serde(default)]
236    pub output_ports: Vec<String>,
237    /// 图输入口队列上限,也用作内部边的软水位
238    #[serde(default = "default_max_queue_size")]
239    pub max_queue_size: usize,
240    /// 全局水位:全图在途包数上限(0 = 不限)
241    #[serde(default)]
242    pub max_queued_packets: usize,
243    /// 单次算子回调超过该时长即打 WARN(0 = 关闭)
244    #[serde(default)]
245    pub watchdog_ms: u64,
246    /// 算子内 buffer 分配池上限(字节, 0 = 关闭)
247    #[serde(default)]
248    pub buffer_pool_max_bytes: usize,
249    /// 运行时统计级别。省略时为 `basic`。
250    ///
251    /// `full` 才记录每次回调耗时、百分位、CoW 与执行器耗时;`off` 进一步关闭低成本
252    /// 吞吐和高水位计数。`watchdog_ms > 0` 时会强制提升为 `full`。
253    #[serde(default)]
254    pub stats: Option<StatsLevel>,
255    /// 旧版兼容字段:`true` 等价于 `stats: full`,`false` 等价于 `stats: basic`。
256    ///
257    /// 不得与 `stats` 同时出现。
258    #[serde(default)]
259    pub stats_timing: Option<bool>,
260    /// 逐次调用 trace 的有界环容量(条数,0 = 关闭)。
261    ///
262    /// 大于 0 时,每次 Open/Process/Close 回调记一条 span 进有界环(满了丢最旧),可经
263    /// `to_chrome_trace()` 导出成 chrome://tracing / perfetto 可读的 JSON。开启会**强制**
264    /// 统计提升为 `full`(需要每次回调计时),故不要在稳态生产中长开。
265    ///
266    /// 单位是**条**而非字节:每条 span 40 字节,故 4096 条 ≈ 160 KB、100 万条 ≈ 40 MB。
267    /// 引擎不设上限 —— 环占多少内存由你这个数字直接决定,填之前先乘一下。
268    #[serde(default)]
269    pub trace_capacity: usize,
270}
271
272/// **必须与上面的 serde 默认值保持一致** —— 否则「YAML 省略字段」与 Rust
273/// `..Default::default()` 两条路会得到不同行为,故手写而不 derive。
274impl 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
294/// 与 serde 默认逐字段对齐(照 [`InputPolicyConfig`] 的先例手写、不 derive):
295/// `input_policy` 的默认 `type` 是 `"sync"` 而非空串,derive 会给错。
296/// 有了它,以后给 `NodeConfig` 加字段不会再打断仓库内的结构体字面量。
297impl 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/// 子图定义:一张可复用的小图(ADR #27)。**不声明 executor** —— 内部节点按名引用主图的执行器。
320/// 边界口按**位置**对应实例节点的 `input_ports` / `output_ports`(见 expand)。
321#[derive(Debug, Clone, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct SubgraphConfig {
324    #[serde(default)]
325    pub nodes: Vec<NodeConfig>,
326    /// 子图边界输入口:按位置对应实例节点的 `input_ports`。
327    #[serde(default)]
328    pub input_ports: Vec<String>,
329    /// 子图边界输出口:按位置对应实例节点的 `output_ports`。
330    #[serde(default)]
331    pub output_ports: Vec<String>,
332}
333
334impl GraphConfig {
335    /// Parse, resolve includes, expand subgraphs, and validate configuration-only rules.
336    ///
337    /// This entry point deliberately does not access the kernel registry or create executors.
338    /// Use it for deployment-time checks where the host may not have linked its kernels yet.
339    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    /// File equivalent of [`Self::preflight_from_yaml`].
345    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            // watchdog 与 trace 都依赖每次回调的计时,故都强制提升为 full。
358            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    /// 只做 serde 解析:不校验、不展开、不解析 include。管线内部用。
372    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    /// 从 YAML 文本建图配置:解析 → 展开子图 → 校验,返回**展平**(无 `type:` 节点)的配置。
377    /// 文本入口不支持 `include`(相对路径无从解析);需要 include 请用 [`GraphConfig::from_yaml_file`]。
378    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    /// 从 YAML 文件建图配置:读文件 → 按本文件目录递归解析 + 合并 `include` 的子图
393    /// → 展开子图 → 校验。返回展平的配置。
394    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    /// 只检查「本版本是否支持」,拓扑合法性在 Graph::build 里查。
405    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            // `max_in_flight` 与「源节点该挂什么执行器」都要看解析出的执行器长什么样
499            // (是池还是委托、几个线程),光看 YAML 里的名字答不上来 ——
500            // 那两条校验在 Graph::build 的 check_node_executor_fit 里。
501            // rate 定速:只对源节点有意义(非源由上游数据驱动),且必须为正。
502            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                // 要求正的有限值。`> 0.0` 一并挡住 0、负数和 NaN(NaN 的比较恒假),
510                // `is_finite` 挡住 inf。
511                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                        // 容量 0 意味着「每个包都丢」,几乎肯定是漏配
525                        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                    // batch:攒够 capacity 个**对齐元组**一次交给算子。
532                    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 的分组合法性(名字存在、完整划分)在 Graph::build 里查 ——
539                // 那里才有输入口名表。这里只放行类型名。
540                "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            // 错误策略:未知值明确拒掉,不静默当默认(与 input_policy / executor type 同规矩)。
603            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            // 反馈环:back_edges 名字须是本节点输入口;须留至少一个正向输入口驱动;不得与 sync_set 冲突。
612            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            // 空 type 视作 ThreadPoolExecutor(历史默认)。字段是否对得上类型
646            // (如 DelegatingExecutor 不该配 num_threads)在 Graph::build 里查。
647            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/// 展开并完成配置语义校验后的不可变建图计划。
666///
667/// 计划阶段不查询 kernel registry、不创建 executor、不启动线程;运行时建图和
668/// `lmflow check-config` 都消费同一个计划,避免“预检通过但实际建图失败”的两套规则。
669#[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    /// Export the validated static plan as Graphviz DOT without loading kernels or executors.
906    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
1159/// Convert a positive source rate without panicking when its reciprocal
1160/// overflows either f64 or Duration's representable range.
1161pub(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/// 端口声明的解析结果:`"TAG:index:name"` / `"TAG:name"` / `"name"`。
1220#[derive(Debug, Clone, PartialEq, Eq)]
1221pub struct PortSpec {
1222    pub tag: String,
1223    /// None 表示「按同 tag 内出现次序自动编号」
1224    pub index: Option<usize>,
1225    pub name: String,
1226}
1227
1228/// 解析端口声明。参见 docs/design.md §6.4。
1229pub 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        // trace 记的是每次回调的耗时,故与 watchdog 同样强制提升 —— 哪怕显式配了 `off`。
1498        let cfg = GraphConfig::from_yaml("nodes: []\nstats: off\ntrace_capacity: 8").unwrap();
1499        assert_eq!(cfg.effective_stats_level(), StatsLevel::Full);
1500        // 关闭时不得越权提升:默认仍是 basic。
1501        let off = GraphConfig::from_yaml("nodes: []\ntrace_capacity: 0").unwrap();
1502        assert_eq!(off.effective_stats_level(), StatsLevel::Basic);
1503    }
1504
1505    /// `docs/lmflow-config.schema.json` 与 `GraphConfig` 必须逐字段对齐。schema 顶层是
1506    /// `additionalProperties: false`,所以**漏一个字段**就等于让文档里教的配置校验不过;
1507    /// 反过来 `GraphConfig` 是 `deny_unknown_fields`,schema **多一个字段**就等于放行一个
1508    /// 引擎其实会拒的键。两边都是静默失败,靠人记着同步已经漏过一次(`trace_capacity`)。
1509    #[test]
1510    fn schema_and_graph_config_agree_on_top_level_fields() {
1511        // 这个解构是这个测试的关键:给 `GraphConfig` 加字段会让**这里编译不过**,从而强制
1512        // 把新字段同时补进下面的名单和 schema。没有它,这个测试只是又一处要手动同步的地方。
1513        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        // 与上面解构出的字段一一对应(没有 serde rename,故 YAML 键名与字段名同名)。
1529        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        // JSON 是 YAML 1.2 的子集,故直接用已有的 serde_yaml 解析,无需新增依赖。
1546        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        // 不配时默认:不绑核、优先级 0(普通分时)
1621        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        // 静默忽略是最坏的结果 —— 用户会以为开了某个特性,实际没有。
1637        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        // max_in_flight>1 与「源节点该挂什么执行器」现在要看解析出的执行器长什么样
1652        // (是池还是委托、几个线程),校验在 Graph::build 里 —— 见 tests/max_in_flight.rs
1653        // 与 tests/concurrency.rs。
1654        // fixed_size 现已实现;仍保留「未实现的特性必须报错」这条原则的其它用例
1655    }
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}