Skip to main content

earthsci_ast/
reference_resolution.rs

1//! Build-time reference resolution for the semiring-FAQ unified IR: the
2//! intra-document node-id / index-set dependency DAG.
3//!
4//! Not to be confused with [`crate::ref_loading`], which inlines cross-file
5//! `{ "ref": ... }` subsystem mounts at load time — this module never touches
6//! the filesystem; it wires id-addressed edges inside one document.
7//!
8//! Implements *node addressing* and *reference-edge resolution* — the hard
9//! prerequisite the §6.1 cadence-partition pass of the
10//! `semiring-faq-unified-ir` RFC calls out:
11//!
12//! > "node addressing — referencing a node by id — is a hard prerequisite: the
13//! > pass cannot be built until `from_faq` and join references are real edges
14//! > in this DAG."
15//!
16//! The partition pass classifies every node by cadence (`CONST` / `DISCRETE` /
17//! `CONTINUOUS`) by walking the *inter-node* dependency DAG bottom-up
18//! (`class(n) = max` over inputs). For that walk to exist, three kinds of
19//! name/id reference in the document must be resolved into real, queryable
20//! graph edges (RFC §6.1 "Propagation"):
21//!
22//! * an aggregate node → an index set it references (`ranges[*].from`);
23//! * a `kind:"derived"` index set → its `from_faq` node (by stable id);
24//! * an aggregate `join.on` factor → the factor it names.
25//!
26//! Like the Julia and Python bindings, this pass operates on the **raw parsed
27//! document** ([`serde_json::Value`]) rather than the typed [`crate::types`]
28//! structs — not because the typed layer lacks these fields (it carries every
29//! one: `EsmFile.index_sets`, `ExpressionNode.id`, the `from` of
30//! `RangeSpec::IndexSetRef`, and `ExpressionNode.join`) but for parity with the
31//! Julia and Python passes, which have no typed layer and resolve references
32//! straight from the parsed JSON. Working the same raw shape keeps the three
33//! bindings byte-for-byte in step.
34//! The pass is self-contained and additive — a document using none of these
35//! features yields an empty-but-valid graph.
36//!
37//! The [`ReferenceGraph`] output is the queryable surface the partition pass
38//! consumes: [`ReferenceGraph::dependencies`] / [`ReferenceGraph::dependents`]
39//! give the DAG adjacency, and [`ReferenceGraph::topological_order`] both
40//! detects reference cycles (an out-of-scope implicit/iterative solve, RFC §6.1
41//! "Acyclicity") and yields a bottom-up evaluation order.
42
43use indexmap::IndexMap;
44use serde_json::{Map, Value};
45use std::collections::HashSet;
46use thiserror::Error;
47
48/// Deprecated alias of [`ReferenceResolutionError`].
49///
50/// API_SPEC.md §8 item 10: Julia, Python and Go all raise
51/// `ReferenceResolutionError`; this binding alone spelled it `ReferenceError`.
52/// The canonical spelling is now `ReferenceResolutionError`; this alias is kept
53/// for one minor per API_SPEC.md §10 and will be removed at the next major.
54#[deprecated(
55    since = "0.2.0",
56    note = "renamed to `ReferenceResolutionError` (API_SPEC.md §8 item 10)"
57)]
58pub type ReferenceError = ReferenceResolutionError;
59
60/// A reference could not be resolved, or the reference graph has a cycle.
61///
62/// Variant names are deliberately cross-language-compatible so the Julia,
63/// Python, and Rust bindings report the same failure mode under the same name
64/// (mirrors the `E_REF_*` codes in the Python binding).
65#[derive(Error, Debug, Clone, PartialEq, Eq)]
66pub enum ReferenceResolutionError {
67    /// A `ranges[*].from` names an index set not declared in `index_sets`.
68    #[error(
69        "undeclared index set '{name}' referenced by range '{index}' of node {node} (model '{model}', at {path})"
70    )]
71    UndeclaredIndexSet {
72        name: String,
73        index: String,
74        node: String,
75        model: String,
76        path: String,
77    },
78
79    /// A `kind:"derived"` index set's `from_faq` names no expression-node id.
80    ///
81    /// Resolved at DOCUMENT scope (esm-spec.md §9.7.5); `model` names the model
82    /// whose graph was being built when the lookup failed.
83    #[error(
84        "derived index set '{index_set}' references from_faq '{from_faq}', which is not the id of any expression node in the document (model '{model}')"
85    )]
86    UnknownFaqNode {
87        index_set: String,
88        from_faq: String,
89        model: String,
90    },
91
92    /// Two expression nodes in the same DOCUMENT share an explicit `id`.
93    ///
94    /// Node ids are unique per document, not per model (esm-spec.md §9.7.5);
95    /// when the two occurrences are in different models the paths are
96    /// model-qualified and `model` names the second occurrence's model.
97    #[error("duplicate expression-node id '{id}' in model '{model}' (at {path} and {first})")]
98    DuplicateNodeId {
99        id: String,
100        model: String,
101        path: String,
102        first: String,
103    },
104
105    /// A `join.on` factor reference names nothing in the node's scope.
106    #[error(
107        "join factor '{factor}' of node {node} names no range, output index, factor arg, declared variable, or index set in scope (model '{model}', at {path})"
108    )]
109    UnresolvedJoinFactor {
110        factor: String,
111        node: String,
112        model: String,
113        path: String,
114    },
115
116    /// A directed cycle exists among the reference edges (RFC §6.1 acyclicity).
117    #[error("reference cycle detected: {}", .path.join(" -> "))]
118    ReferenceCycle { path: Vec<String> },
119}
120
121/// The three kinds of vertex in the reference graph.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum VertexKind {
124    /// An expression node (aggregate / `id`-bearing).
125    Node,
126    /// A declared `index_sets` entry.
127    IndexSet,
128    /// A factor named by a `join.on` reference.
129    Factor,
130}
131
132/// The three kinds of reference edge (RFC §6.1 "Propagation").
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum EdgeKind {
135    /// Aggregate node → the index set it iterates (`ranges[*].from`).
136    RangeFrom,
137    /// `kind:"derived"` index set → the node that materialises it (`from_faq`).
138    FromFaq,
139    /// Aggregate node → a factor named by `join.on`.
140    JoinFactor,
141}
142
143/// A vertex in the reference graph, addressed by a kind-namespaced `key`.
144///
145/// `key` is `"{kind}:{name}"`. For a [`VertexKind::Node`] vertex `name` is the
146/// node's stable address: its explicit `id` when present, else its structural
147/// path (e.g. `equations/0/rhs/expr`).
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct ReferenceVertex {
150    pub key: String,
151    pub kind: VertexKind,
152    pub name: String,
153    pub op: Option<String>,
154    pub node_id: Option<String>,
155    pub path: Option<String>,
156}
157
158/// A directed `source → target` edge: *source references / depends on target*.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ReferenceEdge {
161    pub source: String,
162    pub target: String,
163    pub kind: EdgeKind,
164}
165
166/// The resolved reference DAG for one model — the partition pass's input.
167///
168/// Edges point from a vertex to a vertex it *depends on*, so a bottom-up
169/// ([`topological_order`](ReferenceGraph::topological_order)) walk visits each
170/// vertex after its dependencies — the order `class(n) = max(class(inputs))`
171/// propagation needs.
172#[derive(Debug, Clone, Default)]
173pub struct ReferenceGraph {
174    pub model: String,
175    pub vertices: IndexMap<String, ReferenceVertex>,
176    pub edges: Vec<ReferenceEdge>,
177    out: IndexMap<String, Vec<String>>,
178    incoming: IndexMap<String, Vec<String>>,
179}
180
181impl ReferenceGraph {
182    fn ensure_vertex(&mut self, vertex: ReferenceVertex) {
183        if !self.vertices.contains_key(&vertex.key) {
184            self.out.entry(vertex.key.clone()).or_default();
185            self.incoming.entry(vertex.key.clone()).or_default();
186            self.vertices.insert(vertex.key.clone(), vertex);
187        }
188    }
189
190    fn add_edge(&mut self, source: &str, target: &str, kind: EdgeKind) {
191        self.edges.push(ReferenceEdge {
192            source: source.to_string(),
193            target: target.to_string(),
194            kind,
195        });
196        self.out
197            .entry(source.to_string())
198            .or_default()
199            .push(target.to_string());
200        self.incoming
201            .entry(target.to_string())
202            .or_default()
203            .push(source.to_string());
204    }
205
206    /// Vertices `key` references / depends on (its out-neighbours).
207    pub fn dependencies(&self, key: &str) -> Vec<String> {
208        self.out.get(key).cloned().unwrap_or_default()
209    }
210
211    /// Vertices that reference / depend on `key` (its in-neighbours).
212    pub fn dependents(&self, key: &str) -> Vec<String> {
213        self.incoming.get(key).cloned().unwrap_or_default()
214    }
215
216    /// All edges of a given kind, in insertion order.
217    pub fn edges_of_kind(&self, kind: EdgeKind) -> Vec<&ReferenceEdge> {
218        self.edges.iter().filter(|e| e.kind == kind).collect()
219    }
220
221    /// Return a reference cycle as a vertex-key path (`[v, …, v]`), or `None`.
222    ///
223    /// Three-colour DFS over the dependency edges, deterministic (sorted
224    /// vertices, sorted neighbours).
225    pub fn detect_cycle(&self) -> Option<Vec<String>> {
226        #[derive(Clone, Copy, PartialEq)]
227        enum Colour {
228            White,
229            Grey,
230            Black,
231        }
232        let mut colour: IndexMap<String, Colour> = self
233            .vertices
234            .keys()
235            .map(|k| (k.clone(), Colour::White))
236            .collect();
237        let mut starts: Vec<String> = self.vertices.keys().cloned().collect();
238        starts.sort();
239
240        for start in starts {
241            if colour.get(&start).copied().unwrap_or(Colour::White) != Colour::White {
242                continue;
243            }
244            let mut stack: Vec<(String, usize)> = vec![(start.clone(), 0)];
245            let mut path: Vec<String> = vec![start.clone()];
246            colour.insert(start.clone(), Colour::Grey);
247            while let Some((node, i)) = stack.last().cloned() {
248                let mut neigh = self.out.get(&node).cloned().unwrap_or_default();
249                neigh.sort();
250                if i < neigh.len() {
251                    stack.last_mut().unwrap().1 = i + 1;
252                    let nxt = neigh[i].clone();
253                    match colour.get(&nxt).copied().unwrap_or(Colour::White) {
254                        Colour::Grey => {
255                            let idx = path.iter().position(|p| *p == nxt).unwrap_or(0);
256                            let mut cyc: Vec<String> = path[idx..].to_vec();
257                            cyc.push(nxt);
258                            return Some(cyc);
259                        }
260                        Colour::White => {
261                            colour.insert(nxt.clone(), Colour::Grey);
262                            stack.push((nxt.clone(), 0));
263                            path.push(nxt);
264                        }
265                        Colour::Black => {}
266                    }
267                } else {
268                    colour.insert(node.clone(), Colour::Black);
269                    stack.pop();
270                    path.pop();
271                }
272            }
273        }
274        None
275    }
276
277    /// Bottom-up order (dependencies before dependents).
278    ///
279    /// Errors with [`ReferenceResolutionError::ReferenceCycle`] if the graph is cyclic —
280    /// a cycle among reference edges is an out-of-scope implicit/iterative solve
281    /// (RFC §6.1 "Acyclicity").
282    pub fn topological_order(&self) -> Result<Vec<String>, ReferenceResolutionError> {
283        // Two deliberate passes rather than one Kahn sweep: `detect_cycle`'s
284        // DFS reports the actual cycle PATH (Kahn's leftover-vertex set
285        // cannot name the path), and the wave loop below emits ready vertices
286        // in sorted-key order per wave, keeping the materialization order
287        // deterministic. Document node counts are small, so the O(V²) scan
288        // is irrelevant; after the cycle check the no-progress break is
289        // unreachable.
290        if let Some(cyc) = self.detect_cycle() {
291            return Err(ReferenceResolutionError::ReferenceCycle { path: cyc });
292        }
293        let mut emitted: Vec<String> = Vec::new();
294        let mut done: HashSet<String> = HashSet::new();
295        let mut keys: Vec<String> = self.vertices.keys().cloned().collect();
296        keys.sort();
297        while emitted.len() < self.vertices.len() {
298            let mut progressed = false;
299            for k in &keys {
300                if done.contains(k) {
301                    continue;
302                }
303                let deps = self.out.get(k).cloned().unwrap_or_default();
304                if deps.iter().all(|d| done.contains(d)) {
305                    emitted.push(k.clone());
306                    done.insert(k.clone());
307                    progressed = true;
308                }
309            }
310            if !progressed {
311                break;
312            }
313        }
314        Ok(emitted)
315    }
316}
317
318fn node_key(addr: &str) -> String {
319    format!("node:{addr}")
320}
321fn index_set_key(name: &str) -> String {
322    format!("index_set:{name}")
323}
324fn factor_key(name: &str) -> String {
325    format!("factor:{name}")
326}
327
328fn nonempty_str(value: Option<&Value>) -> Option<&str> {
329    value.and_then(|v| v.as_str()).filter(|s| !s.is_empty())
330}
331
332/// Which binder class a `join` name resolves to, or `None`.
333///
334/// An `on` key column is POLYMORPHIC (esm-spec §4.9.5): it may name a loop
335/// symbol bound by the enclosing `ranges`, a document-scoped index set
336/// (§9.7.5), or a declared component-local variable — a value-invention bin
337/// buffer. A binding that diagnoses such a name "must do so against the
338/// variable AND index-set registries", not against node-local names alone.
339///
340/// The classes are tested in the order CONFORMANCE_SPEC §5.5.6 fixes, because a
341/// node-local binder SHADOWS a same-named declared variable (esm-spec §4.3.1
342/// permits one string to be a variable reference outside an aggregate and an
343/// index symbol inside one):
344///
345/// 1. a name the node BINDS — a `ranges` key or a symbolic `output_idx` entry —
346///    tested FIRST;
347/// 2. a node-local string factor `arg`;
348/// 3. a declared component-local variable (the model's variable registry);
349/// 4. a document-scoped index set.
350///
351/// Anything else is unresolvable and raises `E_REF_UNRESOLVED_JOIN_FACTOR`.
352/// Nothing here widens the check to "accept any string": a name in none of the
353/// four registries is still a typo and is still reported.
354fn join_binder_class(
355    map: &Map<String, Value>,
356    name: &str,
357    variables: Option<&Map<String, Value>>,
358    index_sets: Option<&Map<String, Value>>,
359) -> Option<JoinBinder> {
360    if name.is_empty() {
361        return None;
362    }
363    // 1. node-local binders (these shadow a same-named variable)
364    if let Some(ranges) = map.get("ranges").and_then(|v| v.as_object())
365        && ranges.contains_key(name)
366    {
367        return Some(JoinBinder::Range);
368    }
369    if let Some(oi) = map.get("output_idx").and_then(|v| v.as_array())
370        && oi.iter().any(|o| o.as_str() == Some(name))
371    {
372        return Some(JoinBinder::OutputIdx);
373    }
374    // 2. node-local string factor args
375    if let Some(args) = map.get("args").and_then(|v| v.as_array())
376        && args.iter().any(|a| a.as_str() == Some(name))
377    {
378        return Some(JoinBinder::Arg);
379    }
380    // 3. the model's variable registry — where a bin buffer lives
381    if variables.map(|v| v.contains_key(name)).unwrap_or(false) {
382        return Some(JoinBinder::Variable);
383    }
384    // 4. the document-scoped index-set registry
385    if index_sets.map(|v| v.contains_key(name)).unwrap_or(false) {
386        return Some(JoinBinder::IndexSet);
387    }
388    None
389}
390
391/// The binder classes a `join` name may resolve to (CONFORMANCE_SPEC §5.5.6).
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393enum JoinBinder {
394    Range,
395    OutputIdx,
396    Arg,
397    Variable,
398    IndexSet,
399}
400
401/// Diagnose one `join` name, or return the unresolved-join-factor error.
402fn check_join_name(
403    map: &Map<String, Value>,
404    name: Option<&str>,
405    variables: Option<&Map<String, Value>>,
406    index_sets: Option<&Map<String, Value>>,
407    node_key: &str,
408    model_name: &str,
409    path: &str,
410) -> Result<(), ReferenceResolutionError> {
411    let resolved = name
412        .and_then(|n| join_binder_class(map, n, variables, index_sets))
413        .is_some();
414    if resolved {
415        Ok(())
416    } else {
417        Err(ReferenceResolutionError::UnresolvedJoinFactor {
418            factor: name.unwrap_or("").to_string(),
419            node: node_key.to_string(),
420            model: model_name.to_string(),
421            path: path.to_string(),
422        })
423    }
424}
425
426/// One id-bearing expression node, as the `from_faq` scope sees it.
427#[derive(Clone, Debug)]
428struct DocNode {
429    addr: String,
430    path: String,
431    op: Option<String>,
432}
433
434#[allow(clippy::too_many_arguments)]
435fn register_and_process(
436    map: &Map<String, Value>,
437    path: &str,
438    model_name: &str,
439    index_sets: Option<&Map<String, Value>>,
440    variables: Option<&Map<String, Value>>,
441    graph: &mut ReferenceGraph,
442    id_to_addr: &mut IndexMap<String, DocNode>,
443) -> Result<(), ReferenceResolutionError> {
444    let op = map.get("op").and_then(|v| v.as_str());
445    let nid = nonempty_str(map.get("id"));
446    let is_agg = op.map(crate::faq::is_faq_op).unwrap_or(false);
447    // only aggregate / FAQ nodes and any node carrying an explicit id become
448    // addressable vertices.
449    if !is_agg && nid.is_none() {
450        return Ok(());
451    }
452    let addr = nid
453        .map(|s| s.to_string())
454        .unwrap_or_else(|| path.to_string());
455    let key = node_key(&addr);
456
457    if let Some(id) = nid {
458        if let Some(first) = id_to_addr.get(id) {
459            return Err(ReferenceResolutionError::DuplicateNodeId {
460                id: id.to_string(),
461                model: model_name.to_string(),
462                path: path.to_string(),
463                first: first.path.clone(),
464            });
465        }
466        id_to_addr.insert(
467            id.to_string(),
468            DocNode {
469                addr: addr.clone(),
470                path: path.to_string(),
471                op: op.map(|s| s.to_string()),
472            },
473        );
474    }
475
476    graph.ensure_vertex(ReferenceVertex {
477        key: key.clone(),
478        kind: VertexKind::Node,
479        name: addr.clone(),
480        op: op.map(|s| s.to_string()),
481        node_id: nid.map(|s| s.to_string()),
482        path: Some(path.to_string()),
483    });
484
485    // ranges[*].from -> index set
486    if let Some(ranges) = map.get("ranges").and_then(|v| v.as_object()) {
487        for (idx_name, spec) in ranges {
488            if let Some(spec_obj) = spec.as_object()
489                && let Some(from) = spec_obj.get("from")
490            {
491                let target = from.as_str().unwrap_or("");
492                let declared = index_sets.map(|m| m.contains_key(target)).unwrap_or(false);
493                if target.is_empty() || !declared {
494                    return Err(ReferenceResolutionError::UndeclaredIndexSet {
495                        name: target.to_string(),
496                        index: idx_name.clone(),
497                        node: key.clone(),
498                        model: model_name.to_string(),
499                        path: path.to_string(),
500                    });
501                }
502                graph.add_edge(&key, &index_set_key(target), EdgeKind::RangeFrom);
503            }
504        }
505    }
506
507    // join[*].on[*] -> factor
508    if let Some(join) = map.get("join").and_then(|v| v.as_array()) {
509        for clause in join {
510            let on = match clause.get("on").and_then(|v| v.as_array()) {
511                Some(on) => on,
512                None => continue,
513            };
514            for pair in on {
515                let reference = pair
516                    .as_array()
517                    .and_then(|p| p.first())
518                    .and_then(|v| v.as_str());
519                check_join_name(
520                    map, reference, variables, index_sets, &key, model_name, path,
521                )?;
522                // BOTH key columns are references (esm-spec §4.9.5). Only the
523                // LEFT one carries the graph edge: the JoinFactor edge kind
524                // records the node's own key-column dependency, and the right
525                // column is frequently a document-scoped index set, which
526                // already has its own vertex kind. The right column is
527                // DIAGNOSED here and left un-edged, which is what makes
528                // `aggregate/join_filter.esm`'s ["src", "sourceType"] resolve
529                // through the index-set class without inventing a
530                // `factor:sourceType` twin of `index_set:sourceType`.
531                //
532                // A non-string right column is a SCHEMA defect
533                // (tests/invalid/faq/join_on_key_not_string.esm pins it
534                // there) and is not re-diagnosed as a reference error.
535                if let Some(right) = pair.as_array().and_then(|p| p.get(1))
536                    && let Some(r) = right.as_str()
537                {
538                    check_join_name(map, Some(r), variables, index_sets, &key, model_name, path)?;
539                }
540                match reference {
541                    Some(r) => {
542                        graph.ensure_vertex(ReferenceVertex {
543                            key: factor_key(r),
544                            kind: VertexKind::Factor,
545                            name: r.to_string(),
546                            op: None,
547                            node_id: None,
548                            path: None,
549                        });
550                        graph.add_edge(&key, &factor_key(r), EdgeKind::JoinFactor);
551                    }
552                    None => unreachable!("check_join_name rejects a non-string left column"),
553                }
554            }
555        }
556    }
557
558    Ok(())
559}
560
561#[allow(clippy::too_many_arguments)]
562fn walk(
563    value: &Value,
564    path: &str,
565    model_name: &str,
566    index_sets: Option<&Map<String, Value>>,
567    variables: Option<&Map<String, Value>>,
568    graph: &mut ReferenceGraph,
569    id_to_addr: &mut IndexMap<String, DocNode>,
570) -> Result<(), ReferenceResolutionError> {
571    match value {
572        Value::Object(map) => {
573            if map.contains_key("op") {
574                register_and_process(
575                    map, path, model_name, index_sets, variables, graph, id_to_addr,
576                )?;
577            }
578            for (k, v) in map {
579                walk(
580                    v,
581                    &format!("{path}/{k}"),
582                    model_name,
583                    index_sets,
584                    variables,
585                    graph,
586                    id_to_addr,
587                )?;
588            }
589        }
590        Value::Array(arr) => {
591            for (i, v) in arr.iter().enumerate() {
592                walk(
593                    v,
594                    &format!("{path}/{i}"),
595                    model_name,
596                    index_sets,
597                    variables,
598                    graph,
599                    id_to_addr,
600                )?;
601            }
602        }
603        _ => {}
604    }
605    Ok(())
606}
607
608/// The model members whose expression trees carry addressable nodes.
609const WALK_ROOTS: [&str; 2] = ["equations", "initialization_equations"];
610
611/// Collect every explicit expression-node `id` under one model's `value`.
612fn collect_ids(
613    value: &Value,
614    path: &str,
615    model_name: &str,
616    nodes: &mut IndexMap<String, DocNode>,
617) -> Result<(), ReferenceResolutionError> {
618    match value {
619        Value::Object(map) => {
620            if map.contains_key("op")
621                && let Some(id) = nonempty_str(map.get("id"))
622            {
623                let qualified = format!("models/{model_name}/{path}");
624                if let Some(first) = nodes.get(id) {
625                    return Err(ReferenceResolutionError::DuplicateNodeId {
626                        id: id.to_string(),
627                        model: model_name.to_string(),
628                        path: qualified,
629                        first: first.path.clone(),
630                    });
631                }
632                nodes.insert(
633                    id.to_string(),
634                    DocNode {
635                        addr: id.to_string(),
636                        path: qualified,
637                        op: map
638                            .get("op")
639                            .and_then(|v| v.as_str())
640                            .map(|s| s.to_string()),
641                    },
642                );
643            }
644            for (k, v) in map {
645                collect_ids(v, &format!("{path}/{k}"), model_name, nodes)?;
646            }
647        }
648        Value::Array(arr) => {
649            for (i, v) in arr.iter().enumerate() {
650                collect_ids(v, &format!("{path}/{i}"), model_name, nodes)?;
651            }
652        }
653        _ => {}
654    }
655    Ok(())
656}
657
658/// Every explicit expression-node `id` in `document`, keyed by id.
659///
660/// `from_faq` resolves at DOCUMENT scope (esm-spec.md §9.7.5): a document-scoped
661/// `index_sets` entry is visible to every model, so the node it names may live
662/// in any of them. This pass therefore runs over all models BEFORE any single
663/// model's graph is built.
664///
665/// Because ids from different models share one namespace, uniqueness is a
666/// DOCUMENT-wide requirement: a duplicate `id` anywhere in the document is
667/// [`ReferenceResolutionError::DuplicateNodeId`].
668fn collect_document_node_ids(
669    document: &Value,
670) -> Result<IndexMap<String, DocNode>, ReferenceResolutionError> {
671    let mut nodes = IndexMap::new();
672    if let Some(models) = document.get("models").and_then(|v| v.as_object()) {
673        for (model_name, model) in models {
674            for root in WALK_ROOTS {
675                if let Some(v) = model.get(root) {
676                    collect_ids(v, root, model_name, &mut nodes)?;
677                }
678            }
679        }
680    }
681    Ok(nodes)
682}
683
684/// Deprecated alias of [`build_reference_graph`].
685///
686/// API_SPEC.md §8 item 17: the explicit index-set registry is an optional
687/// TRAILING argument of `build_reference_graph`, not a separate name. This
688/// alias is deprecated and will be removed at the next major (API_SPEC.md
689/// §10).
690#[deprecated(
691    since = "0.2.0",
692    note = "the registry is now the trailing argument of `build_reference_graph` (API_SPEC.md §8 item 17)"
693)]
694pub fn build_reference_graph_with_index_sets(
695    model: &Value,
696    model_name: &str,
697    doc_index_sets: Option<&Map<String, Value>>,
698) -> Result<ReferenceGraph, ReferenceResolutionError> {
699    build_reference_graph(model, model_name, doc_index_sets)
700}
701
702/// Resolve the reference edges of one `model` value into a graph, against the
703/// **document-scoped** `index_sets` registry.
704///
705/// One name, with the registry as an optional TRAILING argument (API_SPEC.md
706/// §8 item 17) — the shape Python (`index_sets=None`) and Go (`docIndexSets`)
707/// already have. Pass `None` for a direct caller holding only a raw model;
708/// [`resolve_references`], which is the DOCUMENT-scoped entry point, derives
709/// the registry from the document itself and threads it in for every model.
710///
711/// The `index_sets` registry is a sibling of `models`, shared by every model
712/// (as in [`crate::cadence::model_with_loaders`]). An `index_sets` nested
713/// inside the model itself is merged on top, and a model-level entry wins on
714/// a key collision.
715///
716/// Errors on a duplicate node id, an undeclared `ranges[*].from` index set, a
717/// `from_faq` naming no node, or an unresolved `join.on` factor. Cycles are
718/// reported lazily by [`ReferenceGraph::topological_order`], or eagerly by
719/// [`resolve_references`].
720pub fn build_reference_graph(
721    model: &Value,
722    model_name: &str,
723    doc_index_sets: Option<&Map<String, Value>>,
724) -> Result<ReferenceGraph, ReferenceResolutionError> {
725    build_reference_graph_impl(model, model_name, doc_index_sets, None)
726}
727
728/// [`build_reference_graph`], plus the document-wide `from_faq` scope.
729///
730/// `document_nodes` is the map [`collect_document_node_ids`] builds over every
731/// model. When it is `None` the model's own nodes are the scope.
732fn build_reference_graph_impl(
733    model: &Value,
734    model_name: &str,
735    doc_index_sets: Option<&Map<String, Value>>,
736    document_nodes: Option<&IndexMap<String, DocNode>>,
737) -> Result<ReferenceGraph, ReferenceResolutionError> {
738    let mut graph = ReferenceGraph {
739        model: model_name.to_string(),
740        ..Default::default()
741    };
742
743    // Merge the document-scoped registry (v0.8.0+) with any model-nested one
744    // (pre-0.8.0); model-level entries take precedence on a key collision.
745    let model_index_sets = model.get("index_sets").and_then(|v| v.as_object());
746    let merged_index_sets: Option<Map<String, Value>> = match (doc_index_sets, model_index_sets) {
747        (None, None) => None,
748        (Some(d), None) => Some(d.clone()),
749        (None, Some(m)) => Some(m.clone()),
750        (Some(d), Some(m)) => {
751            let mut merged = d.clone();
752            for (k, v) in m {
753                merged.insert(k.clone(), v.clone());
754            }
755            Some(merged)
756        }
757    };
758    let index_sets = merged_index_sets.as_ref();
759
760    // Pass 1 — register declared index sets as vertices.
761    if let Some(is) = index_sets {
762        for name in is.keys() {
763            graph.ensure_vertex(ReferenceVertex {
764                key: index_set_key(name),
765                kind: VertexKind::IndexSet,
766                name: name.clone(),
767                op: None,
768                node_id: None,
769                path: None,
770            });
771        }
772    }
773
774    // The model's declared variable registry. A `join` `on` key column may name
775    // a declared component-local variable — a value-invention bin buffer written
776    // by an earlier equation — one of the three binder classes esm-spec §4.9.5
777    // makes an `on` column polymorphic over. See `join_binder_class`.
778    let variables = model.get("variables").and_then(|v| v.as_object());
779
780    // Pass 2 — walk every expression node: assign a stable address, register
781    // aggregate / id-bearing nodes, and add the within-node reference edges
782    // (ranges[*].from, join.on). Builds id -> address for from_faq.
783    let mut id_to_addr: IndexMap<String, DocNode> = IndexMap::new();
784    for root in WALK_ROOTS {
785        if let Some(v) = model.get(root) {
786            walk(
787                v,
788                root,
789                model_name,
790                index_sets,
791                variables,
792                &mut graph,
793                &mut id_to_addr,
794            )?;
795        }
796    }
797
798    // Pass 3 — derived index sets resolve their from_faq to a node by id, at
799    // DOCUMENT scope (esm-spec.md §9.7.5): the producing node may live in any
800    // model, since the registry entry naming it is visible to all of them.
801    let faq_scope = document_nodes.unwrap_or(&id_to_addr);
802    if let Some(is) = index_sets {
803        for (name, entry) in is {
804            if entry.get("kind").and_then(|v| v.as_str()) == Some("derived") {
805                let faq = entry.get("from_faq").and_then(|v| v.as_str());
806                match faq.and_then(|f| faq_scope.get(f)) {
807                    Some(target) => {
808                        // The producer may belong to another model; give this
809                        // graph a vertex for it so the edge has a real endpoint.
810                        // Idempotent: a local producer was registered in pass 2.
811                        graph.ensure_vertex(ReferenceVertex {
812                            key: node_key(&target.addr),
813                            kind: VertexKind::Node,
814                            name: target.addr.clone(),
815                            op: target.op.clone(),
816                            node_id: faq.map(|s| s.to_string()),
817                            path: Some(target.path.clone()),
818                        });
819                        graph.add_edge(
820                            &index_set_key(name),
821                            &node_key(&target.addr),
822                            EdgeKind::FromFaq,
823                        );
824                    }
825                    None => {
826                        return Err(ReferenceResolutionError::UnknownFaqNode {
827                            index_set: name.clone(),
828                            from_faq: faq.unwrap_or("").to_string(),
829                            model: model_name.to_string(),
830                        });
831                    }
832                }
833            }
834        }
835    }
836
837    Ok(graph)
838}
839
840/// Resolve reference edges for every model in `document`.
841///
842/// Returns a `{model_name: ReferenceGraph}` map. Errors on any unresolved
843/// reference *or* reference cycle (each model's graph is checked acyclic).
844///
845/// Node ids are collected from EVERY model first, so a derived index set's
846/// `from_faq` may name a producer in any model (esm-spec.md §9.7.5) and a
847/// duplicate id anywhere in the document is an error.
848pub fn resolve_references(
849    document: &Value,
850) -> Result<IndexMap<String, ReferenceGraph>, ReferenceResolutionError> {
851    let mut out = IndexMap::new();
852    let models = match document.get("models").and_then(|v| v.as_object()) {
853        Some(m) => m,
854        None => return Ok(out),
855    };
856    // Since v0.8.0 the index_sets registry is document-scoped (a sibling of
857    // `models`); thread it down so each model's ranges[*].from resolves.
858    let doc_index_sets = document.get("index_sets").and_then(|v| v.as_object());
859    let document_nodes = collect_document_node_ids(document)?;
860    for (model_name, model) in models {
861        let graph =
862            build_reference_graph_impl(model, model_name, doc_index_sets, Some(&document_nodes))?;
863        if let Some(cyc) = graph.detect_cycle() {
864            return Err(ReferenceResolutionError::ReferenceCycle { path: cyc });
865        }
866        out.insert(model_name.clone(), graph);
867    }
868    Ok(out)
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874    use serde_json::json;
875
876    fn agg(extra: Value) -> Value {
877        let mut base = json!({"op": "faq", "args": []});
878        if let (Some(b), Some(e)) = (base.as_object_mut(), extra.as_object()) {
879            for (k, v) in e {
880                b.insert(k.clone(), v.clone());
881            }
882        }
883        base
884    }
885
886    // (1) from_faq resolves to a specific node ------------------------------
887
888    #[test]
889    fn from_faq_resolves_to_node_by_id() {
890        let producer = agg(json!({"id": "edge_faq", "output_idx": ["edge"],
891                                  "ranges": {"f": {"from": "faces"}}}));
892        let model = json!({
893            "index_sets": {
894                "faces": {"kind": "interval", "size": 8},
895                "edges": {"kind": "derived", "from_faq": "edge_faq"}
896            },
897            "equations": [{"lhs": producer, "rhs": 0}]
898        });
899        let g = build_reference_graph(&model, "M", None).unwrap();
900        let from_faq = g.edges_of_kind(EdgeKind::FromFaq);
901        assert_eq!(from_faq.len(), 1);
902        assert_eq!(from_faq[0].source, "index_set:edges");
903        assert_eq!(from_faq[0].target, "node:edge_faq");
904        assert_eq!(
905            g.vertices["node:edge_faq"].node_id.as_deref(),
906            Some("edge_faq")
907        );
908        assert!(
909            g.dependencies("index_set:edges")
910                .contains(&"node:edge_faq".to_string())
911        );
912    }
913
914    #[test]
915    fn from_faq_unknown_node_id_errors() {
916        let model = json!({
917            "index_sets": {"edges": {"kind": "derived", "from_faq": "missing"}},
918            "equations": [{"lhs": agg(json!({"id": "present"})), "rhs": 0}]
919        });
920        let err = build_reference_graph(&model, "M", None).unwrap_err();
921        assert!(matches!(
922            err,
923            ReferenceResolutionError::UnknownFaqNode { .. }
924        ));
925    }
926
927    #[test]
928    fn duplicate_node_id_errors() {
929        let model = json!({
930            "equations": [
931                {"lhs": agg(json!({"id": "dup"})), "rhs": 0},
932                {"lhs": agg(json!({"id": "dup"})), "rhs": 0}
933            ]
934        });
935        let err = build_reference_graph(&model, "M", None).unwrap_err();
936        assert!(matches!(
937            err,
938            ReferenceResolutionError::DuplicateNodeId { .. }
939        ));
940    }
941
942    // ranges[*].from resolves to an index set -------------------------------
943
944    #[test]
945    fn range_from_resolves_to_index_set() {
946        let node = agg(json!({"output_idx": ["i"], "ranges": {"i": {"from": "cells"}}}));
947        let model = json!({
948            "index_sets": {"cells": {"kind": "interval", "size": 4}},
949            "equations": [{"lhs": node, "rhs": 0}]
950        });
951        let g = build_reference_graph(&model, "M", None).unwrap();
952        let rf = g.edges_of_kind(EdgeKind::RangeFrom);
953        assert_eq!(rf.len(), 1);
954        assert_eq!(rf[0].target, "index_set:cells");
955        assert!(
956            g.dependencies(&rf[0].source)
957                .contains(&"index_set:cells".to_string())
958        );
959    }
960
961    #[test]
962    fn range_from_undeclared_index_set_errors() {
963        let node = agg(json!({"output_idx": ["i"], "ranges": {"i": {"from": "nope"}}}));
964        let model = json!({
965            "index_sets": {"cells": {"kind": "interval", "size": 4}},
966            "equations": [{"lhs": node, "rhs": 0}]
967        });
968        let err = build_reference_graph(&model, "M", None).unwrap_err();
969        assert!(matches!(
970            err,
971            ReferenceResolutionError::UndeclaredIndexSet { .. }
972        ));
973    }
974
975    #[test]
976    fn dense_tuple_ranges_make_no_edge() {
977        let node = agg(json!({"output_idx": ["i"], "ranges": {"i": [1, 64]}}));
978        let model = json!({"equations": [{"lhs": node, "rhs": 0}]});
979        let g = build_reference_graph(&model, "M", None).unwrap();
980        assert!(g.edges.is_empty());
981    }
982
983    // (2) a join factor resolves to its referenced factor -------------------
984
985    #[test]
986    fn join_factor_resolves_to_arg_factor() {
987        let node = agg(json!({
988            "output_idx": ["county"],
989            "ranges": {"county": {"from": "county"}, "src": {"from": "sourceType"}},
990            "join": [{"on": [["activity", "sourceType"]]}],
991            "args": ["activity", "base_rate"]
992        }));
993        let model = json!({
994            "index_sets": {
995                "county": {"kind": "categorical", "members": ["A", "B"]},
996                "sourceType": {"kind": "categorical", "members": ["x"]}
997            },
998            "equations": [{"lhs": node, "rhs": 0}]
999        });
1000        let g = build_reference_graph(&model, "M", None).unwrap();
1001        let jf = g.edges_of_kind(EdgeKind::JoinFactor);
1002        assert_eq!(jf.len(), 1);
1003        assert_eq!(jf[0].target, "factor:activity");
1004        assert_eq!(g.vertices["factor:activity"].kind, VertexKind::Factor);
1005        assert!(
1006            g.dependencies(&jf[0].source)
1007                .contains(&"factor:activity".to_string())
1008        );
1009    }
1010
1011    #[test]
1012    fn join_factor_resolves_to_range_key() {
1013        let node = agg(json!({
1014            "output_idx": ["county"],
1015            "ranges": {"county": {"from": "county"}, "src": {"from": "sourceType"}},
1016            "join": [{"on": [["src", "sourceType"]]}],
1017            "args": ["activity"]
1018        }));
1019        let model = json!({
1020            "index_sets": {
1021                "county": {"kind": "categorical", "members": ["A"]},
1022                "sourceType": {"kind": "categorical", "members": ["x"]}
1023            },
1024            "equations": [{"lhs": node, "rhs": 0}]
1025        });
1026        let g = build_reference_graph(&model, "M", None).unwrap();
1027        let jf = g.edges_of_kind(EdgeKind::JoinFactor);
1028        assert_eq!(jf.len(), 1);
1029        assert_eq!(jf[0].target, "factor:src");
1030    }
1031
1032    #[test]
1033    fn join_factor_unresolved_errors() {
1034        let node = agg(json!({
1035            "output_idx": ["i"],
1036            "ranges": {"i": {"from": "cells"}},
1037            "join": [{"on": [["ghost", "col"]]}],
1038            "args": ["activity"]
1039        }));
1040        let model = json!({
1041            "index_sets": {"cells": {"kind": "interval", "size": 2}},
1042            "equations": [{"lhs": node, "rhs": 0}]
1043        });
1044        let err = build_reference_graph(&model, "M", None).unwrap_err();
1045        assert!(matches!(
1046            err,
1047            ReferenceResolutionError::UnresolvedJoinFactor { .. }
1048        ));
1049    }
1050
1051    /// One document declaring all four binder classes: a variable (`bin`), an
1052    /// index set (`cells`), a bound range (`i`) and a string arg (`w`).
1053    fn four_class_model(on: serde_json::Value) -> Value {
1054        json!({
1055            "index_sets": {"cells": {"kind": "interval", "size": 4}},
1056            "variables": {"bin": {"type": "parameter"}, "w": {"type": "parameter"}},
1057            "equations": [{"lhs": agg(json!({
1058                "id": "j",
1059                "args": ["w"],
1060                "output_idx": [],
1061                "ranges": {"i": {"from": "cells"}},
1062                "join": [{"on": [on]}]
1063            })), "rhs": 0}]
1064        })
1065    }
1066
1067    /// A `join` `on` column resolves against ALL FOUR binder classes esm-spec
1068    /// §4.9.5 / CONFORMANCE_SPEC §5.5.6 require, on BOTH key columns. The
1069    /// variable class is the one corpus defect #3 was missing.
1070    #[test]
1071    fn join_binder_classes_all_resolve_on_both_columns() {
1072        for on in [
1073            json!(["i", "i"]),         // 1. node-local binder (ranges key)
1074            json!(["w", "w"]),         // 2. node-local string factor arg
1075            json!(["bin", "bin"]),     // 3. declared model variable — defect #3
1076            json!(["cells", "cells"]), // 4. document-scoped index set
1077        ] {
1078            let model = four_class_model(on.clone());
1079            build_reference_graph(&model, "M", None)
1080                .unwrap_or_else(|e| panic!("join on {on} should resolve: {e}"));
1081        }
1082    }
1083
1084    /// The NEGATIVE guard: widening the scope to the variable and index-set
1085    /// registries must not degrade into "accept any string". A name in none of
1086    /// the four classes is still a typo, on either column.
1087    #[test]
1088    fn join_binder_classes_still_reject_an_undefined_name() {
1089        for on in [
1090            json!(["no_such_name", "bin"]),
1091            json!(["bin", "no_such_name"]),
1092        ] {
1093            let err = build_reference_graph(&four_class_model(on.clone()), "M", None).unwrap_err();
1094            match &err {
1095                ReferenceResolutionError::UnresolvedJoinFactor { factor, .. } => {
1096                    assert_eq!(factor, "no_such_name", "for {on}");
1097                }
1098                other => panic!("want UnresolvedJoinFactor for {on}, got {other:?}"),
1099            }
1100        }
1101    }
1102
1103    // (3) edges are queryable by the partition pass -------------------------
1104
1105    #[test]
1106    fn graph_is_queryable_topologically() {
1107        let producer = agg(json!({"id": "edge_faq", "output_idx": ["edge"],
1108                                  "ranges": {"f": {"from": "faces"}}}));
1109        let consumer = agg(json!({"output_idx": ["e"], "ranges": {"e": {"from": "edges"}}}));
1110        let model = json!({
1111            "index_sets": {
1112                "faces": {"kind": "interval", "size": 8},
1113                "edges": {"kind": "derived", "from_faq": "edge_faq"}
1114            },
1115            "equations": [{"lhs": producer, "rhs": 0}, {"lhs": consumer, "rhs": 0}]
1116        });
1117        let g = build_reference_graph(&model, "M", None).unwrap();
1118        let order = g.topological_order().unwrap();
1119        assert_eq!(order.len(), g.vertices.len());
1120        let pos = |k: &str| order.iter().position(|x| x == k).unwrap();
1121        assert!(pos("node:edge_faq") < pos("index_set:edges"));
1122        assert!(g.dependencies("index_set:faces").is_empty());
1123    }
1124
1125    // (4) a reference cycle is detectable -----------------------------------
1126
1127    #[test]
1128    fn reference_cycle_is_detected() {
1129        let producer = agg(json!({"id": "edge_faq", "output_idx": ["edge"],
1130                                  "ranges": {"e": {"from": "edges"}}}));
1131        let model = json!({
1132            "index_sets": {"edges": {"kind": "derived", "from_faq": "edge_faq"}},
1133            "equations": [{"lhs": producer, "rhs": 0}]
1134        });
1135        let g = build_reference_graph(&model, "M", None).unwrap();
1136        let cyc = g.detect_cycle().expect("cycle");
1137        assert_eq!(cyc.first(), cyc.last());
1138        assert!(cyc.contains(&"node:edge_faq".to_string()));
1139        assert!(cyc.contains(&"index_set:edges".to_string()));
1140
1141        let doc = json!({"models": {"M": model}});
1142        let err = resolve_references(&doc).unwrap_err();
1143        assert!(matches!(
1144            err,
1145            ReferenceResolutionError::ReferenceCycle { .. }
1146        ));
1147    }
1148
1149    // additive: no references -> empty graph --------------------------------
1150
1151    #[test]
1152    fn no_references_empty_graph() {
1153        let model = json!({
1154            "variables": {"u": {"type": "unknown"}},
1155            "equations": [{"lhs": {"op": "D", "args": ["u"], "wrt": "t"}, "rhs": -1}]
1156        });
1157        let g = build_reference_graph(&model, "M", None).unwrap();
1158        assert!(g.edges.is_empty());
1159        assert!(g.detect_cycle().is_none());
1160    }
1161
1162    #[test]
1163    fn resolve_references_multi_model() {
1164        let m1 = json!({
1165            "index_sets": {"cells": {"kind": "interval", "size": 4}},
1166            "equations": [{"lhs": agg(json!({"output_idx": ["i"], "ranges": {"i": {"from": "cells"}}})), "rhs": 0}]
1167        });
1168        let m2 = json!({"equations": [{"lhs": {"op": "D", "args": ["u"], "wrt": "t"}, "rhs": 0}]});
1169        let doc = json!({"models": {"A": m1, "B": m2}});
1170        let graphs = resolve_references(&doc).unwrap();
1171        assert_eq!(graphs.len(), 2);
1172        assert_eq!(graphs["A"].edges_of_kind(EdgeKind::RangeFrom).len(), 1);
1173        assert!(graphs["B"].edges.is_empty());
1174    }
1175
1176    // Since v0.8.0 the index_sets registry is document-scoped (a sibling of
1177    // `models`); ranges[*].from must resolve against it even when the model
1178    // itself declares no index_sets. -------------------------------------
1179
1180    #[test]
1181    fn document_scoped_index_sets_resolve() {
1182        let node = agg(json!({"output_idx": ["i"], "ranges": {"i": {"from": "cells"}}}));
1183        let doc = json!({
1184            "index_sets": {"cells": {"kind": "interval", "size": 4}},
1185            "models": {"M": {"equations": [{"lhs": node, "rhs": 0}]}}
1186        });
1187        let graphs = resolve_references(&doc).unwrap();
1188        let g = &graphs["M"];
1189        let rf = g.edges_of_kind(EdgeKind::RangeFrom);
1190        assert_eq!(rf.len(), 1);
1191        assert_eq!(rf[0].target, "index_set:cells");
1192        // The document-scoped index set is registered as a vertex too.
1193        assert!(g.vertices.contains_key("index_set:cells"));
1194    }
1195
1196    #[test]
1197    fn document_and_model_scoped_index_sets_merge() {
1198        // A `faces` set at the document scope and a `cells` set nested in the
1199        // model (pre-0.8.0 shape) must both resolve — merge, don't replace.
1200        let node = agg(json!({
1201            "output_idx": ["i"],
1202            "ranges": {"i": {"from": "cells"}, "f": {"from": "faces"}}
1203        }));
1204        let doc = json!({
1205            "index_sets": {"faces": {"kind": "interval", "size": 8}},
1206            "models": {"M": {
1207                "index_sets": {"cells": {"kind": "interval", "size": 4}},
1208                "equations": [{"lhs": node, "rhs": 0}]
1209            }}
1210        });
1211        let graphs = resolve_references(&doc).unwrap();
1212        let g = &graphs["M"];
1213        assert_eq!(g.edges_of_kind(EdgeKind::RangeFrom).len(), 2);
1214        assert!(g.vertices.contains_key("index_set:faces"));
1215        assert!(g.vertices.contains_key("index_set:cells"));
1216    }
1217
1218    /// API_SPEC.md §8 item 17: ONE name, with the document-scoped registry as
1219    /// an optional TRAILING argument rather than a second function.
1220    #[test]
1221    fn document_registry_is_a_trailing_argument() {
1222        let node = agg(json!({"output_idx": ["i"], "ranges": {"i": {"from": "cells"}}}));
1223        let model = json!({"equations": [{"lhs": node, "rhs": 0}]});
1224        let registry: Map<String, Value> = json!({"cells": {"kind": "interval", "size": 4}})
1225            .as_object()
1226            .expect("object")
1227            .clone();
1228
1229        // Without the registry the range names an undeclared index set.
1230        let e = build_reference_graph(&model, "M", None).unwrap_err();
1231        assert!(matches!(
1232            e,
1233            ReferenceResolutionError::UndeclaredIndexSet { .. }
1234        ));
1235
1236        // With it, the range resolves against the document scope.
1237        let g = build_reference_graph(&model, "M", Some(&registry)).expect("resolves");
1238        assert!(g.vertices.contains_key("index_set:cells"));
1239        assert_eq!(g.edges_of_kind(EdgeKind::RangeFrom).len(), 1);
1240    }
1241}