Skip to main content

sim_lib_topology/
validate.rs

1//! Static topology graph validation.
2
3use 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
16/// Validates topology graph data before it is compiled or executed.
17pub 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}