1use std::collections::{BTreeMap, BTreeSet};
4
5use sim_kernel::{Cx, Expr, Result, Symbol};
6use sim_shape::parse_shape_expr;
7
8use crate::{
9 capability::capability_name_from_symbol,
10 error::validation_error,
11 model::{Edge, EdgeId, Graph, Node, NodeId, Port, PortRef},
12};
13
14mod cycle;
15
16pub fn validate_graph(_cx: &mut Cx, graph: &Graph) -> Result<()> {
18 validate_budget(graph)?;
19 validate_capabilities(graph)?;
20 validate_shapes(graph)?;
21
22 let index = GraphIndex::build(graph)?;
23 validate_node_declarations(graph)?;
24 validate_public_boundary(graph)?;
25 validate_edges(graph, &index)?;
26 validate_required_ports(graph)?;
27 validate_verbs(graph)?;
28 validate_reachability(graph, &index)?;
29 cycle::validate_bounded_cycles(graph, &index)?;
30
31 Ok(())
32}
33
34#[derive(Clone, Copy)]
35enum PortDirection {
36 Input,
37 Output,
38}
39
40struct GraphIndex {
41 nodes: BTreeMap<NodeId, usize>,
42}
43
44impl GraphIndex {
45 fn build(graph: &Graph) -> Result<Self> {
46 let mut nodes = BTreeMap::new();
47 for (index, node) in graph.nodes.iter().enumerate() {
48 if nodes.insert(node.id.clone(), index).is_some() {
49 return Err(validation_error(
50 &graph.name,
51 node_context(node),
52 "duplicate node id",
53 ));
54 }
55 }
56 Ok(Self { nodes })
57 }
58
59 fn node<'a>(&self, graph: &'a Graph, id: &NodeId) -> Option<&'a Node> {
60 self.nodes.get(id).and_then(|index| graph.nodes.get(*index))
61 }
62
63 fn position(&self, id: &NodeId) -> Option<usize> {
64 self.nodes.get(id).copied()
65 }
66}
67
68fn validate_budget(graph: &Graph) -> Result<()> {
69 check_positive(graph, "budget.max_steps", graph.budget.max_steps)?;
70 check_positive(
71 graph,
72 "budget.max_node_visits",
73 graph.budget.max_node_visits,
74 )?;
75 check_positive(
76 graph,
77 "budget.max_edge_visits",
78 graph.budget.max_edge_visits,
79 )?;
80 check_positive(graph, "budget.max_outputs", graph.budget.max_outputs)?;
81 check_positive(graph, "budget.max_child_runs", graph.budget.max_child_runs)?;
82 check_positive(
83 graph,
84 "scheduler.max_concurrency",
85 graph.scheduler.max_concurrency,
86 )?;
87
88 if graph.budget.deadline_ms == Some(0) {
89 return Err(validation_error(
90 &graph.name,
91 "budget.deadline_ms",
92 "deadline must be positive when present",
93 ));
94 }
95
96 Ok(())
97}
98
99fn check_positive(graph: &Graph, context: &str, value: u32) -> Result<()> {
100 if value == 0 {
101 return Err(validation_error(
102 &graph.name,
103 context,
104 "value must be positive",
105 ));
106 }
107 Ok(())
108}
109
110fn validate_capabilities(graph: &Graph) -> Result<()> {
111 let mut seen = BTreeSet::new();
112 for capability in &graph.capabilities {
113 let name = capability_name_from_symbol(capability)
114 .map_err(|err| validation_error(&graph.name, "capabilities", err.to_string()))?;
115 if !seen.insert(name.clone()) {
116 return Err(validation_error(
117 &graph.name,
118 "capabilities",
119 format!("duplicate capability name {name}"),
120 ));
121 }
122 }
123 Ok(())
124}
125
126fn validate_shapes(graph: &Graph) -> Result<()> {
127 validate_shape(graph, "graph.input", graph.input.as_ref())?;
128 validate_shape(graph, "graph.output", graph.output.as_ref())?;
129
130 for node in &graph.nodes {
131 validate_shape(
132 graph,
133 format!("{}.input", node_context(node)),
134 node.input.as_ref(),
135 )?;
136 validate_shape(
137 graph,
138 format!("{}.output", node_context(node)),
139 node.output.as_ref(),
140 )?;
141 for port in &node.inputs {
142 validate_shape(
143 graph,
144 format!("{} input port {}", node_context(node), port.name),
145 port.shape.as_ref(),
146 )?;
147 }
148 for port in &node.outputs {
149 validate_shape(
150 graph,
151 format!("{} output port {}", node_context(node), port.name),
152 port.shape.as_ref(),
153 )?;
154 }
155 }
156
157 for cell in &graph.cells {
158 validate_shape(
159 graph,
160 format!("cell {} shape", cell.name),
161 cell.shape.as_ref(),
162 )?;
163 }
164
165 Ok(())
166}
167
168fn validate_shape(graph: &Graph, context: impl AsRef<str>, shape: Option<&Expr>) -> Result<()> {
169 let Some(shape) = shape else {
170 return Ok(());
171 };
172 parse_shape_expr(shape).map_err(|error| {
173 validation_error(
174 &graph.name,
175 context.as_ref(),
176 format!("invalid shape value: {error}"),
177 )
178 })?;
179 Ok(())
180}
181
182fn validate_node_declarations(graph: &Graph) -> Result<()> {
183 for node in &graph.nodes {
184 if !valid_symbol(node.id.as_symbol()) {
185 return Err(validation_error(
186 &graph.name,
187 node_context(node),
188 "node id must be a non-keyword symbol",
189 ));
190 }
191 if !valid_symbol(&node.verb) {
192 return Err(validation_error(
193 &graph.name,
194 node_context(node),
195 "node verb must be a non-keyword symbol",
196 ));
197 }
198 validate_ports(graph, node, PortDirection::Input, &node.inputs)?;
199 validate_ports(graph, node, PortDirection::Output, &node.outputs)?;
200 }
201 Ok(())
202}
203
204fn validate_ports(
205 graph: &Graph,
206 node: &Node,
207 direction: PortDirection,
208 ports: &[Port],
209) -> Result<()> {
210 let mut seen = BTreeSet::new();
211 for port in ports {
212 if !valid_symbol(&port.name) {
213 return Err(validation_error(
214 &graph.name,
215 format!(
216 "{} {} port {}",
217 node_context(node),
218 direction_name(direction),
219 port.name
220 ),
221 "port name must be a non-keyword symbol",
222 ));
223 }
224 if !seen.insert(port.name.clone()) {
225 return Err(validation_error(
226 &graph.name,
227 format!(
228 "{} {} port {}",
229 node_context(node),
230 direction_name(direction),
231 port.name
232 ),
233 "duplicate port name",
234 ));
235 }
236 }
237 Ok(())
238}
239
240fn validate_public_boundary(graph: &Graph) -> Result<()> {
241 if !graph
242 .nodes
243 .iter()
244 .any(|node| node.verb.name.as_ref() == "in")
245 {
246 return Err(validation_error(
247 &graph.name,
248 "graph",
249 "missing input node with verb in",
250 ));
251 }
252 if !graph
253 .nodes
254 .iter()
255 .any(|node| node.verb.name.as_ref() == "out")
256 {
257 return Err(validation_error(
258 &graph.name,
259 "graph",
260 "missing output node with verb out",
261 ));
262 }
263 Ok(())
264}
265
266fn validate_edges(graph: &Graph, index: &GraphIndex) -> Result<()> {
267 let mut seen = BTreeSet::<EdgeId>::new();
268 for edge in &graph.edges {
269 if !seen.insert(edge.id) {
270 return Err(validation_error(
271 &graph.name,
272 edge_context(edge),
273 "duplicate edge id",
274 ));
275 }
276 if edge.max_visits == Some(0) {
277 return Err(validation_error(
278 &graph.name,
279 edge_context(edge),
280 "max_visits must be positive when present",
281 ));
282 }
283 validate_endpoint(graph, index, edge, &edge.from, PortDirection::Output)?;
284 validate_endpoint(graph, index, edge, &edge.to, PortDirection::Input)?;
285 }
286 Ok(())
287}
288
289fn validate_endpoint(
290 graph: &Graph,
291 index: &GraphIndex,
292 edge: &Edge,
293 endpoint: &PortRef,
294 direction: PortDirection,
295) -> Result<()> {
296 let Some(node) = index.node(graph, &endpoint.node) else {
297 return Err(validation_error(
298 &graph.name,
299 edge_context(edge),
300 format!(
301 "unknown {} endpoint node {}",
302 direction_name(direction),
303 endpoint.node.as_symbol()
304 ),
305 ));
306 };
307
308 let ports = match direction {
309 PortDirection::Input => &node.inputs,
310 PortDirection::Output => &node.outputs,
311 };
312 if !ports.iter().any(|port| port.name == endpoint.port) {
313 return Err(validation_error(
314 &graph.name,
315 edge_context(edge),
316 format!(
317 "unknown {} endpoint port {}:{}",
318 direction_name(direction),
319 endpoint.node.as_symbol(),
320 endpoint.port
321 ),
322 ));
323 }
324 Ok(())
325}
326
327fn validate_required_ports(graph: &Graph) -> Result<()> {
328 let incoming = connected_counts(graph, PortDirection::Input);
329 let outgoing = connected_counts(graph, PortDirection::Output);
330
331 for node in &graph.nodes {
332 for port in &node.inputs {
333 if port.required && connected_count(&incoming, node, port) == 0 {
334 return Err(validation_error(
335 &graph.name,
336 format!("{} input port {}", node_context(node), port.name),
337 "required input port is not connected",
338 ));
339 }
340 }
341 for port in &node.outputs {
342 if port.required && connected_count(&outgoing, node, port) == 0 {
343 return Err(validation_error(
344 &graph.name,
345 format!("{} output port {}", node_context(node), port.name),
346 "required output port is not connected",
347 ));
348 }
349 }
350 }
351 Ok(())
352}
353
354fn connected_counts(graph: &Graph, direction: PortDirection) -> BTreeMap<(NodeId, Symbol), usize> {
355 let mut counts = BTreeMap::new();
356 for edge in &graph.edges {
357 let endpoint = match direction {
358 PortDirection::Input => &edge.to,
359 PortDirection::Output => &edge.from,
360 };
361 *counts
362 .entry((endpoint.node.clone(), endpoint.port.clone()))
363 .or_insert(0) += 1;
364 }
365 counts
366}
367
368fn connected_count(counts: &BTreeMap<(NodeId, Symbol), usize>, node: &Node, port: &Port) -> usize {
369 counts
370 .get(&(node.id.clone(), port.name.clone()))
371 .copied()
372 .unwrap_or(0)
373}
374
375fn validate_verbs(graph: &Graph) -> Result<()> {
376 for node in &graph.nodes {
377 if node.verb.name.as_ref() == "call" && node.target.is_none() {
378 return Err(validation_error(
379 &graph.name,
380 node_context(node),
381 "call node requires target",
382 ));
383 }
384 }
385 Ok(())
386}
387
388fn validate_reachability(graph: &Graph, index: &GraphIndex) -> Result<()> {
389 let adjacency = adjacency(graph, index);
390 let mut visited = vec![false; graph.nodes.len()];
391 for (node_index, node) in graph.nodes.iter().enumerate() {
392 if node.verb.name.as_ref() == "in" {
393 visit_reachable(node_index, &adjacency, &mut visited);
394 }
395 }
396
397 for (node_index, node) in graph.nodes.iter().enumerate() {
398 if node.verb.name.as_ref() == "out" && !visited[node_index] {
399 return Err(validation_error(
400 &graph.name,
401 node_context(node),
402 "output is unreachable from graph input",
403 ));
404 }
405 }
406
407 Ok(())
408}
409
410fn visit_reachable(node: usize, adjacency: &[Vec<(usize, usize)>], visited: &mut [bool]) {
411 if visited[node] {
412 return;
413 }
414 visited[node] = true;
415 for (next, _) in &adjacency[node] {
416 visit_reachable(*next, adjacency, visited);
417 }
418}
419
420fn adjacency(graph: &Graph, index: &GraphIndex) -> Vec<Vec<(usize, usize)>> {
421 let mut adjacency = vec![Vec::new(); graph.nodes.len()];
422 for (edge_index, edge) in graph.edges.iter().enumerate() {
423 let Some(from) = index.position(&edge.from.node) else {
424 continue;
425 };
426 let Some(to) = index.position(&edge.to.node) else {
427 continue;
428 };
429 adjacency[from].push((to, edge_index));
430 }
431 adjacency
432}
433
434fn valid_symbol(symbol: &Symbol) -> bool {
435 !symbol.name.is_empty() && !symbol.name.starts_with(':')
436}
437
438fn direction_name(direction: PortDirection) -> &'static str {
439 match direction {
440 PortDirection::Input => "input",
441 PortDirection::Output => "output",
442 }
443}
444
445fn node_context(node: &Node) -> String {
446 format!("node {}", node.id.as_symbol())
447}
448
449fn edge_context(edge: &Edge) -> String {
450 format!("edge {}", edge.id.0)
451}