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 ¶m_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}