Skip to main content

nika_core/ast/
decompose.rs

1//! Decompose Module - Runtime DAG expansion via MCP traversal
2//!
3//! The `decompose:` modifier enables dynamic task expansion based on
4//! semantic graph traversal. Instead of static `for_each` arrays,
5//! decompose queries NovaNet to discover iteration items at runtime.
6//!
7//! # Example
8//!
9//! ```yaml
10//! tasks:
11//!   - id: generate_all
12//!     decompose:
13//!       strategy: semantic
14//!       traverse: HAS_CHILD
15//!       source: $entity
16//!     infer: "Generate for {{with.item}}"
17//! ```
18
19use serde::{Deserialize, Serialize};
20
21/// Decomposition strategy for runtime DAG expansion
22#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
23#[serde(rename_all = "snake_case")]
24pub enum DecomposeStrategy {
25    /// Use novanet_search (walk mode) with arc to discover items
26    #[default]
27    Semantic,
28    /// Use literal array from source binding
29    Static,
30    /// Recursive decomposition (nested traversal)
31    Nested,
32}
33
34/// Specification for runtime decomposition
35///
36/// Decompose expands a task at runtime into multiple iterations
37/// based on graph traversal or static arrays.
38#[derive(Debug, Clone, Deserialize, Serialize)]
39pub struct DecomposeSpec {
40    /// Strategy for discovering iteration items
41    #[serde(default)]
42    pub strategy: DecomposeStrategy,
43    /// Arc name to traverse (e.g., "HAS_CHILD", "HAS_NATIVE")
44    pub traverse: String,
45    /// Source binding expression (e.g., "$entity", "{{with.entity_key}}")
46    pub source: String,
47    /// MCP server to use for traversal (defaults to "novanet")
48    #[serde(default)]
49    pub mcp_server: Option<String>,
50    /// Maximum items to expand (optional limit)
51    #[serde(default)]
52    pub max_items: Option<usize>,
53    /// Maximum recursion depth for nested strategy (default: 3)
54    #[serde(default)]
55    pub max_depth: Option<usize>,
56}
57
58impl DecomposeSpec {
59    /// Get the MCP server name (defaults to "novanet")
60    pub fn mcp_server(&self) -> &str {
61        self.mcp_server.as_deref().unwrap_or("novanet")
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::serde_yaml;
69
70    #[test]
71    fn test_decompose_strategy_default_is_semantic() {
72        let strategy = DecomposeStrategy::default();
73        assert_eq!(strategy, DecomposeStrategy::Semantic);
74    }
75
76    #[test]
77    fn test_decompose_spec_parses_minimal() {
78        let yaml = r#"
79traverse: HAS_CHILD
80source: $entity
81"#;
82        let spec: DecomposeSpec = serde_yaml::from_str(yaml).unwrap();
83        assert_eq!(spec.strategy, DecomposeStrategy::Semantic);
84        assert_eq!(spec.traverse, "HAS_CHILD");
85        assert_eq!(spec.source, "$entity");
86        assert_eq!(spec.mcp_server(), "novanet");
87        assert!(spec.max_items.is_none());
88    }
89
90    #[test]
91    fn test_decompose_spec_parses_full() {
92        let yaml = r#"
93strategy: nested
94traverse: HAS_NATIVE
95source: "{{with.entity_key}}"
96mcp_server: custom_mcp
97max_items: 10
98"#;
99        let spec: DecomposeSpec = serde_yaml::from_str(yaml).unwrap();
100        assert_eq!(spec.strategy, DecomposeStrategy::Nested);
101        assert_eq!(spec.traverse, "HAS_NATIVE");
102        assert_eq!(spec.source, "{{with.entity_key}}");
103        assert_eq!(spec.mcp_server(), "custom_mcp");
104        assert_eq!(spec.max_items, Some(10));
105    }
106
107    #[test]
108    fn test_decompose_spec_static_strategy() {
109        let yaml = r#"
110strategy: static
111traverse: DUMMY
112source: $locales
113"#;
114        let spec: DecomposeSpec = serde_yaml::from_str(yaml).unwrap();
115        assert_eq!(spec.strategy, DecomposeStrategy::Static);
116    }
117
118    #[test]
119    fn test_decompose_spec_serializes() {
120        let spec = DecomposeSpec {
121            strategy: DecomposeStrategy::Semantic,
122            traverse: "HAS_CHILD".to_string(),
123            source: "$entity".to_string(),
124            mcp_server: None,
125            max_items: Some(5),
126            max_depth: None,
127        };
128        let yaml = serde_yaml::to_string(&spec).unwrap();
129        assert!(yaml.contains("traverse: HAS_CHILD"));
130        assert!(yaml.contains("source: $entity"));
131        assert!(yaml.contains("max_items: 5"));
132    }
133}