Skip to main content

earthsci_ast/
graph.rs

1//! Graph utilities for analyzing model structure and coupling
2
3use crate::{CouplingEntry, EsmFile};
4
5/// Return a map's keys sorted lexicographically.
6///
7/// Node and edge order feeds rendered output (`to_dot`/`to_mermaid`/
8/// `to_json_graph`), so every component/variable map is iterated in
9/// sorted-key order rather than nondeterministic `HashMap` order.
10fn sorted_keys<V>(map: &indexmap::IndexMap<String, V>) -> Vec<&String> {
11    let mut keys: Vec<&String> = map.keys().collect();
12    keys.sort();
13    keys
14}
15
16/// The scope a target with no owning component reports (esm-libraries-spec
17/// §4.8.2). A bare `Model`/`ReactionSystem`/`Equation`/`Reaction`/`Expr` handed
18/// straight to [`expression_graph`] has no document position, so its variables
19/// are unscoped and belong to the pseudo-system `default`.
20const DEFAULT_SYSTEM: &str = "default";
21
22/// `equation_index` for a dependency that no positionally-numbered equation or
23/// reaction produced: a `variable_map` folded in by
24/// [`ExpressionGraphOptions::merge_coupled`], and the synthetic `expr_result`
25/// edges of a bare-expression target.
26const NON_EQUATION_INDEX: i64 = -1;
27
28/// Which incident edges a closure query follows.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum Direction {
31    /// Both directions — the undirected neighbourhood.
32    Both,
33    /// Edges pointing AT the node.
34    Incoming,
35    /// Edges pointing AWAY from the node.
36    Outgoing,
37}
38
39/// The neighbours of `node` reachable over `edges` in `direction`, sorted and
40/// deduplicated.
41///
42/// `known` reports whether `node` is a registered graph node: an unknown key
43/// resolves to `[]` rather than scanning. A neighbour is NOT required to be a
44/// registered node — the guard is on the node being asked about, matching the
45/// reference implementation.
46fn neighbours<'a, I>(known: bool, edges: I, node: &str, direction: Direction) -> Vec<String>
47where
48    I: Iterator<Item = (&'a str, &'a str)>,
49{
50    if !known {
51        return Vec::new();
52    }
53    let mut out = std::collections::BTreeSet::new();
54    for (source, target) in edges {
55        if direction != Direction::Incoming && source == node {
56            out.insert(target.to_string());
57        }
58        if direction != Direction::Outgoing && target == node {
59            out.insert(source.to_string());
60        }
61    }
62    out.into_iter().collect()
63}
64
65/// Render a graph as the JSON adjacency-list export of esm-libraries-spec
66/// §4.8.3: `{"nodes": [...], "edges": [...], "adjacency": {...}}`.
67///
68/// Every node object carries an `id` (a component node's `id`, a variable
69/// node's `name`) alongside its own serialized fields; every edge is
70/// `{source, target, data}`; `adjacency` maps each node key to its UNDIRECTED
71/// neighbourhood.
72fn render_json_graph<N, E>(
73    nodes: &[N],
74    node_key: impl Fn(&N) -> String,
75    edges: &[E],
76    edge_endpoints: impl Fn(&E) -> (String, String),
77    adjacency: impl Fn(&str) -> Vec<String>,
78) -> String
79where
80    N: serde::Serialize,
81    E: serde::Serialize,
82{
83    let json_nodes: Vec<serde_json::Value> = nodes
84        .iter()
85        .map(|node| {
86            let key = node_key(node);
87            let mut value = serde_json::to_value(node).unwrap_or(serde_json::Value::Null);
88            if let Some(object) = value.as_object_mut() {
89                object.insert("id".to_string(), serde_json::Value::String(key));
90            }
91            value
92        })
93        .collect();
94
95    let json_edges: Vec<serde_json::Value> = edges
96        .iter()
97        .map(|edge| {
98            let (source, target) = edge_endpoints(edge);
99            serde_json::json!({
100                "source": source,
101                "target": target,
102                "data": serde_json::to_value(edge).unwrap_or(serde_json::Value::Null),
103            })
104        })
105        .collect();
106
107    let mut json_adjacency = serde_json::Map::new();
108    for node in nodes {
109        let key = node_key(node);
110        let neighbours = adjacency(&key);
111        json_adjacency.insert(key, serde_json::to_value(neighbours).unwrap_or_default());
112    }
113
114    let graph = serde_json::json!({
115        "nodes": json_nodes,
116        "edges": json_edges,
117        "adjacency": serde_json::Value::Object(json_adjacency),
118    });
119    serde_json::to_string_pretty(&graph).unwrap_or_else(|_| "{}".to_string())
120}
121
122/// Component graph representing model structure
123#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
124pub struct ComponentGraph {
125    /// Nodes in the graph (models, reaction systems)
126    pub nodes: Vec<ComponentNode>,
127    /// Edges representing coupling relationships
128    pub edges: Vec<CouplingEdge>,
129}
130
131/// Summary counts a component node carries (esm-libraries-spec §4.8.1).
132///
133/// A model reports its declared variables and its equations; a reaction system
134/// reports its reactions as `eq_count`, its species as `species_count`, and its
135/// PARAMETERS as `var_count` — the schema forbids a `variables` field there and
136/// `species_count` already carries the species, so `parameters` is the only
137/// thing left for §4.8.1's "variable count" to mean, and it is the exact
138/// analogue of a model's `variables` (which likewise counts unknowns and
139/// parameters together). Subsystems contribute NOTHING — the counts describe
140/// the component's own body, matching the reference implementation.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
142pub struct ComponentMetadata {
143    /// Declared variables (models only)
144    pub var_count: usize,
145    /// Equations (models) or reactions (reaction systems)
146    pub eq_count: usize,
147    /// Declared species (reaction systems only)
148    pub species_count: usize,
149}
150
151/// Node in the component graph
152#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
153pub struct ComponentNode {
154    /// Unique node identifier
155    pub id: String,
156    /// Type of component
157    pub component_type: ComponentType,
158    /// Human-readable name
159    pub name: Option<String>,
160    /// Summary counts (§4.8.1)
161    pub metadata: ComponentMetadata,
162}
163
164/// Type of component in the graph.
165///
166/// There is no data-source arm. esm-libraries-spec §4.8.1 is explicit that "a
167/// `data_sources` entry is not a component and is NOT a node": from 1.0.0
168/// external data reaches a model as a PARAMETER whose `update` names the
169/// source, so the dependency is already carried by that parameter's node.
170#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum ComponentType {
173    /// ODE model
174    Model,
175    /// Reaction system
176    ReactionSystem,
177}
178
179/// Kind of coupling relationship represented by an edge
180#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum CouplingEdgeKind {
183    /// Operator composition of two named systems
184    OperatorCompose,
185    /// Direct coupling of two named systems
186    Couple,
187    /// Variable mapping between two systems
188    VariableMap,
189}
190
191impl std::fmt::Display for CouplingEdgeKind {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.write_str(match self {
194            CouplingEdgeKind::OperatorCompose => "operator_compose",
195            CouplingEdgeKind::Couple => "couple",
196            CouplingEdgeKind::VariableMap => "variable_map",
197        })
198    }
199}
200
201/// Edge in the component graph
202#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
203pub struct CouplingEdge {
204    /// Source component ID
205    pub from: String,
206    /// Target component ID
207    pub to: String,
208    /// Type of coupling
209    pub coupling_type: CouplingEdgeKind,
210    /// Human-readable edge label (§4.8.1): `compose` / `couple`, or, for a
211    /// `variable_map`, the mapped variable — everything after the FIRST dot of
212    /// the `from` reference.
213    pub label: String,
214    /// Additional coupling data
215    pub data: serde_json::Value,
216}
217
218/// Build a component graph from an ESM file
219///
220/// # Arguments
221///
222/// * `esm_file` - The ESM file to analyze
223///
224/// # Returns
225///
226/// * Component graph showing structure and coupling
227pub fn component_graph(esm_file: &EsmFile) -> ComponentGraph {
228    let mut nodes = Vec::new();
229    let mut edges = Vec::new();
230
231    // Add model nodes
232    if let Some(ref models) = esm_file.models {
233        for id in sorted_keys(models) {
234            let model = &models[id];
235            nodes.push(ComponentNode {
236                id: id.clone(),
237                component_type: ComponentType::Model,
238                name: model.name.clone(),
239                metadata: ComponentMetadata {
240                    var_count: model.variables.len(),
241                    eq_count: model.equations.len(),
242                    species_count: 0,
243                },
244            });
245        }
246    }
247
248    // Add reaction system nodes
249    if let Some(ref reaction_systems) = esm_file.reaction_systems {
250        for id in sorted_keys(reaction_systems) {
251            let rs = &reaction_systems[id];
252            nodes.push(ComponentNode {
253                id: id.clone(),
254                component_type: ComponentType::ReactionSystem,
255                name: None,
256                metadata: ComponentMetadata {
257                    var_count: rs.parameters.len(),
258                    eq_count: rs.reactions.len(),
259                    species_count: rs.species.len(),
260                },
261            });
262        }
263    }
264
265    // NOTE: `data_sources` contributes NO nodes (esm-libraries-spec §4.8.1). A
266    // data source is a document-scoped ingest registry exposing no variables of
267    // its own; a model reaches it through a parameter whose `update` names it,
268    // so the dependency it creates is already carried by that parameter.
269
270    // Add coupling edges.
271    //
272    // Coupling edges only connect endpoints that are real component nodes
273    // (models, reaction systems). Coupling kinds that do not name two concrete
274    // components (operator_apply, callback, event, coupling_import) contribute
275    // no edge to the source-level component graph.
276    let node_ids: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
277
278    if let Some(ref coupling_entries) = esm_file.coupling {
279        for entry in coupling_entries {
280            // Resolve each coupling entry to a (from, to, kind, label) tuple.
281            //
282            // `operator_compose`/`couple` name two concrete systems;
283            // `variable_map` resolves each endpoint to its owning system via the
284            // scope prefix and labels the edge with the mapped variable. Kinds
285            // that do not name two concrete components (operator_apply,
286            // callback, event, coupling_import) contribute no edge.
287            let (from, to, kind, label) = match entry {
288                CouplingEntry::OperatorCompose { systems, .. } => {
289                    if systems.len() >= 2 {
290                        (
291                            systems[0].clone(),
292                            systems[1].clone(),
293                            CouplingEdgeKind::OperatorCompose,
294                            "compose".to_string(),
295                        )
296                    } else {
297                        continue; // Skip invalid coupling
298                    }
299                }
300                CouplingEntry::Couple { systems, .. } => {
301                    if systems.len() == 2 {
302                        (
303                            systems[0].clone(),
304                            systems[1].clone(),
305                            CouplingEdgeKind::Couple,
306                            "couple".to_string(),
307                        )
308                    } else {
309                        continue; // Skip invalid coupling
310                    }
311                }
312                CouplingEntry::VariableMap { from, to, .. } => {
313                    // BOTH endpoints must be SCOPED (`System.variable...`): an
314                    // unscoped reference names no component, so it can neither
315                    // anchor an edge nor label one. The label is everything
316                    // after the FIRST dot of `from`.
317                    let (Some((from_system, from_var)), Some((to_system, _))) =
318                        (from.split_once('.'), to.split_once('.'))
319                    else {
320                        continue;
321                    };
322                    (
323                        from_system.to_string(),
324                        to_system.to_string(),
325                        CouplingEdgeKind::VariableMap,
326                        from_var.to_string(),
327                    )
328                }
329                // These coupling kinds do not name two concrete component nodes,
330                // so they contribute no edge to the component graph.
331                CouplingEntry::OperatorApply { .. }
332                | CouplingEntry::Callback { .. }
333                | CouplingEntry::Event { .. }
334                | CouplingEntry::CouplingImport { .. } => continue,
335            };
336
337            // Only emit an edge when both endpoints are real graph nodes.
338            if node_ids.contains(from.as_str()) && node_ids.contains(to.as_str()) {
339                edges.push(CouplingEdge {
340                    from,
341                    to,
342                    coupling_type: kind,
343                    label,
344                    data: serde_json::Value::Null,
345                });
346            }
347        }
348    }
349
350    ComponentGraph { nodes, edges }
351}
352
353/// Check if a component exists in the ESM file
354///
355/// # Arguments
356///
357/// * `esm_file` - The ESM file to check
358/// * `component_id` - The component ID to look for
359///
360/// # Returns
361///
362/// * `true` if the component exists, `false` otherwise
363pub fn component_exists(esm_file: &EsmFile, component_id: &str) -> bool {
364    get_component_type(esm_file, component_id).is_some()
365}
366
367/// Get the type of a component
368///
369/// A `data_sources` id is NOT a component (esm-libraries-spec §4.8.1) and
370/// resolves to `None`.
371///
372/// # Arguments
373///
374/// * `esm_file` - The ESM file to check
375/// * `component_id` - The component ID to look for
376///
377/// # Returns
378///
379/// * `Some(ComponentType)` if the component exists
380/// * `None` if the component doesn't exist
381pub fn get_component_type(esm_file: &EsmFile, component_id: &str) -> Option<ComponentType> {
382    fn contains<V>(map: &Option<indexmap::IndexMap<String, V>>, key: &str) -> bool {
383        map.as_ref().is_some_and(|m| m.contains_key(key))
384    }
385
386    if contains(&esm_file.models, component_id) {
387        Some(ComponentType::Model)
388    } else if contains(&esm_file.reaction_systems, component_id) {
389        Some(ComponentType::ReactionSystem)
390    } else {
391        None
392    }
393}
394
395/// Expression graph representing variable dependencies within expressions
396#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
397pub struct ExpressionGraph {
398    /// Nodes representing variables only (no operators or constants)
399    pub nodes: Vec<VariableNode>,
400    /// Edges representing dependencies between variables
401    pub edges: Vec<DependencyEdge>,
402}
403
404/// Node representing a variable in an expression graph
405#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
406pub struct VariableNode {
407    /// Variable name, SCOPED by its owning system (`Transport.temperature`).
408    /// A variable of the pseudo-system `default` — a bare target handed
409    /// straight to [`expression_graph`] — is unscoped.
410    pub name: String,
411    /// Variable kind/type
412    pub kind: VariableKind,
413    /// Physical units (optional)
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub units: Option<String>,
416    /// Which model/system owns this variable
417    pub system: String,
418}
419
420/// Type/kind of variable, as the graph renders it.
421///
422/// These are the esm-spec §6.3.1 DERIVED categories, not declared types — esm
423/// 1.0.0 declares only `unknown` and `parameter`. The mapping is fixed by
424/// [`variable_kind`].
425#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
426#[serde(rename_all = "snake_case")]
427pub enum VariableKind {
428    /// An ODE state — an unknown carrying a time derivative.
429    State,
430    /// An unknown pinned only by an implicit algebraic constraint. Solved for
431    /// like a state, but not differentiated.
432    Algebraic,
433    /// A constant or sampled parameter.
434    Parameter,
435    /// An unknown defined by a bare-variable equation — eliminable.
436    Observed,
437    /// A parameter whose update is `wiener` — any present promotes the model
438    /// to an SDE.
439    Brownian,
440    /// A parameter carrying any other update: piecewise-constant between
441    /// refreshes, never differentiated by the solver.
442    Discrete,
443    /// Chemical species
444    Species,
445}
446
447/// The graph kind of one variable, derived through [`crate::classification`]
448/// (esm-spec §6.3.1) rather than read off a declared type.
449fn variable_kind(class: &crate::classification::Classification, name: &str) -> VariableKind {
450    if class.is_observed(name) {
451        VariableKind::Observed
452    } else if class.is_brownian(name) {
453        VariableKind::Brownian
454    } else if class.is_discrete_parameter(name) {
455        VariableKind::Discrete
456    } else if class.ode_states.iter().any(|s| s == name) {
457        VariableKind::State
458    } else if class.algebraic_unknowns.iter().any(|s| s == name) {
459        VariableKind::Algebraic
460    } else {
461        VariableKind::Parameter
462    }
463}
464
465/// Edge representing dependencies between variables in an expression graph
466#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
467pub struct DependencyEdge {
468    /// Source variable name (influences the target)
469    pub source: String,
470    /// Target variable name (is influenced by the source)
471    pub target: String,
472    /// How the dependency arises
473    pub relationship: DependencyRelationship,
474    /// Which equation/reaction index produced this edge, or `-1` when no
475    /// positionally-numbered equation did (a folded `variable_map`).
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub equation_index: Option<i64>,
478    /// The relevant subexpression (optional)
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub expression: Option<crate::Expr>,
481}
482
483/// PROVENANCE of a dependency — which structural site produced it, NOT a
484/// classification of the operators involved. An equation edge is always
485/// `Additive` (even for `w ~ u * v`) and a definition edge always
486/// `Multiplicative` (even for `w ~ u + v`).
487#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
488#[serde(rename_all = "snake_case")]
489pub enum DependencyRelationship {
490    /// An RHS variable of an equation → the variable that equation defines.
491    Additive,
492    /// A free variable of an expression definition or a coupling variable map
493    /// → the value it defines.
494    Multiplicative,
495    /// A reaction-rate variable → a species the reaction consumes or produces.
496    Rate,
497    /// A substrate species → a product species, via stoichiometry.
498    Stoichiometric,
499}
500
501/// Settings for [`expression_graph_with_options`].
502#[derive(Debug, Clone, Default)]
503pub struct ExpressionGraphOptions {
504    /// Fold `variable_map` coupling entries into cross-system dependency edges
505    /// (esm-libraries-spec §4.8.2, "coupled file-level graph"). Applies to
506    /// [`EsmFile`] targets only; every other target carries no coupling list.
507    pub merge_coupled: bool,
508}
509
510/// Build an expression graph from various ESM components
511///
512/// # Arguments
513///
514/// * `input` - Can be an ESM file, model, reaction system, equation, reaction, or expression
515///
516/// # Returns
517///
518/// * `ExpressionGraph` - Graph showing variable dependencies
519pub fn expression_graph<T>(input: &T) -> ExpressionGraph
520where
521    T: ExpressionGraphInput,
522{
523    input.build_expression_graph()
524}
525
526/// Build an expression graph with explicit [`ExpressionGraphOptions`].
527///
528/// # Arguments
529///
530/// * `input` - Can be an ESM file, model, reaction system, equation, reaction, or expression
531/// * `options` - Settings; options that do not apply to `input` are ignored
532///
533/// # Returns
534///
535/// * `ExpressionGraph` - Graph showing variable dependencies
536pub fn expression_graph_with_options<T>(
537    input: &T,
538    options: &ExpressionGraphOptions,
539) -> ExpressionGraph
540where
541    T: ExpressionGraphInput,
542{
543    input.build_expression_graph_with_options(options)
544}
545
546/// Trait for types that can build expression graphs
547pub trait ExpressionGraphInput {
548    /// Build the graph with default options.
549    fn build_expression_graph(&self) -> ExpressionGraph;
550
551    /// Build the graph honouring `options`. The default implementation ignores
552    /// them: only [`EsmFile`] carries a coupling list for `merge_coupled` to
553    /// fold in.
554    fn build_expression_graph_with_options(
555        &self,
556        _options: &ExpressionGraphOptions,
557    ) -> ExpressionGraph {
558        self.build_expression_graph()
559    }
560}
561
562/// The growing node/edge lists of one expression-graph build, plus the dedup
563/// index that keys nodes by their SCOPED name.
564struct ExprGraphBuilder {
565    nodes: Vec<VariableNode>,
566    edges: Vec<DependencyEdge>,
567    seen: std::collections::HashSet<String>,
568}
569
570impl ExprGraphBuilder {
571    fn new() -> Self {
572        ExprGraphBuilder {
573            nodes: Vec::new(),
574            edges: Vec::new(),
575            seen: std::collections::HashSet::new(),
576        }
577    }
578
579    /// Add a node, deduplicated by its scoped name, and return that name. A
580    /// name already present keeps the kind and units it was first added with —
581    /// declared variables are added before the equations that mention them, so
582    /// a declaration always wins over the `parameter`/`state` fallback an
583    /// equation would supply.
584    fn add_node(
585        &mut self,
586        name: &str,
587        kind: VariableKind,
588        units: Option<String>,
589        system: &str,
590    ) -> String {
591        let scoped = if system == DEFAULT_SYSTEM {
592            name.to_string()
593        } else {
594            format!("{system}.{name}")
595        };
596        if self.seen.insert(scoped.clone()) {
597            self.nodes.push(VariableNode {
598                name: scoped.clone(),
599                kind,
600                units,
601                system: system.to_string(),
602            });
603        }
604        scoped
605    }
606
607    fn add_dependency(
608        &mut self,
609        source: String,
610        target: String,
611        relationship: DependencyRelationship,
612        equation_index: i64,
613        expression: Option<crate::Expr>,
614    ) {
615        self.edges.push(DependencyEdge {
616            source,
617            target,
618            relationship,
619            equation_index: Some(equation_index),
620            expression,
621        });
622    }
623
624    fn finish(self) -> ExpressionGraph {
625        ExpressionGraph {
626            nodes: self.nodes,
627            edges: self.edges,
628        }
629    }
630}
631
632impl ExpressionGraphInput for crate::EsmFile {
633    fn build_expression_graph(&self) -> ExpressionGraph {
634        self.build_expression_graph_with_options(&ExpressionGraphOptions::default())
635    }
636
637    fn build_expression_graph_with_options(
638        &self,
639        options: &ExpressionGraphOptions,
640    ) -> ExpressionGraph {
641        let mut b = ExprGraphBuilder::new();
642
643        // Sorted-key iteration: node/edge order feeds rendered output and
644        // must not depend on HashMap ordering.
645        if let Some(ref models) = self.models {
646            for model_id in sorted_keys(models) {
647                process_model_tree(&mut b, &models[model_id], model_id);
648            }
649        }
650
651        if let Some(ref reaction_systems) = self.reaction_systems {
652            for rs_id in sorted_keys(reaction_systems) {
653                process_reaction_system_tree(&mut b, &reaction_systems[rs_id], rs_id);
654            }
655        }
656
657        if options.merge_coupled
658            && let Some(ref coupling) = self.coupling
659        {
660            process_coupling(&mut b, coupling);
661        }
662
663        b.finish()
664    }
665}
666
667impl ExpressionGraphInput for crate::Model {
668    fn build_expression_graph(&self) -> ExpressionGraph {
669        let mut b = ExprGraphBuilder::new();
670        process_model_tree(&mut b, self, DEFAULT_SYSTEM);
671        b.finish()
672    }
673}
674
675impl ExpressionGraphInput for crate::ReactionSystem {
676    fn build_expression_graph(&self) -> ExpressionGraph {
677        let mut b = ExprGraphBuilder::new();
678        process_reaction_system_tree(&mut b, self, DEFAULT_SYSTEM);
679        b.finish()
680    }
681}
682
683impl ExpressionGraphInput for crate::Equation {
684    fn build_expression_graph(&self) -> ExpressionGraph {
685        // Delegate to the same per-equation extractor used by `process_model`
686        // so a standalone equation and a model equation share one
687        // `equation_index` policy. A standalone equation has no equation list,
688        // hence index 0.
689        let mut b = ExprGraphBuilder::new();
690        process_equation(&mut b, self, 0, DEFAULT_SYSTEM);
691        b.finish()
692    }
693}
694
695impl ExpressionGraphInput for crate::Reaction {
696    fn build_expression_graph(&self) -> ExpressionGraph {
697        // Delegate to the same per-reaction extractor used by
698        // `process_reaction_system` so a standalone reaction and a reaction in
699        // a system share one variable classification (rate variables →
700        // parameters) and one `equation_index` policy.
701        let mut b = ExprGraphBuilder::new();
702        process_reaction(&mut b, self, 0, DEFAULT_SYSTEM);
703        b.finish()
704    }
705}
706
707impl ExpressionGraphInput for crate::Expr {
708    fn build_expression_graph(&self) -> ExpressionGraph {
709        // esm-libraries-spec §4.8.2: for a bare expression "every variable in
710        // the expression becomes a node, and the tree structure is flattened
711        // into dependency edges" — so the expression's VALUE is a synthetic
712        // observed node every free variable feeds.
713        let mut b = ExprGraphBuilder::new();
714        // NON_EQUATION_INDEX: a bare expression has no positional equation to
715        // index, which is exactly what the sentinel is for. (The Equation and
716        // Reaction overloads DO number their single target 0.)
717        process_expression(
718            &mut b,
719            self,
720            "expr_result",
721            NON_EQUATION_INDEX,
722            DEFAULT_SYSTEM,
723        );
724        b.finish()
725    }
726}
727
728/// Is this `subsystems` entry an unresolved include stub (`{"ref": …}`) rather
729/// than an inline component? A stub's contents are resolved elsewhere, so the
730/// graph treats it as an opaque leaf and contributes nothing for it.
731fn is_reference_stub(entry: &serde_json::Value) -> bool {
732    entry.get("ref").is_some()
733}
734
735/// Deserialize a `subsystems` entry as a [`crate::Model`], supplying the
736/// defaults a partial component omits (`variables` / `equations`) so a
737/// subsystem that declares only one of them still contributes its half.
738fn as_model(entry: &serde_json::Value) -> Option<crate::Model> {
739    let mut value = entry.clone();
740    let object = value.as_object_mut()?;
741    object
742        .entry("variables")
743        .or_insert_with(|| serde_json::json!({}));
744    object
745        .entry("equations")
746        .or_insert_with(|| serde_json::json!([]));
747    serde_json::from_value(value).ok()
748}
749
750/// Deserialize a `subsystems` entry as a [`crate::ReactionSystem`], supplying
751/// the defaults a partial component omits.
752fn as_reaction_system(entry: &serde_json::Value) -> Option<crate::ReactionSystem> {
753    let mut value = entry.clone();
754    let object = value.as_object_mut()?;
755    object
756        .entry("species")
757        .or_insert_with(|| serde_json::json!({}));
758    object
759        .entry("parameters")
760        .or_insert_with(|| serde_json::json!({}));
761    object
762        .entry("reactions")
763        .or_insert_with(|| serde_json::json!([]));
764    serde_json::from_value(value).ok()
765}
766
767/// The scoped name of a child component of `system_id`. A `default` parent —
768/// only reachable when a bare `Model`/`ReactionSystem` is handed straight to
769/// [`expression_graph`] — yields a bare child name.
770fn child_scope(system_id: &str, child_name: &str) -> String {
771    if system_id == DEFAULT_SYSTEM {
772        child_name.to_string()
773    } else {
774        format!("{system_id}.{child_name}")
775    }
776}
777
778/// Add a model's own variables and equations, then recurse into its inline
779/// `subsystems` (esm-libraries-spec §4.8.2). Reference stubs are skipped.
780fn process_model_tree(b: &mut ExprGraphBuilder, model: &crate::Model, system_id: &str) {
781    process_model(b, model, system_id);
782
783    let Some(ref subsystems) = model.subsystems else {
784        return;
785    };
786    for child_name in sorted_keys(subsystems) {
787        let entry = &subsystems[child_name];
788        if is_reference_stub(entry) {
789            continue;
790        }
791        if let Some(child) = as_model(entry) {
792            process_model_tree(b, &child, &child_scope(system_id, child_name));
793        }
794    }
795}
796
797/// Add a reaction system's own body, then recurse into its inline
798/// `subsystems`. Reference stubs are skipped.
799fn process_reaction_system_tree(
800    b: &mut ExprGraphBuilder,
801    rs: &crate::ReactionSystem,
802    system_id: &str,
803) {
804    process_reaction_system(b, rs, system_id);
805
806    let Some(ref subsystems) = rs.subsystems else {
807        return;
808    };
809    for child_name in sorted_keys(subsystems) {
810        let entry = &subsystems[child_name];
811        if is_reference_stub(entry) {
812            continue;
813        }
814        if let Some(child) = as_reaction_system(entry) {
815            process_reaction_system_tree(b, &child, &child_scope(system_id, child_name));
816        }
817    }
818}
819
820/// Add a model's declared variables (with their DERIVED kinds) and the
821/// dependency edges of its equations.
822fn process_model(b: &mut ExprGraphBuilder, model: &crate::Model, system_id: &str) {
823    // The node kind is DERIVED (esm-spec §6.3.1), never read off
824    // `variable.type`: the format declares only `unknown` and `parameter`, so
825    // classifying here is the only way the graph can distinguish a state from
826    // an observed, or a Brownian parameter from a constant.
827    let class = crate::classification::Classification::of(model);
828    for var_name in sorted_keys(&model.variables) {
829        let kind = variable_kind(&class, var_name);
830        b.add_node(
831            var_name,
832            kind,
833            model.variables[var_name].units.clone(),
834            system_id,
835        );
836    }
837
838    for (eq_idx, equation) in model.equations.iter().enumerate() {
839        process_equation(b, equation, eq_idx as i64, system_id);
840    }
841}
842
843/// Add a reaction system's species, declared parameters, reactions, and
844/// constraint equations.
845fn process_reaction_system(b: &mut ExprGraphBuilder, rs: &crate::ReactionSystem, system_id: &str) {
846    for species_name in sorted_keys(&rs.species) {
847        b.add_node(
848            species_name,
849            VariableKind::Species,
850            rs.species[species_name].units.clone(),
851            system_id,
852        );
853    }
854
855    // Declared parameters are nodes in their own right, so a rate constant
856    // keeps the units it was declared with instead of being fabricated
857    // unit-less by the reaction that mentions it.
858    for param_name in sorted_keys(&rs.parameters) {
859        b.add_node(
860            param_name,
861            VariableKind::Parameter,
862            rs.parameters[param_name].units.clone(),
863            system_id,
864        );
865    }
866
867    for (rxn_idx, reaction) in rs.reactions.iter().enumerate() {
868        process_reaction(b, reaction, rxn_idx as i64, system_id);
869    }
870
871    // Constraint equations are numbered AFTER the reactions.
872    if let Some(ref constraints) = rs.constraint_equations {
873        for (idx, equation) in constraints.iter().enumerate() {
874            process_equation(b, equation, (idx + rs.reactions.len()) as i64, system_id);
875        }
876    }
877}
878
879/// The single variable an equation LHS defines: a bare name, or the name under
880/// the derivative / element-index / aggregate-output wrappers (`D(x)`,
881/// `index(v, i)`, `aggregate(…, expr: D(index(v, i)))`).
882///
883/// Deliberately NOT [`crate::classification::lhs_form`]: that helper answers a
884/// different question (which §6.3.1 CATEGORY the LHS puts the unknown in) and
885/// so refuses a SPATIAL derivative and accepts `faq`/`broadcast` shells.
886/// The graph only wants to know WHICH quantity is written, whatever kind of
887/// derivative writes it.
888fn lhs_target_name(lhs: &crate::Expr) -> Option<String> {
889    match lhs {
890        crate::Expr::Variable(name) => Some(name.clone()),
891        crate::Expr::Operator(node) => match node.op.as_str() {
892            "D" | "index" => node.args.first().and_then(lhs_target_name),
893            "faq" => node.expr.as_deref().and_then(lhs_target_name),
894            _ => None,
895        },
896        crate::Expr::Number(_) | crate::Expr::Integer(_) => None,
897    }
898}
899
900/// Add the nodes and edges contributed by a single equation: one edge from
901/// every free variable of the RHS to the variable the LHS defines (including
902/// self-references such as `D(x)/dt = -x`).
903///
904/// An LHS that names no single variable — an implicit constraint like
905/// `f(x, y) ~ 0` — defines nothing and contributes no edge.
906fn process_equation(
907    b: &mut ExprGraphBuilder,
908    equation: &crate::Equation,
909    eq_idx: i64,
910    system_id: &str,
911) {
912    let Some(target_name) = lhs_target_name(&equation.lhs) else {
913        return;
914    };
915    let lhs_var = b.add_node(&target_name, VariableKind::State, None, system_id);
916
917    for rhs_var in extract_variables_from_expr(&equation.rhs) {
918        let source = b.add_node(&rhs_var, VariableKind::Parameter, None, system_id);
919        b.add_dependency(
920            source,
921            lhs_var.clone(),
922            DependencyRelationship::Additive,
923            eq_idx,
924            Some(equation.rhs.clone()),
925        );
926    }
927}
928
929/// Add the nodes and edges contributed by a single reaction: a `rate` edge
930/// from every rate variable to every substrate and product, and a
931/// `stoichiometric` edge from every substrate to every product.
932fn process_reaction(
933    b: &mut ExprGraphBuilder,
934    reaction: &crate::Reaction,
935    rxn_idx: i64,
936    system_id: &str,
937) {
938    let rate_vars = extract_variables_from_expr(&reaction.rate);
939
940    // Substrates are consumed.
941    for substrate in reaction.substrates.iter().flatten() {
942        let substrate_var = b.add_node(&substrate.species, VariableKind::Species, None, system_id);
943        for rate_var in &rate_vars {
944            let param = b.add_node(rate_var, VariableKind::Parameter, None, system_id);
945            b.add_dependency(
946                param,
947                substrate_var.clone(),
948                DependencyRelationship::Rate,
949                rxn_idx,
950                Some(reaction.rate.clone()),
951            );
952        }
953    }
954
955    // Products are produced.
956    for product in reaction.products.iter().flatten() {
957        let product_var = b.add_node(&product.species, VariableKind::Species, None, system_id);
958        for rate_var in &rate_vars {
959            let param = b.add_node(rate_var, VariableKind::Parameter, None, system_id);
960            b.add_dependency(
961                param,
962                product_var.clone(),
963                DependencyRelationship::Rate,
964                rxn_idx,
965                Some(reaction.rate.clone()),
966            );
967        }
968        for substrate in reaction.substrates.iter().flatten() {
969            let substrate_var =
970                b.add_node(&substrate.species, VariableKind::Species, None, system_id);
971            b.add_dependency(
972                substrate_var,
973                product_var.clone(),
974                DependencyRelationship::Stoichiometric,
975                rxn_idx,
976                Some(reaction.rate.clone()),
977            );
978        }
979    }
980}
981
982/// Add one expression's free-variable → result dependency edges.
983fn process_expression(
984    b: &mut ExprGraphBuilder,
985    expr: &crate::Expr,
986    target_var: &str,
987    eq_idx: i64,
988    system_id: &str,
989) {
990    let target = b.add_node(target_var, VariableKind::Observed, None, system_id);
991    for free_var in extract_variables_from_expr(expr) {
992        let source = b.add_node(&free_var, VariableKind::Parameter, None, system_id);
993        b.add_dependency(
994            source,
995            target.clone(),
996            DependencyRelationship::Multiplicative,
997            eq_idx,
998            Some(expr.clone()),
999        );
1000    }
1001}
1002
1003/// Fold `variable_map` coupling entries into cross-system dependency edges
1004/// (esm-libraries-spec §4.8.2). Both endpoints must be scoped; either is added
1005/// as a `parameter` node if the components' own bodies did not already declare
1006/// it.
1007fn process_coupling(b: &mut ExprGraphBuilder, coupling: &[CouplingEntry]) {
1008    for entry in coupling {
1009        let CouplingEntry::VariableMap { from, to, .. } = entry else {
1010            continue;
1011        };
1012        let (Some((from_system, from_var)), Some((to_system, to_var))) =
1013            (from.split_once('.'), to.split_once('.'))
1014        else {
1015            continue;
1016        };
1017
1018        let source = b.add_node(from_var, VariableKind::Parameter, None, from_system);
1019        let target = b.add_node(to_var, VariableKind::Parameter, None, to_system);
1020        b.add_dependency(
1021            source,
1022            target,
1023            DependencyRelationship::Multiplicative,
1024            NON_EQUATION_INDEX,
1025            Some(crate::Expr::Variable(from.clone())),
1026        );
1027    }
1028}
1029
1030/// Index symbols a node BINDS for its own body: a `faq`'s
1031/// `ranges` keys and `output_idx` entries, and an `integral`'s `var`.
1032///
1033/// Narrower than `structural::bound_index_symbols`, which also treats every bare
1034/// name in an `index(A, i, j)` position as bound. Those positions are exactly
1035/// where an aggregate's binders appear, and subtracting them THERE rather than
1036/// at the binding node would hide a real reference to a declared variable used
1037/// as an index. The binder itself is caught at its `faq`.
1038fn graph_bound_index_symbols(node: &crate::types::ExpressionNode) -> Vec<String> {
1039    let mut bound = Vec::new();
1040    if let Some(ranges) = &node.ranges {
1041        bound.extend(ranges.keys().cloned());
1042    }
1043    if let Some(idx) = &node.output_idx {
1044        bound.extend(idx.iter().cloned());
1045    }
1046    if let Some(v) = &node.int_var {
1047        bound.push(v.clone());
1048    }
1049    bound
1050}
1051
1052/// Accumulate `expr`'s variable references into `out`, subtracting each node's
1053/// own binders at that node.
1054fn collect_graph_variables(expr: &crate::Expr, out: &mut std::collections::HashSet<String>) {
1055    match expr {
1056        crate::Expr::Variable(name) => {
1057            out.insert(name.clone());
1058        }
1059        crate::Expr::Operator(node) => {
1060            // Collect this node's subtree into a LOCAL set, subtract the symbols
1061            // this node binds, and only then promote what is left to the caller.
1062            let mut local = std::collections::HashSet::new();
1063            node.for_each_child(&mut |child| collect_graph_variables(child, &mut local));
1064            for symbol in graph_bound_index_symbols(node) {
1065                local.remove(&symbol);
1066            }
1067            out.extend(local);
1068        }
1069        crate::Expr::Number(_) | crate::Expr::Integer(_) => {}
1070    }
1071}
1072
1073/// Extract the variable names an expression REFERENCES, with every locally-bound
1074/// index symbol removed, sorted and deduplicated for deterministic node/edge
1075/// ordering — the node set §4.8.2 asks for.
1076///
1077/// Walks the full canonical child set
1078/// ([`crate::types::ExpressionNode::for_each_child`]) so variables inside
1079/// aggregate bodies, `filter` predicates, integral bounds, makearray `values`,
1080/// and `table_lookup` axes all contribute graph edges.
1081///
1082/// Deliberately NOT [`crate::expression::collect_variables`]. That collector
1083/// reports every bare name it reaches, so a `faq`'s own range binders
1084/// (`sum over a of src[a]`) came back looking like model variables and became
1085/// graph nodes with no declaration, no units and no kind. A binder is introduced
1086/// by the aggregate's own `ranges` clause and is scoped to it; it is not a
1087/// variable of the system, so it is not a node. The subtraction is PER NODE, at
1088/// the binder that introduces the symbol, so a name bound inside one reduction
1089/// and free outside it is still reported.
1090///
1091/// `collect_variables` itself is unchanged: it backs the public `free_variables`
1092/// and is shared with unit conversion, where "every name the subtree mentions"
1093/// is the wanted answer.
1094fn extract_variables_from_expr(expr: &crate::Expr) -> Vec<String> {
1095    let mut set = std::collections::HashSet::new();
1096    collect_graph_variables(expr, &mut set);
1097    let mut vars: Vec<String> = set.into_iter().collect();
1098    vars.sort();
1099    vars
1100}
1101
1102/// Escape a string for use inside a DOT double-quoted id/label.
1103///
1104/// DOT quoted strings tolerate spaces, dots, and parens verbatim; only
1105/// backslashes and double quotes must be escaped. Ids/labels are always emitted
1106/// double-quoted by the callers, so simple ids remain unchanged.
1107fn escape_dot(s: &str) -> String {
1108    s.replace('\\', "\\\\").replace('"', "\\\"")
1109}
1110
1111/// Render a mermaid label, quoting it only when it contains characters that
1112/// would break unquoted mermaid text (double quotes, the shape delimiters
1113/// `()[]{}`, angle brackets, or the edge-label pipe).
1114///
1115/// Simple identifiers and plain names with spaces (e.g. `Test Model`) are
1116/// emitted verbatim so existing simple output — and the tests that assert it —
1117/// is unchanged; only labels with structural characters are wrapped in quotes,
1118/// with embedded double quotes replaced by the mermaid `#quot;` entity. (Node
1119/// ids themselves are emitted as-is: mermaid ids cannot be quoted, so ids with
1120/// structural characters remain a rendering limitation.)
1121fn mermaid_label(s: &str) -> String {
1122    if s.contains(['"', '(', ')', '[', ']', '{', '}', '<', '>', '|']) {
1123        format!("\"{}\"", s.replace('"', "#quot;"))
1124    } else {
1125        s.to_string()
1126    }
1127}
1128
1129impl ComponentGraph {
1130    /// Is `id` a registered node of this graph?
1131    fn has_node(&self, id: &str) -> bool {
1132        self.nodes.iter().any(|n| n.id == id)
1133    }
1134
1135    /// Iterate this graph's edges as `(source, target)` id pairs.
1136    fn endpoints(&self) -> impl Iterator<Item = (&str, &str)> {
1137        self.edges.iter().map(|e| (e.from.as_str(), e.to.as_str()))
1138    }
1139
1140    /// The UNDIRECTED neighbourhood of `id` — its predecessors together with
1141    /// its successors (esm-libraries-spec §4.8.3), sorted. An unknown id
1142    /// resolves to `[]`.
1143    pub fn adjacency(&self, id: &str) -> Vec<String> {
1144        neighbours(self.has_node(id), self.endpoints(), id, Direction::Both)
1145    }
1146
1147    /// The nodes with an edge pointing AT `id`, sorted.
1148    pub fn predecessors(&self, id: &str) -> Vec<String> {
1149        neighbours(self.has_node(id), self.endpoints(), id, Direction::Incoming)
1150    }
1151
1152    /// The nodes `id` points at, sorted.
1153    pub fn successors(&self, id: &str) -> Vec<String> {
1154        neighbours(self.has_node(id), self.endpoints(), id, Direction::Outgoing)
1155    }
1156
1157    /// Export graph to DOT format for Graphviz
1158    ///
1159    /// # Returns
1160    ///
1161    /// * `String` - DOT representation of the graph
1162    pub fn to_dot(&self) -> String {
1163        let mut dot = String::from("digraph ComponentGraph {\n");
1164        dot.push_str("  rankdir=LR;\n");
1165        dot.push_str("  node [shape=box];\n\n");
1166
1167        // Add nodes
1168        for node in &self.nodes {
1169            let shape = match node.component_type {
1170                ComponentType::Model => "ellipse",
1171                ComponentType::ReactionSystem => "box",
1172            };
1173
1174            let label = node.name.as_ref().unwrap_or(&node.id);
1175            dot.push_str(&format!(
1176                "  \"{}\" [label=\"{}\" shape={}];\n",
1177                escape_dot(&node.id),
1178                escape_dot(label),
1179                shape
1180            ));
1181        }
1182
1183        dot.push('\n');
1184
1185        // Add edges
1186        for edge in &self.edges {
1187            dot.push_str(&format!(
1188                "  \"{}\" -> \"{}\" [label=\"{}\"];\n",
1189                escape_dot(&edge.from),
1190                escape_dot(&edge.to),
1191                escape_dot(&edge.coupling_type.to_string())
1192            ));
1193        }
1194
1195        dot.push_str("}\n");
1196        dot
1197    }
1198
1199    /// Export graph to Mermaid format
1200    ///
1201    /// # Returns
1202    ///
1203    /// * `String` - Mermaid representation of the graph
1204    pub fn to_mermaid(&self) -> String {
1205        // `graph TD`, not `graph LR`. §4.8.3 requires a Mermaid export and
1206        // specifies no syntax for it, so the cross-binding tie-break is the
1207        // majority, applied to the keyword and the direction independently:
1208        // `graph` beats `flowchart` 4-1 (this binding, Python, Go and Julia
1209        // against TypeScript) and `TD` beats `LR` 3-2 (TypeScript, Python and
1210        // Julia against Go and this binding). The EXPRESSION graph below
1211        // already wrote `graph TD`. tests/conformance/graph/cases.json pins it.
1212        let mut mermaid = String::from("graph TD\n");
1213
1214        // Add nodes with types
1215        for node in &self.nodes {
1216            let shape = match node.component_type {
1217                ComponentType::Model => ("(", ")"),
1218                ComponentType::ReactionSystem => ("[", "]"),
1219            };
1220
1221            let label = node.name.as_ref().unwrap_or(&node.id);
1222            mermaid.push_str(&format!(
1223                "  {}{}{}{}\n",
1224                node.id,
1225                shape.0,
1226                mermaid_label(label),
1227                shape.1
1228            ));
1229        }
1230
1231        // Add edges
1232        for edge in &self.edges {
1233            mermaid.push_str(&format!(
1234                "  {} -->|{}| {}\n",
1235                edge.from,
1236                mermaid_label(&edge.coupling_type.to_string()),
1237                edge.to
1238            ));
1239        }
1240
1241        mermaid
1242    }
1243
1244    /// Export graph as the JSON adjacency list of esm-libraries-spec §4.8.3.
1245    ///
1246    /// # Returns
1247    ///
1248    /// * `String` - `{"nodes": […], "edges": […], "adjacency": {…}}`
1249    pub fn to_json_graph(&self) -> String {
1250        render_json_graph(
1251            &self.nodes,
1252            |node| node.id.clone(),
1253            &self.edges,
1254            |edge| (edge.from.clone(), edge.to.clone()),
1255            |id| self.adjacency(id),
1256        )
1257    }
1258}
1259
1260impl ExpressionGraph {
1261    /// Is `name` a registered node of this graph?
1262    fn has_node(&self, name: &str) -> bool {
1263        self.nodes.iter().any(|n| n.name == name)
1264    }
1265
1266    /// Iterate this graph's edges as `(source, target)` name pairs.
1267    fn endpoints(&self) -> impl Iterator<Item = (&str, &str)> {
1268        self.edges
1269            .iter()
1270            .map(|e| (e.source.as_str(), e.target.as_str()))
1271    }
1272
1273    /// The UNDIRECTED neighbourhood of `name` — its predecessors together with
1274    /// its successors (esm-libraries-spec §4.8.3), sorted. An unknown name
1275    /// resolves to `[]`.
1276    pub fn adjacency(&self, name: &str) -> Vec<String> {
1277        neighbours(self.has_node(name), self.endpoints(), name, Direction::Both)
1278    }
1279
1280    /// The variables an edge points FROM into `name`, sorted.
1281    pub fn predecessors(&self, name: &str) -> Vec<String> {
1282        neighbours(
1283            self.has_node(name),
1284            self.endpoints(),
1285            name,
1286            Direction::Incoming,
1287        )
1288    }
1289
1290    /// The variables `name` points at, sorted.
1291    pub fn successors(&self, name: &str) -> Vec<String> {
1292        neighbours(
1293            self.has_node(name),
1294            self.endpoints(),
1295            name,
1296            Direction::Outgoing,
1297        )
1298    }
1299
1300    /// Export graph to DOT format for Graphviz
1301    ///
1302    /// # Returns
1303    ///
1304    /// * `String` - DOT representation of the expression graph
1305    pub fn to_dot(&self) -> String {
1306        let mut dot = String::from("digraph ExpressionGraph {\n");
1307        dot.push_str("  rankdir=TB;\n");
1308        dot.push_str("  node [shape=ellipse];\n\n");
1309
1310        // Add nodes (all variables). An `algebraic` unknown renders like a
1311        // state: it is solved for, and it was classified `state` before the
1312        // kind vocabulary gained its own arm.
1313        for node in &self.nodes {
1314            let shape = match node.kind {
1315                VariableKind::State | VariableKind::Algebraic => "ellipse",
1316                VariableKind::Parameter => "box",
1317                VariableKind::Observed => "diamond",
1318                VariableKind::Brownian => "doubleoctagon",
1319                VariableKind::Discrete => "hexagon",
1320                VariableKind::Species => "circle",
1321            };
1322
1323            dot.push_str(&format!(
1324                "  \"{}\" [label=\"{}\" shape={}];\n",
1325                escape_dot(&node.name),
1326                escape_dot(&node.name),
1327                shape
1328            ));
1329        }
1330
1331        dot.push('\n');
1332
1333        // Add edges
1334        for edge in &self.edges {
1335            let label = match edge.relationship {
1336                DependencyRelationship::Additive => "additive",
1337                DependencyRelationship::Multiplicative => "mult",
1338                DependencyRelationship::Rate => "rate",
1339                DependencyRelationship::Stoichiometric => "stoich",
1340            };
1341            dot.push_str(&format!(
1342                "  \"{}\" -> \"{}\" [label=\"{}\"];\n",
1343                escape_dot(&edge.source),
1344                escape_dot(&edge.target),
1345                label
1346            ));
1347        }
1348
1349        dot.push_str("}\n");
1350        dot
1351    }
1352
1353    /// Export graph to Mermaid format
1354    ///
1355    /// # Returns
1356    ///
1357    /// * `String` - Mermaid representation of the expression graph
1358    pub fn to_mermaid(&self) -> String {
1359        let mut mermaid = String::from("graph TD\n");
1360
1361        // Add nodes with appropriate shapes
1362        for node in &self.nodes {
1363            let (shape_start, shape_end) = match node.kind {
1364                VariableKind::State | VariableKind::Algebraic => ("(", ")"),
1365                VariableKind::Parameter => ("[", "]"),
1366                VariableKind::Observed => ("{", "}"),
1367                VariableKind::Brownian => ("{{", "}}"),
1368                VariableKind::Discrete => ("[/", "/]"),
1369                VariableKind::Species => ("((", "))"),
1370            };
1371
1372            mermaid.push_str(&format!(
1373                "  {}{}{}{}\n",
1374                node.name,
1375                shape_start,
1376                mermaid_label(&node.name),
1377                shape_end
1378            ));
1379        }
1380
1381        // Add edges
1382        for edge in &self.edges {
1383            mermaid.push_str(&format!("  {} --> {}\n", edge.source, edge.target));
1384        }
1385
1386        mermaid
1387    }
1388
1389    /// Export graph as the JSON adjacency list of esm-libraries-spec §4.8.3.
1390    ///
1391    /// # Returns
1392    ///
1393    /// * `String` - `{"nodes": […], "edges": […], "adjacency": {…}}`
1394    pub fn to_json_graph(&self) -> String {
1395        render_json_graph(
1396            &self.nodes,
1397            |node| node.name.clone(),
1398            &self.edges,
1399            |edge| (edge.source.clone(), edge.target.clone()),
1400            |name| self.adjacency(name),
1401        )
1402    }
1403}
1404
1405/// The graph kinds the esm-libraries-spec §4.8.3 text exporters render.
1406///
1407/// API_SPEC.md §8 item 8 rules the canonical renderer trio to be
1408/// [`to_dot`] / [`to_mermaid`] / [`to_json_graph`], one free function each,
1409/// dispatching on the graph kind — the spelling TypeScript, Python and Julia
1410/// already share. This trait is that dispatch: it is implemented by
1411/// [`ComponentGraph`] and [`ExpressionGraph`] and by nothing else, and each
1412/// method forwards to the graph's own inherent renderer, so the rendered
1413/// bytes are unchanged (they are pinned by `tests/conformance/graph`).
1414///
1415/// It is deliberately NOT re-exported from the crate root: §8 item 8 adds
1416/// three functions to the surface, not a fourth name.
1417pub trait Graph {
1418    /// This graph rendered as Graphviz DOT.
1419    fn render_dot(&self) -> String;
1420    /// This graph rendered as Mermaid.
1421    fn render_mermaid(&self) -> String;
1422    /// This graph rendered as a JSON adjacency-list document.
1423    fn render_json_graph(&self) -> String;
1424}
1425
1426impl Graph for ComponentGraph {
1427    fn render_dot(&self) -> String {
1428        ComponentGraph::to_dot(self)
1429    }
1430    fn render_mermaid(&self) -> String {
1431        ComponentGraph::to_mermaid(self)
1432    }
1433    fn render_json_graph(&self) -> String {
1434        ComponentGraph::to_json_graph(self)
1435    }
1436}
1437
1438impl Graph for ExpressionGraph {
1439    fn render_dot(&self) -> String {
1440        ExpressionGraph::to_dot(self)
1441    }
1442    fn render_mermaid(&self) -> String {
1443        ExpressionGraph::to_mermaid(self)
1444    }
1445    fn render_json_graph(&self) -> String {
1446        ExpressionGraph::to_json_graph(self)
1447    }
1448}
1449
1450/// Render `graph` as Graphviz DOT (esm-libraries-spec §4.8.3).
1451///
1452/// The canonical free-function spelling (API_SPEC.md §8 item 8). Equivalent to
1453/// the graph's own `to_dot` method; the byte-level output is pinned by
1454/// `tests/conformance/graph/cases.json`.
1455pub fn to_dot<G: Graph>(graph: &G) -> String {
1456    graph.render_dot()
1457}
1458
1459/// Render `graph` as Mermaid (esm-libraries-spec §4.8.3). See [`to_dot`].
1460pub fn to_mermaid<G: Graph>(graph: &G) -> String {
1461    graph.render_mermaid()
1462}
1463
1464/// Render `graph` as a JSON adjacency-list document (esm-libraries-spec
1465/// §4.8.3). Named `to_json_graph`, not `to_json`, because `to_json` is the
1466/// DOCUMENT serializer (API_SPEC.md §8 item 2). See [`to_dot`].
1467pub fn to_json_graph<G: Graph>(graph: &G) -> String {
1468    graph.render_json_graph()
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473    use super::*;
1474    use crate::test_support::test_file;
1475    use crate::{Expr, ExpressionNode as ExprNode, Model, ReactionSystem};
1476    use indexmap::IndexMap;
1477
1478    /// An empty model carrying only a display name.
1479    fn test_model(name: &str) -> Model {
1480        Model {
1481            name: Some(name.to_string()),
1482            ..Default::default()
1483        }
1484    }
1485
1486    #[test]
1487    fn test_component_graph_empty() {
1488        let esm_file = test_file();
1489
1490        let graph = component_graph(&esm_file);
1491        assert_eq!(graph.nodes.len(), 0);
1492        assert_eq!(graph.edges.len(), 0);
1493    }
1494
1495    #[test]
1496    fn test_component_graph_with_models() {
1497        let mut models = IndexMap::new();
1498        models.insert("model1".to_string(), test_model("Test Model 1"));
1499        models.insert("model2".to_string(), test_model("Test Model 2"));
1500
1501        let esm_file = EsmFile {
1502            models: Some(models),
1503            ..test_file()
1504        };
1505
1506        let graph = component_graph(&esm_file);
1507        assert_eq!(graph.nodes.len(), 2);
1508
1509        let node1 = graph.nodes.iter().find(|n| n.id == "model1").unwrap();
1510        assert_eq!(node1.component_type, ComponentType::Model);
1511        assert_eq!(node1.name, Some("Test Model 1".to_string()));
1512
1513        let node2 = graph.nodes.iter().find(|n| n.id == "model2").unwrap();
1514        assert_eq!(node2.component_type, ComponentType::Model);
1515        assert_eq!(node2.name, Some("Test Model 2".to_string()));
1516    }
1517
1518    #[test]
1519    fn test_component_exists() {
1520        let mut models = IndexMap::new();
1521        models.insert("test_model".to_string(), test_model("Test Model"));
1522
1523        let esm_file = EsmFile {
1524            models: Some(models),
1525            ..test_file()
1526        };
1527
1528        assert!(component_exists(&esm_file, "test_model"));
1529        assert!(!component_exists(&esm_file, "nonexistent"));
1530    }
1531
1532    #[test]
1533    fn test_get_component_type() {
1534        let mut models = IndexMap::new();
1535        models.insert("test_model".to_string(), test_model("Test Model"));
1536
1537        let mut reaction_systems = IndexMap::new();
1538        reaction_systems.insert("test_rs".to_string(), ReactionSystem::default());
1539
1540        let esm_file = EsmFile {
1541            models: Some(models),
1542            reaction_systems: Some(reaction_systems),
1543            ..test_file()
1544        };
1545
1546        assert_eq!(
1547            get_component_type(&esm_file, "test_model"),
1548            Some(ComponentType::Model)
1549        );
1550        assert_eq!(
1551            get_component_type(&esm_file, "test_rs"),
1552            Some(ComponentType::ReactionSystem)
1553        );
1554        assert_eq!(get_component_type(&esm_file, "nonexistent"), None);
1555    }
1556
1557    #[test]
1558    fn test_expression_graph() {
1559        let expr = Expr::operator(ExprNode {
1560            op: "+".to_string(),
1561            args: vec![Expr::Variable("x".to_string()), Expr::Number(1.0)],
1562            wrt: None,
1563            dim: None,
1564            ..Default::default()
1565        });
1566
1567        let graph = expression_graph(&expr);
1568        // Variable dependency graph: only variables as nodes, no operators or
1569        // constants — plus the synthetic node standing for the expression's own
1570        // value (esm-libraries-spec §4.8.2), which every free variable feeds.
1571        assert_eq!(graph.nodes.len(), 2);
1572        assert_eq!(graph.edges.len(), 1);
1573
1574        let result = graph
1575            .nodes
1576            .iter()
1577            .find(|n| n.name == "expr_result")
1578            .expect("synthetic result node");
1579        assert_eq!(result.kind, VariableKind::Observed);
1580        assert_eq!(result.system, "default");
1581
1582        let x = graph
1583            .nodes
1584            .iter()
1585            .find(|n| n.name == "x")
1586            .expect("free variable node");
1587        assert_eq!(x.kind, VariableKind::Parameter);
1588
1589        assert_eq!(graph.edges[0].source, "x");
1590        assert_eq!(graph.edges[0].target, "expr_result");
1591        assert_eq!(
1592            graph.edges[0].relationship,
1593            DependencyRelationship::Multiplicative
1594        );
1595        // A bare expression has no positional equation to index, so its
1596        // `expr_result` edges carry NON_EQUATION_INDEX.
1597        assert_eq!(graph.edges[0].equation_index, Some(NON_EQUATION_INDEX));
1598    }
1599
1600    #[test]
1601    fn test_component_graph_to_dot() {
1602        let graph = ComponentGraph {
1603            nodes: vec![ComponentNode {
1604                id: "model1".to_string(),
1605                component_type: ComponentType::Model,
1606                name: Some("Test Model".to_string()),
1607                metadata: ComponentMetadata::default(),
1608            }],
1609            edges: vec![],
1610        };
1611
1612        let dot = graph.to_dot();
1613        assert!(dot.contains("digraph ComponentGraph"));
1614        assert!(dot.contains("model1"));
1615        assert!(dot.contains("Test Model"));
1616    }
1617
1618    #[test]
1619    fn test_component_graph_to_mermaid() {
1620        let graph = ComponentGraph {
1621            nodes: vec![ComponentNode {
1622                id: "model1".to_string(),
1623                component_type: ComponentType::Model,
1624                name: Some("Test Model".to_string()),
1625                metadata: ComponentMetadata::default(),
1626            }],
1627            edges: vec![],
1628        };
1629
1630        let mermaid = graph.to_mermaid();
1631        assert!(mermaid.contains("graph TD"));
1632        assert!(mermaid.contains("model1(Test Model)"));
1633    }
1634
1635    #[test]
1636    fn test_expression_graph_to_mermaid() {
1637        let expr = Expr::operator(ExprNode {
1638            op: "+".to_string(),
1639            args: vec![Expr::Variable("x".to_string()), Expr::Number(1.0)],
1640            wrt: None,
1641            dim: None,
1642            ..Default::default()
1643        });
1644
1645        let graph = expression_graph(&expr);
1646        let mermaid = graph.to_mermaid();
1647
1648        assert!(mermaid.contains("graph TD"));
1649        assert!(mermaid.contains("x[x]")); // Parameter variable node (square brackets)
1650        // No constants or operators in variable dependency graph
1651        assert!(!mermaid.contains("const_")); // No constant nodes
1652        assert!(!mermaid.contains("{+}")); // No operator nodes
1653        // The free variable feeds the expression's synthetic result node.
1654        assert!(mermaid.contains("expr_result{expr_result}"));
1655        assert!(mermaid.contains("x --> expr_result"));
1656    }
1657
1658    #[test]
1659    fn test_component_graph_variable_map_edge_extraction() {
1660        let mut models = IndexMap::new();
1661        models.insert("source".to_string(), test_model("Source System"));
1662        models.insert("target".to_string(), test_model("Target System"));
1663
1664        let coupling_entries = vec![crate::CouplingEntry::VariableMap {
1665            from: "source.var".to_string(),
1666            to: "target.param".to_string(),
1667            transform: crate::types::VariableMapTransform::Named("identity".to_string()),
1668            factor: None,
1669            lifting: None,
1670            description: None,
1671        }];
1672
1673        let esm_file = EsmFile {
1674            models: Some(models),
1675            coupling: Some(coupling_entries),
1676            ..test_file()
1677        };
1678
1679        let graph = component_graph(&esm_file);
1680
1681        // Should have 2 nodes (source and target systems)
1682        assert_eq!(graph.nodes.len(), 2);
1683
1684        // Should have 1 edge for the variable mapping
1685        assert_eq!(graph.edges.len(), 1);
1686
1687        let edge = &graph.edges[0];
1688
1689        // Edge should connect system names, not full scoped references
1690        assert_eq!(edge.from, "source"); // Not "source.var"
1691        assert_eq!(edge.to, "target"); // Not "target.param"
1692        assert_eq!(edge.coupling_type, CouplingEdgeKind::VariableMap);
1693        // The label is everything after the FIRST dot of `from`.
1694        assert_eq!(edge.label, "var");
1695    }
1696
1697    /// A `variable_map` whose endpoints are not SCOPED names the no component,
1698    /// so it anchors no edge (esm-libraries-spec §4.8.1).
1699    #[test]
1700    fn test_component_graph_variable_map_requires_scoped_endpoints() {
1701        let mut models = IndexMap::new();
1702        for id in ["source", "target"] {
1703            models.insert(id.to_string(), test_model(id));
1704        }
1705
1706        let esm_file = EsmFile {
1707            coupling: Some(vec![crate::CouplingEntry::VariableMap {
1708                from: "source".to_string(),
1709                to: "target".to_string(),
1710                transform: crate::types::VariableMapTransform::Named("identity".to_string()),
1711                factor: None,
1712                lifting: None,
1713                description: None,
1714            }]),
1715            models: Some(models),
1716            ..test_file()
1717        };
1718
1719        let graph = component_graph(&esm_file);
1720        assert_eq!(graph.nodes.len(), 2);
1721        assert!(graph.edges.is_empty());
1722    }
1723
1724    /// esm-libraries-spec §4.8.1: "A `data_sources` entry is not a component
1725    /// and is NOT a node."
1726    #[test]
1727    fn test_data_sources_contribute_no_nodes() {
1728        let json = r#"{
1729            "esm": "1.0.0",
1730            "metadata": { "name": "ds" },
1731            "data_sources": {
1732                "GEOSFP": {
1733                    "kind": "grid",
1734                    "source": { "url_template": "file:///data/GEOSFP/{date:%Y%m%d_%H%M}.nc" }
1735                }
1736            }
1737        }"#;
1738        let esm_file: EsmFile = serde_json::from_str(json).expect("parses");
1739
1740        let graph = component_graph(&esm_file);
1741        assert!(graph.nodes.is_empty());
1742        assert!(graph.edges.is_empty());
1743        assert_eq!(get_component_type(&esm_file, "GEOSFP"), None);
1744        assert!(!component_exists(&esm_file, "GEOSFP"));
1745    }
1746
1747    /// The §4.8.3 closure lookups, on a two-node one-edge graph.
1748    #[test]
1749    fn test_component_graph_closure() {
1750        let mut models = IndexMap::new();
1751        for id in ["source", "target"] {
1752            models.insert(id.to_string(), test_model(id));
1753        }
1754
1755        let esm_file = EsmFile {
1756            coupling: Some(vec![crate::CouplingEntry::VariableMap {
1757                from: "source.var".to_string(),
1758                to: "target.param".to_string(),
1759                transform: crate::types::VariableMapTransform::Named("identity".to_string()),
1760                factor: None,
1761                lifting: None,
1762                description: None,
1763            }]),
1764            models: Some(models),
1765            ..test_file()
1766        };
1767
1768        let graph = component_graph(&esm_file);
1769        assert_eq!(graph.adjacency("source"), vec!["target".to_string()]);
1770        assert_eq!(graph.successors("source"), vec!["target".to_string()]);
1771        assert!(graph.predecessors("source").is_empty());
1772        assert_eq!(graph.adjacency("target"), vec!["source".to_string()]);
1773        assert_eq!(graph.predecessors("target"), vec!["source".to_string()]);
1774        assert!(graph.successors("target").is_empty());
1775        // An unknown key resolves to [] rather than panicking.
1776        assert!(graph.adjacency("nonexistent").is_empty());
1777    }
1778
1779    /// The §4.8.3 JSON adjacency-list export: three top-level keys, an `id` on
1780    /// every node, `{source, target, data}` edges, and an adjacency map.
1781    #[test]
1782    fn test_to_json_graph_is_an_adjacency_list() {
1783        let mut models = IndexMap::new();
1784        for id in ["source", "target"] {
1785            models.insert(id.to_string(), test_model(id));
1786        }
1787
1788        let esm_file = EsmFile {
1789            coupling: Some(vec![crate::CouplingEntry::VariableMap {
1790                from: "source.var".to_string(),
1791                to: "target.param".to_string(),
1792                transform: crate::types::VariableMapTransform::Named("identity".to_string()),
1793                factor: None,
1794                lifting: None,
1795                description: None,
1796            }]),
1797            models: Some(models),
1798            ..test_file()
1799        };
1800
1801        let json: serde_json::Value =
1802            serde_json::from_str(&component_graph(&esm_file).to_json_graph()).expect("valid JSON");
1803
1804        let mut keys: Vec<&String> = json.as_object().expect("object").keys().collect();
1805        keys.sort();
1806        assert_eq!(keys, vec!["adjacency", "edges", "nodes"]);
1807
1808        let nodes = json["nodes"].as_array().expect("nodes array");
1809        assert_eq!(nodes[0]["id"], "source");
1810        let edges = json["edges"].as_array().expect("edges array");
1811        assert_eq!(edges[0]["source"], "source");
1812        assert_eq!(edges[0]["target"], "target");
1813        assert_eq!(edges[0]["data"]["coupling_type"], "variable_map");
1814        assert_eq!(json["adjacency"]["source"][0], "target");
1815        assert_eq!(json["adjacency"]["target"][0], "source");
1816    }
1817
1818    /// API_SPEC.md §8 item 8: the canonical free-function trio dispatches on
1819    /// the graph kind and must render EXACTLY what the inherent methods do —
1820    /// the output is pinned by `tests/conformance/graph/cases.json`.
1821    #[test]
1822    fn canonical_renderers_match_the_inherent_methods() {
1823        let esm_file = EsmFile {
1824            models: Some(IndexMap::from([("m".to_string(), test_model("M"))])),
1825            ..test_file()
1826        };
1827
1828        let cg = component_graph(&esm_file);
1829        assert_eq!(super::to_dot(&cg), cg.to_dot());
1830        assert_eq!(super::to_mermaid(&cg), cg.to_mermaid());
1831        assert_eq!(super::to_json_graph(&cg), cg.to_json_graph());
1832
1833        let eg = expression_graph(&esm_file);
1834        assert_eq!(super::to_dot(&eg), eg.to_dot());
1835        assert_eq!(super::to_mermaid(&eg), eg.to_mermaid());
1836        assert_eq!(super::to_json_graph(&eg), eg.to_json_graph());
1837    }
1838}