Skip to main content

spectrafit_graph/
compiler.rs

1//! Graph compiler: converts a [`FitGraphSpec`] into a [`CompiledGraph`] ready
2//! for evaluation.
3//!
4//! Responsibilities:
5//!   - Instantiate model objects from `ModelTypeStr`
6//!   - Resolve per-node parameter lists and `free_mask`
7//!   - Build the ordered `free_keys` vector (node alpha-sorted, then model param order)
8//!   - Reject duplicate node IDs
9//!   - Reject duplicate or cyclic `expr_edge` targets
10
11use std::collections::HashMap;
12
13use spectrafit_models::model_from_str_with_dims;
14use spectrafit_types::{CoreError, FitGraphSpec, ModelTypeStr, ParameterSpec};
15
16use crate::error::GraphError;
17use crate::expr::TiedPlan;
18
19// Re-export Model for downstream use without needing to import spectrafit_models directly.
20pub use spectrafit_models::Model;
21
22/// Infer the dimensionality `D` of a parametric N-D kernel from a node's
23/// parameters. For `gaussian_nd`, `D` is the number of `center_<i>` parameters
24/// (`center_0`, `center_1`, …); contiguous indexing and the matching
25/// `sigma_<i>` set are enforced downstream by the missing-parameter check
26/// against `GaussianND::param_names()`. Returns `None` for fixed-dimensionality
27/// models, which carry their own `n_dims` via the `Model` trait.
28fn infer_parametric_n_dims(
29    type_str: &str,
30    params: &HashMap<String, ParameterSpec>,
31) -> Option<usize> {
32    if type_str != ModelTypeStr::GaussianNd.as_str() {
33        return None;
34    }
35    let d = params
36        .keys()
37        .filter(|k| {
38            k.strip_prefix("center_")
39                .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
40        })
41        .count();
42    Some(d)
43}
44
45// ---------------------------------------------------------------------------
46// Public data structures
47// ---------------------------------------------------------------------------
48
49/// A single compiled graph node: model instance + resolved param metadata.
50pub struct NodeEntry {
51    /// Node identifier (matches `ModelNodeSpec::id`).
52    pub id: String,
53    /// Boxed model kernel.
54    pub model: Box<dyn Model>,
55    /// Local parameter names in the order expected by the model.
56    pub param_names: Vec<String>,
57    /// `true` if the parameter is free (vary=true AND no expr).
58    pub free_mask: Vec<bool>,
59    /// Dataset scope (mirrors [`ModelNodeSpec::dataset_index`]). `None` = global
60    /// node (contributes to every dataset's points); `Some(i)` = local to dataset
61    /// `i` (contributes residuals/Jacobian only to that dataset's contiguous
62    /// point-range). Used for simultaneous multi-dataset ("global analysis") fits.
63    pub dataset_index: Option<usize>,
64}
65
66/// The compiled representation of a [`FitGraphSpec`], ready for evaluation.
67pub struct CompiledGraph {
68    /// Nodes in their original declaration order (used for evaluation).
69    pub nodes: Vec<NodeEntry>,
70    /// Keys `"node_id.param_name"` for all free parameters,
71    /// sorted by node_id alphabetically, then by model param order within a node.
72    pub free_keys: Vec<String>,
73    /// Per-node Jacobian column layout, pre-computed during [`compile`].
74    ///
75    /// `node_free_cols[i]` lists `(local_param_idx, jac_col)` for every free
76    /// parameter on `nodes[i]`.  Used by the solver to avoid string-parsing
77    /// `free_keys` on every iteration.
78    pub node_free_cols: Vec<Vec<(usize, usize)>>,
79    /// Dependency-ordered plan for tied (`expr_edge`) parameters.
80    ///
81    /// Empty when the graph declares no `expr_edges`.  When non-empty, the
82    /// solver calls [`TiedPlan::apply`] on the flat parameter map after
83    /// updating the free parameters on every iteration, so that each tied
84    /// target is recomputed from its expression before the model is evaluated.
85    ///
86    /// Applied per-iteration by `spectrafit-solver::lm_problem::set_free_and_tied`
87    /// — the single `set_params` entry shared by both the nalgebra-LM and faer
88    /// trust-region front-ends. The FD Jacobian re-applies ties per
89    /// perturbation, and the analytic Jacobian is swapped for FD when ties are
90    /// present so chain-rule terms are captured. End-to-end coverage:
91    /// `dispatch::tests::test_tied_amplitude_fit_recovers_ratio` and
92    /// `test_tied_fit_reduces_free_param_count` in the solver crate.
93    pub tied_plan: TiedPlan,
94    /// Per-dataset point boundaries for simultaneous multi-dataset ("global
95    /// analysis") fits: cumulative offsets of length `n_datasets + 1`, so
96    /// dataset `i` owns the concatenated point-range `[offsets[i], offsets[i+1])`.
97    ///
98    /// **Empty by default** (single-dataset / fully-global fits). The solver
99    /// dispatch fills it from the dataset sizes after `compile()`. The executor
100    /// only consults it when it is non-empty AND at least one node carries a
101    /// [`NodeEntry::dataset_index`]; otherwise every node contributes to all
102    /// points exactly as before (the all-global path is byte-identical).
103    pub dataset_offsets: Vec<usize>,
104}
105
106// ---------------------------------------------------------------------------
107// compile() sub-steps, extracted as named helpers so each can be read (and
108// unit-tested) independently of the outer orchestration in `compile()`.
109// ---------------------------------------------------------------------------
110
111/// Step 0: reject duplicate node IDs.
112///
113/// Node IDs key the free-column layout (`node_idx_by_id`) and the per-node
114/// component map (`evaluate_components_compiled`).  A duplicate ID silently
115/// keeps only the last index (dropping/doubling free columns) and overwrites
116/// one node's component with another — wrong fits with no error.  Reject up
117/// front instead.
118fn reject_duplicate_ids(graph: &FitGraphSpec) -> Result<(), CoreError> {
119    let mut seen_ids: std::collections::HashSet<&str> =
120        std::collections::HashSet::with_capacity(graph.nodes.len());
121    for node_spec in &graph.nodes {
122        if !seen_ids.insert(node_spec.id.as_str()) {
123            // Typed boundary error: a duplicate node id silently corrupts
124            // the per-node free-column layout and component map (see the
125            // GraphError::DuplicateNodeId docs).
126            return Err(GraphError::DuplicateNodeId(node_spec.id.clone()).into());
127        }
128    }
129    Ok(())
130}
131
132/// Step 1b: collect every tied parameter target, from both `expr_edges` and
133/// any node parameter with its own `ParameterSpec.expr` set. Both sources are
134/// harvested into `tied_plan` by `build_tied_plan`; both must also exclude the
135/// parameter from the free set in `compile_nodes`.
136fn collect_tied_targets(graph: &FitGraphSpec) -> std::collections::HashSet<(String, String)> {
137    let mut tied_targets: std::collections::HashSet<(String, String)> = graph
138        .expr_edges
139        .iter()
140        .map(|e| (e.target_node.clone(), e.target_param.clone()))
141        .collect();
142    for node_spec in &graph.nodes {
143        for (pname, spec) in &node_spec.parameters {
144            if spec.expr.is_some() {
145                tied_targets.insert((node_spec.id.clone(), pname.clone()));
146            }
147        }
148    }
149    tied_targets
150}
151
152/// Step 2: instantiate each node's model kernel and resolve its parameter
153/// metadata (required-parameter presence, free/tied mask).
154fn compile_nodes(
155    graph: &FitGraphSpec,
156    tied_targets: &std::collections::HashSet<(String, String)>,
157) -> Result<Vec<NodeEntry>, CoreError> {
158    let mut nodes: Vec<NodeEntry> = Vec::with_capacity(graph.nodes.len());
159
160    for node_spec in &graph.nodes {
161        let type_str = node_spec.model_type.as_str();
162        // Parametric N-D kernels (`gaussian_nd`) carry no fixed dimensionality:
163        // infer D from the node's indexed `center_<i>` parameters so the
164        // compiler builds a `GaussianND` of the right dimensionality. Fixed
165        // models pass `None` and ignore it. A `gaussian_nd` node with no
166        // `center_i` is degenerate (D == 0) — surface a clear missing-param
167        // error rather than silently building a 0-D (constant) model.
168        let n_dims = infer_parametric_n_dims(type_str, &node_spec.parameters);
169        if matches!(n_dims, Some(0)) {
170            return Err(GraphError::MissingParameter {
171                node: node_spec.id.clone(),
172                param: "center_0".to_string(),
173            }
174            .into());
175        }
176        let model = model_from_str_with_dims(type_str, n_dims)
177            .ok_or_else(|| GraphError::UnknownModelType(type_str.to_string()))?;
178
179        let param_names: Vec<String> = model.param_names().iter().map(|s| s.to_string()).collect();
180
181        // Ensure every model-required parameter is present in the spec.
182        for pname in &param_names {
183            if !node_spec.parameters.contains_key(pname) {
184                return Err(GraphError::MissingParameter {
185                    node: node_spec.id.clone(),
186                    param: pname.clone(),
187                }
188                .into());
189            }
190        }
191
192        let free_mask: Vec<bool> = param_names
193            .iter()
194            .map(|pname| {
195                let ps = &node_spec.parameters[pname];
196                let is_tied = tied_targets.contains(&(node_spec.id.clone(), pname.clone()));
197                ps.vary && ps.expr.is_none() && !is_tied
198            })
199            .collect();
200
201        nodes.push(NodeEntry {
202            id: node_spec.id.clone(),
203            model,
204            param_names,
205            free_mask,
206            dataset_index: node_spec.dataset_index,
207        });
208    }
209
210    Ok(nodes)
211}
212
213/// Step 3: build `free_keys`, alpha-sorted by node_id, then by model param
214/// order within a node.
215fn build_free_keys(nodes: &[NodeEntry]) -> Vec<String> {
216    let mut sorted_ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
217    sorted_ids.sort_unstable();
218
219    // Quick lookup: node_id → index in `nodes`
220    let node_idx_by_id: HashMap<&str, usize> = nodes
221        .iter()
222        .enumerate()
223        .map(|(i, n)| (n.id.as_str(), i))
224        .collect();
225
226    let mut free_keys: Vec<String> = Vec::new();
227    for nid in &sorted_ids {
228        let idx = node_idx_by_id[nid];
229        let node = &nodes[idx];
230        for (pname, &is_free) in node.param_names.iter().zip(node.free_mask.iter()) {
231            if is_free {
232                free_keys.push(format!("{}.{}", nid, pname));
233            }
234        }
235    }
236    free_keys
237}
238
239/// Step 4: pre-compute per-node Jacobian column layout.
240///
241/// Maps each free_key to (node_idx_in_nodes, local_param_pos) once at compile
242/// time.  The solver reads `node_free_cols` directly instead of re-parsing
243/// `free_keys` strings on every iteration.
244fn build_node_free_cols(nodes: &[NodeEntry], free_keys: &[String]) -> Vec<Vec<(usize, usize)>> {
245    let node_idx_by_id: HashMap<&str, usize> = nodes
246        .iter()
247        .enumerate()
248        .map(|(i, n)| (n.id.as_str(), i))
249        .collect();
250
251    let mut node_free_cols: Vec<Vec<(usize, usize)>> = vec![Vec::new(); nodes.len()];
252    for (col, key) in free_keys.iter().enumerate() {
253        // key was built as "{node_id}.{param_name}"; the first '.' is the separator.
254        // INVARIANT: every `key` in `free_keys` was produced by
255        // `format!("{}.{}", nid, pname)` in `build_free_keys`, so it always
256        // contains exactly one '.' separator — `find` is infallible here.
257        let dot = key.find('.').unwrap();
258        let node_id = &key[..dot];
259        let param_name = &key[dot + 1..];
260        // INVARIANT: `node_id` came from `nodes`, so `node_idx_by_id` is
261        // guaranteed to contain it.
262        let node_idx = node_idx_by_id[node_id];
263        // INVARIANT: `param_name` came from `node.param_names` during
264        // `build_free_keys`, so the same slice is guaranteed to contain it.
265        let local_idx = nodes[node_idx]
266            .param_names
267            .iter()
268            .position(|p| p == param_name)
269            .unwrap();
270        node_free_cols[node_idx].push((local_idx, col));
271    }
272    node_free_cols
273}
274
275// ---------------------------------------------------------------------------
276// Implementation
277// ---------------------------------------------------------------------------
278
279impl CompiledGraph {
280    /// Compile a [`FitGraphSpec`] into a [`CompiledGraph`].
281    ///
282    /// # Errors
283    /// Returns [`CoreError::Eval`] if:
284    /// - an unknown model type is encountered
285    /// - a required parameter is missing from the spec
286    /// - two nodes share the same `id` (would silently corrupt the free-column
287    ///   layout and overwrite components)
288    /// - `expr_edges` contain duplicate targets (cycle / conflict)
289    pub fn compile(graph: &FitGraphSpec) -> Result<Self, CoreError> {
290        reject_duplicate_ids(graph)?;
291
292        // Parse every `expr_edge`, topologically order the tied targets, and
293        // reject cycles / duplicate targets.  This replaces the former
294        // `ExpressionNotImplemented` reject-block: the parse + topo-order +
295        // cycle-detection structure is now real and stored on the compiled
296        // graph as `tied_plan`.
297        //
298        // The per-iteration evaluation of `tied_plan` is wired in the solver
299        // crate: `spectrafit-solver::lm_problem::set_free_and_tied` applies it on
300        // every iteration for both solver front-ends (landed in M6; see
301        // `dispatch::tests::test_tied_amplitude_fit_recovers_ratio`).
302        let tied_plan = build_tied_plan(graph)?;
303        let tied_targets = collect_tied_targets(graph);
304        let nodes = compile_nodes(graph, &tied_targets)?;
305        let free_keys = build_free_keys(&nodes);
306        let node_free_cols = build_node_free_cols(&nodes, &free_keys);
307
308        Ok(CompiledGraph {
309            nodes,
310            free_keys,
311            node_free_cols,
312            tied_plan,
313            dataset_offsets: Vec::new(),
314        })
315    }
316
317    /// Extract the parameter value vector for node at `node_idx` from the flat dict.
318    ///
319    /// Values are ordered to match `model.param_names()`.
320    ///
321    /// # Errors
322    /// Returns [`CoreError::Eval`] (via [`GraphError::MissingParamKey`]) if
323    /// `flat` has no entry for one of the node's `"node_id.param_name"` keys.
324    pub fn node_params(
325        &self,
326        node_idx: usize,
327        flat: &HashMap<String, f64>,
328    ) -> Result<Vec<f64>, CoreError> {
329        let node = &self.nodes[node_idx];
330        node.param_names
331            .iter()
332            .map(|pname| {
333                let key = format!("{}.{}", node.id, pname);
334                flat.get(&key)
335                    .copied()
336                    .ok_or_else(|| GraphError::MissingParamKey(key).into())
337            })
338            .collect()
339    }
340
341    /// The common coordinate dimensionality shared by every node in the graph.
342    ///
343    /// The executor lays the flat `x` buffer out point-major (stride =
344    /// `n_dims`), so all nodes must agree on how many coordinate components a
345    /// single point carries.  A graph that mixes a 1-D and a 2-D model over the
346    /// same coordinate grid is rejected.
347    ///
348    /// Returns `1` for an empty graph (degenerate but harmless; evaluation
349    /// produces zeros).
350    ///
351    /// # Errors
352    /// Returns [`CoreError::Eval`] if nodes declare differing `n_dims`.
353    pub fn n_dims(&self) -> Result<usize, CoreError> {
354        let mut iter = self.nodes.iter();
355        let Some(first) = iter.next() else {
356            return Ok(1);
357        };
358        let nd = first.model.n_dims();
359        for node in iter {
360            let other = node.model.n_dims();
361            if other != nd {
362                return Err(GraphError::DimensionalityMismatch {
363                    first: first.id.clone(),
364                    first_nd: nd,
365                    second: node.id.clone(),
366                    second_nd: other,
367                }
368                .into());
369            }
370        }
371        Ok(nd)
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Private helpers
377// ---------------------------------------------------------------------------
378
379/// Parse every `expr_edge` and every `Parameter.expr` into a
380/// dependency-ordered [`TiedPlan`].
381///
382/// Returns an empty plan when the graph declares neither `expr_edges` nor any
383/// per-parameter `expr` fields.  Otherwise the returned plan is topologically
384/// ordered and guaranteed cycle-free; duplicate target assignments (including
385/// a param targeted by both an `expr_edge` and its own `ParameterSpec.expr`)
386/// and full expression-language cycles (e.g. `a → b → a`) are rejected with
387/// [`CoreError::Eval`].
388///
389/// Targets are stored as the fully-qualified `"node.param"` key so they match
390/// the `free_keys` / `params_flat` convention used throughout the engine.
391fn build_tied_plan(graph: &FitGraphSpec) -> Result<TiedPlan, CoreError> {
392    // Materialise "node.param" target keys for expr_edges so the borrowed
393    // `(&str, &str)` pairs handed to `TiedPlan::build` outlive the call.
394    let edge_targets: Vec<String> = graph
395        .expr_edges
396        .iter()
397        .map(|e| format!("{}.{}", e.target_node, e.target_param))
398        .collect();
399
400    // Materialise "node.param" target keys for Parameter.expr fields.
401    // Each entry is (fully-qualified target, expression source string).
402    // Note: HashMap iteration order is nondeterministic, but that is safe —
403    // TiedPlan::build's topo_sort orders by dependency, not insertion order.
404    let param_expr_pairs: Vec<(String, String)> = graph
405        .nodes
406        .iter()
407        .flat_map(|node| {
408            node.parameters.iter().filter_map(|(pname, spec)| {
409                spec.expr
410                    .as_ref()
411                    .map(|src| (format!("{}.{}", node.id, pname), src.clone()))
412            })
413        })
414        .collect();
415
416    if edge_targets.is_empty() && param_expr_pairs.is_empty() {
417        return Ok(TiedPlan::default());
418    }
419
420    // Chain expr_edge edges followed by per-parameter expr edges.
421    let expr_edge_iter = edge_targets
422        .iter()
423        .zip(graph.expr_edges.iter())
424        .map(|(target, edge)| (target.as_str(), edge.expression.as_str()));
425
426    let param_expr_iter = param_expr_pairs
427        .iter()
428        .map(|(target, src)| (target.as_str(), src.as_str()));
429
430    TiedPlan::build(expr_edge_iter.chain(param_expr_iter))
431}
432
433// ---------------------------------------------------------------------------
434// Unit tests for compiler internals
435// ---------------------------------------------------------------------------
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use spectrafit_types::{ExprEdge, FitGraphSpec, ModelNodeSpec, ModelTypeStr, ParameterSpec};
440    use std::collections::HashMap;
441
442    fn make_param(value: f64, vary: bool) -> ParameterSpec {
443        ParameterSpec {
444            value,
445            min: f64::NEG_INFINITY,
446            max: f64::INFINITY,
447            vary,
448            expr: None,
449            scale: None,
450        }
451    }
452
453    fn gaussian_node(id: &str, amp: f64, cen: f64, sig: f64) -> ModelNodeSpec {
454        let mut params = HashMap::new();
455        params.insert("amplitude".to_string(), make_param(amp, true));
456        params.insert("center".to_string(), make_param(cen, true));
457        params.insert("sigma".to_string(), make_param(sig, true));
458        ModelNodeSpec {
459            id: id.to_string(),
460            model_type: ModelTypeStr::Gaussian,
461            dataset_index: None,
462            parameters: params,
463        }
464    }
465
466    #[test]
467    fn compile_single_node_free_keys() {
468        let graph = FitGraphSpec {
469            schema_version: "0.1".to_string(),
470            nodes: vec![gaussian_node("g1", 1.0, 0.0, 1.0)],
471            expr_edges: vec![],
472        };
473        let cg = CompiledGraph::compile(&graph).unwrap();
474        assert_eq!(cg.nodes.len(), 1);
475        // All 3 params are free → 3 free keys
476        assert_eq!(cg.free_keys.len(), 3);
477        assert_eq!(cg.free_keys[0], "g1.amplitude");
478        assert_eq!(cg.free_keys[1], "g1.center");
479        assert_eq!(cg.free_keys[2], "g1.sigma");
480    }
481
482    #[test]
483    fn compile_free_keys_sorted_by_node_id() {
484        // Two nodes: "z_node" and "a_node" — free_keys should list "a_node" first
485        let graph = FitGraphSpec {
486            schema_version: "0.1".to_string(),
487            nodes: vec![
488                gaussian_node("z_node", 1.0, 0.0, 1.0),
489                gaussian_node("a_node", 2.0, 1.0, 0.5),
490            ],
491            expr_edges: vec![],
492        };
493        let cg = CompiledGraph::compile(&graph).unwrap();
494        // 6 free params total; first 3 should belong to "a_node"
495        assert_eq!(cg.free_keys.len(), 6);
496        assert!(cg.free_keys[0].starts_with("a_node."));
497        assert!(cg.free_keys[3].starts_with("z_node."));
498    }
499
500    #[test]
501    fn compile_missing_parameter_returns_error() {
502        let mut params = HashMap::new();
503        // Intentionally omit "sigma"
504        params.insert("amplitude".to_string(), make_param(1.0, true));
505        params.insert("center".to_string(), make_param(0.0, true));
506
507        let graph = FitGraphSpec {
508            schema_version: "0.1".to_string(),
509            nodes: vec![ModelNodeSpec {
510                id: "g1".to_string(),
511                model_type: ModelTypeStr::Gaussian,
512                dataset_index: None,
513                parameters: params,
514            }],
515            expr_edges: vec![],
516        };
517        assert!(CompiledGraph::compile(&graph).is_err());
518    }
519
520    #[test]
521    fn compile_duplicate_expr_edge_target_returns_error() {
522        let graph = FitGraphSpec {
523            schema_version: "0.1".to_string(),
524            nodes: vec![gaussian_node("g1", 1.0, 0.0, 1.0)],
525            expr_edges: vec![
526                ExprEdge {
527                    target_node: "g1".to_string(),
528                    target_param: "amplitude".to_string(),
529                    expression: "2.0".to_string(),
530                },
531                ExprEdge {
532                    target_node: "g1".to_string(),
533                    target_param: "amplitude".to_string(),
534                    expression: "3.0".to_string(),
535                },
536            ],
537        };
538        assert!(CompiledGraph::compile(&graph).is_err());
539    }
540
541    /// A2 follow-up: the typed `GraphError::DuplicateNodeId` variant must be
542    /// the source of the boundary-side `CoreError::Eval`. The match arms
543    /// below pin the conversion path so a regression to a stringly-typed
544    /// constructor breaks loudly.
545    #[test]
546    fn compile_duplicate_node_id_emits_graph_error_variant() {
547        let graph = FitGraphSpec {
548            schema_version: "0.1".to_string(),
549            nodes: vec![
550                gaussian_node("dup", 1.0, 0.0, 1.0),
551                gaussian_node("dup", 2.0, 1.0, 0.5),
552            ],
553            expr_edges: vec![],
554        };
555        // `CompiledGraph` is not `Debug`, so unwrap_err() won't compile;
556        // pattern-match on the Result instead.
557        let err = match CompiledGraph::compile(&graph) {
558            Err(e) => e,
559            Ok(_) => panic!("expected duplicate-node-id error, got Ok"),
560        };
561        let core_err: CoreError = GraphError::DuplicateNodeId("dup".into()).into();
562        assert_eq!(format!("{err}"), format!("{core_err}"));
563    }
564
565    /// A2 follow-up: unknown model type → typed `GraphError::UnknownModelType`.
566    #[test]
567    fn gaussian_nd_infers_dimensionality_from_center_params() {
568        use spectrafit_types::ModelTypeStr;
569        let mut params = HashMap::new();
570        params.insert("amplitude".to_string(), make_param(1.0, true));
571        for i in 0..3 {
572            params.insert(format!("center_{i}"), make_param(0.0, true));
573            params.insert(format!("sigma_{i}"), make_param(1.0, true));
574        }
575        let graph = FitGraphSpec {
576            schema_version: "0.1".to_string(),
577            nodes: vec![ModelNodeSpec {
578                id: "gnd".to_string(),
579                model_type: ModelTypeStr::GaussianNd,
580                dataset_index: None,
581                parameters: params,
582            }],
583            expr_edges: vec![],
584        };
585        let cg = CompiledGraph::compile(&graph).expect("3-D gaussian_nd must compile");
586        assert_eq!(
587            cg.n_dims().unwrap(),
588            3,
589            "D must be inferred from center_0..center_2"
590        );
591    }
592
593    #[test]
594    fn gaussian_nd_without_center_params_errors_clearly() {
595        use spectrafit_types::ModelTypeStr;
596        let mut params = HashMap::new();
597        params.insert("amplitude".to_string(), make_param(1.0, true));
598        let graph = FitGraphSpec {
599            schema_version: "0.1".to_string(),
600            nodes: vec![ModelNodeSpec {
601                id: "gnd".to_string(),
602                model_type: ModelTypeStr::GaussianNd,
603                dataset_index: None,
604                parameters: params,
605            }],
606            expr_edges: vec![],
607        };
608        let err = match CompiledGraph::compile(&graph) {
609            Ok(_) => panic!("expected an error for gaussian_nd with no center params"),
610            Err(e) => e,
611        };
612        assert!(
613            format!("{err}").contains("center_0"),
614            "a gaussian_nd node with no center params must flag missing center_0; got: {err}"
615        );
616    }
617
618    #[test]
619    fn compile_unknown_model_type_emits_graph_error_variant() {
620        use spectrafit_types::ModelTypeStr;
621        // Hijack a valid spec then rewrite to an unknown wire token.
622        let mut node = gaussian_node("g1", 1.0, 0.0, 1.0);
623        // We can't directly construct an unknown ModelTypeStr; instead exercise
624        // the `model_from_str` boundary by deserialising raw JSON.
625        node.model_type = ModelTypeStr::Gaussian; // baseline
626        let graph = FitGraphSpec {
627            schema_version: "0.1".to_string(),
628            nodes: vec![node],
629            expr_edges: vec![],
630        };
631        // Sanity: the baseline must compile.
632        assert!(CompiledGraph::compile(&graph).is_ok());
633
634        // Directly probe the GraphError → CoreError mapping that the compile
635        // path uses internally.
636        let err: CoreError = GraphError::UnknownModelType("not-a-model".into()).into();
637        assert!(format!("{err}").contains("unknown model type"));
638    }
639
640    /// A2 follow-up: missing parameter → typed `GraphError::MissingParameter`.
641    #[test]
642    fn compile_missing_parameter_emits_graph_error_variant() {
643        // Build a Gaussian node spec but drop the required `sigma` parameter.
644        let mut params = HashMap::new();
645        params.insert("amplitude".to_string(), make_param(1.0, true));
646        params.insert("center".to_string(), make_param(0.0, true));
647        let graph = FitGraphSpec {
648            schema_version: "0.1".to_string(),
649            nodes: vec![ModelNodeSpec {
650                id: "g1".to_string(),
651                model_type: ModelTypeStr::Gaussian,
652                dataset_index: None,
653                parameters: params,
654            }],
655            expr_edges: vec![],
656        };
657        let err = match CompiledGraph::compile(&graph) {
658            Err(e) => e,
659            Ok(_) => panic!("expected missing-parameter error, got Ok"),
660        };
661        let core_err: CoreError = GraphError::MissingParameter {
662            node: "g1".into(),
663            param: "sigma".into(),
664        }
665        .into();
666        assert_eq!(format!("{err}"), format!("{core_err}"));
667    }
668
669    /// G2 regression: two nodes sharing an `id` must be rejected at compile
670    /// time (otherwise the free-column layout drops/doubles columns and one
671    /// node's component silently overwrites the other).
672    #[test]
673    fn compile_duplicate_node_id_returns_error() {
674        let graph = FitGraphSpec {
675            schema_version: "0.1".to_string(),
676            nodes: vec![
677                gaussian_node("dup", 1.0, 0.0, 1.0),
678                gaussian_node("dup", 2.0, 1.0, 0.5),
679            ],
680            expr_edges: vec![],
681        };
682        let result = CompiledGraph::compile(&graph);
683        let Err(err) = result else {
684            panic!("expected duplicate-node-id compile error, got Ok");
685        };
686        let msg = format!("{err}");
687        assert!(
688            msg.contains("duplicate node id") && msg.contains("dup"),
689            "expected a duplicate-node-id error, got: {msg}"
690        );
691    }
692
693    #[test]
694    fn node_params_extracts_correct_order() {
695        let graph = FitGraphSpec {
696            schema_version: "0.1".to_string(),
697            nodes: vec![gaussian_node("g1", 3.0, 5.0, 0.5)],
698            expr_edges: vec![],
699        };
700        let cg = CompiledGraph::compile(&graph).unwrap();
701        let flat: HashMap<String, f64> =
702            [("g1.amplitude", 3.0), ("g1.center", 5.0), ("g1.sigma", 0.5)]
703                .iter()
704                .map(|(k, v)| (k.to_string(), *v))
705                .collect();
706        let params = cg.node_params(0, &flat).unwrap();
707        assert_eq!(params, vec![3.0, 5.0, 0.5]);
708    }
709
710    /// Build a Gaussian node whose `amplitude` is tied (`vary=false`, `expr`).
711    ///
712    /// Used by tests that exercise `Parameter.expr` directly (without a
713    /// corresponding `expr_edge`).  Do not pair this with an `expr_edge` on
714    /// the same target — that would be a duplicate-target conflict.
715    fn gaussian_node_tied_amplitude(id: &str, cen: f64, sig: f64, expr: &str) -> ModelNodeSpec {
716        let mut params = HashMap::new();
717        let mut amp = make_param(0.0, false);
718        amp.expr = Some(expr.to_string());
719        params.insert("amplitude".to_string(), amp);
720        params.insert("center".to_string(), make_param(cen, true));
721        params.insert("sigma".to_string(), make_param(sig, true));
722        ModelNodeSpec {
723            id: id.to_string(),
724            model_type: ModelTypeStr::Gaussian,
725            dataset_index: None,
726            parameters: params,
727        }
728    }
729
730    /// A valid `expr_edge` now compiles (no longer `ExpressionNotImplemented`)
731    /// and produces a dependency-ordered, non-empty `tied_plan`.
732    /// g2 is a plain Gaussian node (no `Parameter.expr`); the tie comes
733    /// exclusively from the `expr_edge`, which is the graph-level API.
734    #[test]
735    fn compile_builds_tied_plan_from_expr_edge() {
736        let graph = FitGraphSpec {
737            schema_version: "0.1".to_string(),
738            nodes: vec![
739                gaussian_node("g1", 1.0, 0.0, 1.0),
740                gaussian_node("g2", 2.0, 1.0, 1.0), // plain node — no Parameter.expr
741            ],
742            expr_edges: vec![ExprEdge {
743                target_node: "g2".to_string(),
744                target_param: "amplitude".to_string(),
745                expression: "0.5 * g1.amplitude".to_string(),
746            }],
747        };
748        let cg = CompiledGraph::compile(&graph).unwrap();
749        assert_eq!(cg.tied_plan.len(), 1, "one tied parameter expected");
750        assert_eq!(cg.tied_plan.order[0].target, "g2.amplitude");
751    }
752
753    /// The tied parameter (`g2.amplitude`, tied via `expr_edge`) is excluded from
754    /// `free_keys`, so the free-parameter count drops by exactly one versus the
755    /// fully-free two-Gaussian graph (6 → 5).
756    /// g2 is a plain Gaussian node (no `Parameter.expr`) — the tie comes from
757    /// the `expr_edge` alone, avoiding a double-specification conflict.
758    #[test]
759    fn compile_tied_param_excluded_from_free_keys() {
760        let graph = FitGraphSpec {
761            schema_version: "0.1".to_string(),
762            nodes: vec![
763                gaussian_node("g1", 1.0, 0.0, 1.0),
764                gaussian_node("g2", 2.0, 1.0, 1.0), // plain node — no Parameter.expr
765            ],
766            expr_edges: vec![ExprEdge {
767                target_node: "g2".to_string(),
768                target_param: "amplitude".to_string(),
769                expression: "0.5 * g1.amplitude".to_string(),
770            }],
771        };
772        let cg = CompiledGraph::compile(&graph).unwrap();
773        // 6 params total, 1 tied → 5 free.
774        assert_eq!(cg.free_keys.len(), 5);
775        assert!(
776            !cg.free_keys.iter().any(|k| k == "g2.amplitude"),
777            "tied g2.amplitude must not appear in free_keys"
778        );
779    }
780
781    /// A cyclic pair of `expr_edges` (`a → b → a`) is rejected at compile time.
782    /// Plain Gaussian nodes are used here (no `Parameter.expr`) so the tie comes
783    /// exclusively from the `expr_edge` pairs — testing the cycle-detection path
784    /// without triggering the duplicate-target guard.
785    #[test]
786    fn compile_rejects_expr_cycle_a_b_a() {
787        let graph = FitGraphSpec {
788            schema_version: "0.1".to_string(),
789            nodes: vec![
790                gaussian_node("a", 0.0, 1.0, 1.0), // plain — no Parameter.expr
791                gaussian_node("b", 1.0, 1.0, 1.0), // plain — no Parameter.expr
792            ],
793            expr_edges: vec![
794                ExprEdge {
795                    target_node: "a".to_string(),
796                    target_param: "amplitude".to_string(),
797                    expression: "b.amplitude + 1.0".to_string(),
798                },
799                ExprEdge {
800                    target_node: "b".to_string(),
801                    target_param: "amplitude".to_string(),
802                    expression: "a.amplitude * 2.0".to_string(),
803                },
804            ],
805        };
806        assert!(
807            CompiledGraph::compile(&graph).is_err(),
808            "a→b→a expr cycle must be rejected"
809        );
810    }
811
812    /// `Parameter.expr` (no expr_edge) is harvested into the tied plan.
813    ///
814    /// Node `p0` is a fully-free Gaussian.  Node `p1` has `sigma.expr = "p0.sigma"` —
815    /// NO corresponding `expr_edge`.  The compiler must:
816    /// 1. succeed (no error),
817    /// 2. exclude `p1.sigma` from `free_keys`,
818    /// 3. populate `tied_plan` with at least one entry, and
819    /// 4. have `tied_plan.apply` resolve `p1.sigma` from a values map that contains
820    ///    `p0.sigma = 2.0`.
821    #[test]
822    fn compile_harvests_parameter_expr_into_tied_plan() {
823        // Build p1 with sigma carrying expr="p0.sigma", no expr_edge.
824        let mut p1_params = HashMap::new();
825        p1_params.insert("amplitude".to_string(), make_param(1.0, true));
826        p1_params.insert("center".to_string(), make_param(0.0, true));
827        let mut sigma_tied = make_param(1.0, false);
828        sigma_tied.expr = Some("p0.sigma".to_string());
829        p1_params.insert("sigma".to_string(), sigma_tied);
830        let p1 = ModelNodeSpec {
831            id: "p1".to_string(),
832            model_type: ModelTypeStr::Gaussian,
833            dataset_index: None,
834            parameters: p1_params,
835        };
836
837        let graph = FitGraphSpec {
838            schema_version: "0.1".to_string(),
839            nodes: vec![gaussian_node("p0", 1.0, 0.0, 1.0), p1],
840            expr_edges: vec![], // intentionally empty — no expr_edge for p1.sigma
841        };
842
843        // 1. compile succeeds
844        let cg = CompiledGraph::compile(&graph).expect("compile must succeed");
845
846        // 2. p1.sigma is NOT in free_keys (it is tied via Parameter.expr)
847        assert!(
848            !cg.free_keys.iter().any(|k| k == "p1.sigma"),
849            "p1.sigma must be excluded from free_keys when Parameter.expr is set"
850        );
851
852        // 3. tied_plan is non-empty
853        assert!(
854            !cg.tied_plan.is_empty(),
855            "tied_plan must contain at least the p1.sigma entry"
856        );
857
858        // 4. applying the plan with p0.sigma=2.0 sets p1.sigma=2.0
859        let mut values: HashMap<String, f64> = HashMap::new();
860        values.insert("p0.amplitude".to_string(), 1.0);
861        values.insert("p0.center".to_string(), 0.0);
862        values.insert("p0.sigma".to_string(), 2.0);
863        cg.tied_plan.apply(&mut values).expect("apply must succeed");
864        assert_eq!(
865            values["p1.sigma"], 2.0,
866            "p1.sigma must be resolved to p0.sigma=2.0 by the tied plan"
867        );
868    }
869
870    /// I1: When `g2.amplitude` carries both a `Parameter.expr` AND an `expr_edge`
871    /// targeting the same parameter, `compile` must return the
872    /// `DuplicateExprTarget` error — the conflict rule mandated by T1.
873    #[test]
874    fn compile_rejects_when_expr_edge_and_param_expr_target_same_param() {
875        // g2 node: amplitude is tied via Parameter.expr …
876        let g2 = gaussian_node_tied_amplitude("g2", 1.0, 1.0, "0.5 * g1.amplitude");
877
878        let graph = FitGraphSpec {
879            schema_version: "0.1".to_string(),
880            nodes: vec![gaussian_node("g1", 1.0, 0.0, 1.0), g2],
881            // … AND an expr_edge also targets g2.amplitude — duplicate!
882            expr_edges: vec![ExprEdge {
883                target_node: "g2".to_string(),
884                target_param: "amplitude".to_string(),
885                expression: "0.5 * g1.amplitude".to_string(),
886            }],
887        };
888
889        let err = match CompiledGraph::compile(&graph) {
890            Err(e) => e,
891            Ok(_) => panic!("expected DuplicateExprTarget error, got Ok"),
892        };
893        // The error is a CoreError::Eval wrapping GraphError::DuplicateExprTarget.
894        let expected: CoreError = GraphError::DuplicateExprTarget("g2.amplitude".into()).into();
895        assert_eq!(
896            format!("{err}"),
897            format!("{expected}"),
898            "expected DuplicateExprTarget for g2.amplitude, got: {err}"
899        );
900    }
901
902    /// I2: A cycle formed purely via `Parameter.expr` (no `expr_edges`) is
903    /// detected and rejected at compile time.  Node `a` has
904    /// `amplitude.expr = "b.amplitude + 1.0"` and node `b` has
905    /// `amplitude.expr = "a.amplitude * 2.0"` — a direct a→b→a cycle.
906    #[test]
907    fn compile_rejects_param_expr_cycle_a_b_a() {
908        // Build node `a` using the tied-amplitude helper.
909        let node_a = gaussian_node_tied_amplitude("a", 1.0, 1.0, "b.amplitude + 1.0");
910        // Build node `b` using the tied-amplitude helper.
911        let node_b = gaussian_node_tied_amplitude("b", 1.0, 1.0, "a.amplitude * 2.0");
912
913        let graph = FitGraphSpec {
914            schema_version: "0.1".to_string(),
915            nodes: vec![node_a, node_b],
916            expr_edges: vec![], // cycle comes from Parameter.expr alone
917        };
918
919        // Confirm the error message identifies a cycle — match on Result to
920        // avoid unwrap_err() which requires CompiledGraph: Debug.
921        let err = match CompiledGraph::compile(&graph) {
922            Err(e) => e,
923            Ok(_) => panic!("a→b→a param-expr cycle must be rejected at compile time"),
924        };
925        let msg = format!("{err}");
926        assert!(
927            msg.contains("cycle"),
928            "expected a cycle error message, got: {msg}"
929        );
930    }
931}