Skip to main content

spectrafit_graph/
executor.rs

1//! Graph executor: evaluation and Jacobian computation against a
2//! [`CompiledGraph`].
3//!
4//! All public functions are generic over a pre-compiled graph so the caller
5//! can amortise compilation cost (e.g. in the solver loop).
6
7use std::collections::HashMap;
8
9use nalgebra::DMatrix;
10use rayon::prelude::*;
11use spectrafit_types::CoreError;
12
13use crate::compiler::CompiledGraph;
14use crate::error::GraphError;
15
16// Rayon dispatch thresholds.
17//
18// Rayon's thread-pool has ~30–50 µs wake-up latency.  Parallel dispatch only
19// pays off when:
20//  (a) there are enough points that each thread gets a meaningful chunk, and
21//  (b) total arithmetic work exceeds the dispatch overhead.
22//
23// Previous thresholds (192 pts/thread, 120k total work) were too aggressive:
24// they triggered parallelism for single-peak 1-D fits with ~300 points where
25// the overhead dominated.  The new values require a minimum of 512 points per
26// thread and at least 1.5M arithmetic operations before going parallel, which
27// reserves Rayon for genuinely large fits (n > ~2k on 4-thread machines).
28const POINTS_PER_THREAD_CUTOFF: usize = 512;
29const MIN_TOTAL_WORK_CUTOFF: usize = 1_500_000;
30
31/// Decide whether a point-wise kernel should use rayon.
32///
33/// Returns `true` only when both conditions hold:
34/// - `n_points >= 512 * n_threads` — each worker gets a large enough chunk
35/// - `n_points * work_per_point >= 1_500_000` — dispatch overhead is amortised
36#[inline]
37fn should_parallel(n_points: usize, work_per_point: usize) -> bool {
38    let n_threads = rayon::current_num_threads();
39    if n_threads <= 1 {
40        return false;
41    }
42
43    let point_cutoff = POINTS_PER_THREAD_CUTOFF.saturating_mul(n_threads);
44    let work_cutoff = MIN_TOTAL_WORK_CUTOFF;
45    let total_work = n_points.saturating_mul(work_per_point.max(1));
46
47    n_points >= point_cutoff && total_work >= work_cutoff
48}
49
50// ---------------------------------------------------------------------------
51// evaluate — sum of all node contributions
52// ---------------------------------------------------------------------------
53
54/// The flat coordinate buffer `x` is laid out **point-major**: dimension
55/// `stride = cg.n_dims()` components per point, so point `i` occupies
56/// `x[i*stride .. (i+1)*stride]`.  For the common 1-D case `stride == 1` and
57/// each point is a single `f64`, identical to the historical behavior.
58///
59/// Validates that `x.len()` is an exact multiple of `stride` and returns the
60/// number of points.
61#[inline]
62fn coord_layout(cg: &CompiledGraph, x_len: usize) -> Result<(usize, usize), CoreError> {
63    let stride = cg.n_dims()?;
64    debug_assert!(stride >= 1);
65    if !x_len.is_multiple_of(stride) {
66        return Err(GraphError::XBufferStrideMismatch {
67            x_len,
68            n_dims: stride,
69        }
70        .into());
71    }
72    Ok((stride, x_len / stride))
73}
74
75/// Evaluate the model sum at each x-point.
76///
77/// For each point, sums `model.eval(coord, &params)` across all nodes, where
78/// `coord` is the `n_dims`-length coordinate slice for that point.  For 1-D
79/// models `coord` is a single-element slice (`x[i..i+1]`); for n-D models it is
80/// the full strided coordinate `x[i*d..(i+1)*d]`.
81///
82/// # Errors
83/// Returns [`CoreError::Eval`] if: `flat` is missing a required
84/// `"node_id.param_name"` entry for some node ([`GraphError::MissingParamKey`]);
85/// `x.len()` is not an exact multiple of the graph's coordinate
86/// dimensionality ([`GraphError::XBufferStrideMismatch`]); the graph's nodes
87/// disagree on `n_dims` ([`GraphError::DimensionalityMismatch`]); or, when
88/// multi-dataset scoping is active, a node's `dataset_index` is out of range
89/// ([`GraphError::DatasetIndexOutOfRange`]).
90pub fn evaluate_compiled(
91    cg: &CompiledGraph,
92    flat: &HashMap<String, f64>,
93    x: &[f64],
94) -> Result<Vec<f64>, CoreError> {
95    // Pre-extract param vectors for every node once, outside the x-loop.
96    let node_params: Vec<Vec<f64>> = (0..cg.nodes.len())
97        .map(|i| cg.node_params(i, flat))
98        .collect::<Result<_, _>>()?;
99
100    let (stride, n_points) = coord_layout(cg, x.len())?;
101
102    // Simultaneous multi-dataset scoping (cold path): a node with `dataset_index`
103    // contributes only to its dataset's points, so `best_fit` is the correctly
104    // scoped sum. The all-global path below is untouched.
105    if scoping_active(cg) {
106        validate_dataset_scope(cg)?;
107        let offsets = cg.dataset_offsets.as_slice();
108        let values: Vec<f64> = x
109            .chunks_exact(stride)
110            .enumerate()
111            .map(|(p, coord)| {
112                let ds = dataset_of_point(offsets, p);
113                let mut sum = 0.0_f64;
114                for (node, params) in cg.nodes.iter().zip(node_params.iter()) {
115                    if let Some(i) = node.dataset_index {
116                        if i != ds {
117                            continue;
118                        }
119                    }
120                    sum += node.model.eval(coord, params);
121                }
122                sum
123            })
124            .collect();
125        return Ok(values);
126    }
127
128    // Evaluate over x-points; each point is independent.
129    // Auto-switch between sequential and rayon based on thread count and
130    // estimated arithmetic work.
131    let eval_point = |coord: &[f64]| -> f64 {
132        let mut sum = 0.0_f64;
133        for (node, params) in cg.nodes.iter().zip(node_params.iter()) {
134            sum += node.model.eval(coord, params);
135        }
136        sum
137    };
138
139    let values: Vec<f64> = if should_parallel(n_points, cg.nodes.len().saturating_mul(stride)) {
140        x.par_chunks_exact(stride).map(eval_point).collect()
141    } else {
142        x.chunks_exact(stride).map(eval_point).collect()
143    };
144    Ok(values)
145}
146
147// ---------------------------------------------------------------------------
148// evaluate_components — per-node contributions
149// ---------------------------------------------------------------------------
150
151/// Evaluate each node independently.
152///
153/// Returns `{ node_id => Vec<f64> }` with one entry per node.
154///
155/// # Errors
156/// Returns [`CoreError::Eval`] if: `x.len()` is not an exact multiple of the
157/// graph's coordinate dimensionality ([`GraphError::XBufferStrideMismatch`]);
158/// the graph's nodes disagree on `n_dims`
159/// ([`GraphError::DimensionalityMismatch`]); a node's `dataset_index` is out
160/// of range for the recorded `dataset_offsets`
161/// ([`GraphError::DatasetIndexOutOfRange`]); or `flat` is missing a required
162/// `"node_id.param_name"` entry ([`GraphError::MissingParamKey`]).
163pub fn evaluate_components_compiled(
164    cg: &CompiledGraph,
165    flat: &HashMap<String, f64>,
166    x: &[f64],
167) -> Result<HashMap<String, Vec<f64>>, CoreError> {
168    let mut result: HashMap<String, Vec<f64>> = HashMap::with_capacity(cg.nodes.len());
169
170    let (stride, n_points) = coord_layout(cg, x.len())?;
171    // Reject out-of-range `dataset_index` once, before the per-node loop indexes
172    // `dataset_offsets[di + 1]`, so a mismatched spec/offsets errors cleanly.
173    validate_dataset_scope(cg)?;
174
175    for (i, node) in cg.nodes.iter().enumerate() {
176        let params = cg.node_params(i, flat)?;
177
178        let eval_pt = |coord: &[f64]| node.model.eval(coord, &params);
179
180        let mut values: Vec<f64> = if should_parallel(n_points, stride) {
181            x.par_chunks_exact(stride).map(eval_pt).collect()
182        } else {
183            x.chunks_exact(stride).map(eval_pt).collect()
184        };
185
186        // Dataset scoping: a local node's component curve is zero outside its
187        // dataset's point-range (keeps per-dataset slices/components correct).
188        if scoping_active(cg) {
189            if let Some(di) = node.dataset_index {
190                let offs = &cg.dataset_offsets;
191                let (a, b) = (offs[di], offs[di + 1]);
192                for (p, v) in values.iter_mut().enumerate() {
193                    if p < a || p >= b {
194                        *v = 0.0;
195                    }
196                }
197            }
198        }
199
200        result.insert(node.id.clone(), values);
201    }
202
203    Ok(result)
204}
205
206// ---------------------------------------------------------------------------
207// evaluate_compiled_indexed — hot path: bypasses HashMap lookup
208// ---------------------------------------------------------------------------
209
210/// Evaluate the model sum using pre-computed per-node parameter buffers.
211///
212/// This is the hot-path version of [`evaluate_compiled`] used by the solver.
213/// The caller maintains `node_params[i]` (one `Vec<f64>` per node, in model
214/// `param_names()` order) and updates only the free-param slots on each
215/// iteration, using a free-param-index → `(node_idx, param_pos)` mapping it
216/// builds and owns itself (`spectrafit-solver`'s `LmProblem::free_to_node_param`).
217///
218/// Eliminates all `HashMap` lookups from the inner loop.
219///
220/// # Errors
221/// Returns [`CoreError::Eval`] if `x.len()` is not an exact multiple of the
222/// graph's coordinate dimensionality ([`GraphError::XBufferStrideMismatch`])
223/// or the graph's nodes disagree on `n_dims`
224/// ([`GraphError::DimensionalityMismatch`]).
225pub fn evaluate_compiled_indexed(
226    cg: &CompiledGraph,
227    node_params: &[Vec<f64>],
228    x: &[f64],
229) -> Result<Vec<f64>, CoreError> {
230    // Size the output by the number of points (x.len() / n_dims), not the raw
231    // x length, so the n-D strided path allocates a correctly-shaped buffer.
232    let (_stride, n_points) = coord_layout(cg, x.len())?;
233    let mut values = vec![0.0_f64; n_points];
234    evaluate_compiled_indexed_into(cg, node_params, x, &mut values)?;
235    Ok(values)
236}
237
238/// Evaluate the model sum using pre-computed per-node parameter buffers into a
239/// reusable output slice.
240///
241/// Callers that already own a scratch buffer can reuse it across iterations and
242/// avoid an extra heap allocation for the predicted values.
243///
244/// # Errors
245/// Returns [`CoreError::Eval`] if: `x.len()` is not an exact multiple of the
246/// graph's coordinate dimensionality ([`GraphError::XBufferStrideMismatch`]);
247/// the graph's nodes disagree on `n_dims`
248/// ([`GraphError::DimensionalityMismatch`]); or `out.len()` does not match the
249/// computed point count ([`GraphError::OutputBufferLength`]).
250pub fn evaluate_compiled_indexed_into(
251    cg: &CompiledGraph,
252    node_params: &[Vec<f64>],
253    x: &[f64],
254    out: &mut [f64],
255) -> Result<(), CoreError> {
256    let (stride, n_points) = coord_layout(cg, x.len())?;
257
258    if out.len() != n_points {
259        return Err(GraphError::OutputBufferLength {
260            actual: out.len(),
261            expected: n_points,
262        }
263        .into());
264    }
265
266    let eval_point = |coord: &[f64]| {
267        let mut sum = 0.0_f64;
268        for (node, params) in cg.nodes.iter().zip(node_params.iter()) {
269            sum += node.model.eval(coord, params);
270        }
271        sum
272    };
273
274    if should_parallel(n_points, cg.nodes.len().saturating_mul(stride)) {
275        out.par_iter_mut()
276            .zip(x.par_chunks_exact(stride))
277            .for_each(|(slot, coord)| *slot = eval_point(coord));
278    } else {
279        for (slot, coord) in out.iter_mut().zip(x.chunks_exact(stride)) {
280            *slot = eval_point(coord);
281        }
282    }
283
284    Ok(())
285}
286
287/// Compute weighted residuals directly into a reusable buffer.
288///
289/// This fuses prediction, subtraction, and sigma weighting so solver loops do
290/// not materialize an intermediate prediction vector.
291/// Whether simultaneous multi-dataset ("global analysis") scoping is active:
292/// ≥2 datasets recorded on the compiled graph AND at least one node carrying a
293/// [`crate::compiler::NodeEntry::dataset_index`]. When false, every node
294/// contributes to all points — the historical, byte-identical behaviour.
295#[inline]
296fn scoping_active(cg: &CompiledGraph) -> bool {
297    cg.dataset_offsets.len() > 2 && cg.nodes.iter().any(|n| n.dataset_index.is_some())
298}
299
300/// Validate that every node's `dataset_index` is in range for the recorded
301/// `dataset_offsets`.
302///
303/// `dataset_offsets` has length `n_datasets + 1`, so dataset `i` is valid iff
304/// `i < dataset_offsets.len() - 1` (i.e. `i + 1 < dataset_offsets.len()`).
305/// Because the solver fills `dataset_offsets` *after* `compile()`, the node
306/// spec and the offsets can disagree with no guard between them; indexing
307/// `offsets[di + 1]` blindly would panic. Returns [`CoreError::Eval`] instead
308/// so the scoped eval/Jacobian paths fail cleanly rather than aborting.
309///
310/// A no-op (always `Ok`) when scoping is inactive.
311#[inline]
312fn validate_dataset_scope(cg: &CompiledGraph) -> Result<(), CoreError> {
313    if !scoping_active(cg) {
314        return Ok(());
315    }
316    let n_datasets = cg.dataset_offsets.len() - 1;
317    for node in &cg.nodes {
318        if let Some(di) = node.dataset_index {
319            if di >= n_datasets {
320                return Err(GraphError::DatasetIndexOutOfRange {
321                    node: node.id.clone(),
322                    dataset_index: di,
323                    n_datasets,
324                }
325                .into());
326            }
327        }
328    }
329    Ok(())
330}
331
332/// Dataset index owning concatenated point `p`, from cumulative `offsets`
333/// (len = n_datasets + 1, ascending). `partition_point` is O(log n_datasets).
334#[inline]
335fn dataset_of_point(offsets: &[usize], p: usize) -> usize {
336    offsets.partition_point(|&o| o <= p).saturating_sub(1)
337}
338
339/// Post-pass for the analytical Jacobian: zero the free-parameter columns of
340/// every local (`dataset_index`-scoped) node for rows outside that node's
341/// dataset point-range. The normal fill computes each node's Jacobian at all
342/// rows; this restricts a local node's columns to its own dataset (a no-op when
343/// scoping is inactive), keeping the hot fill loops untouched.
344fn apply_jacobian_dataset_scope(
345    cg: &CompiledGraph,
346    n_points: usize,
347    n_free: usize,
348    data: &mut [f64],
349) {
350    if !scoping_active(cg) {
351        return;
352    }
353    let offsets = &cg.dataset_offsets;
354    for (node_idx, node) in cg.nodes.iter().enumerate() {
355        let Some(di) = node.dataset_index else {
356            continue;
357        };
358        let (a, b) = (offsets[di], offsets[di + 1]);
359        for &(_local, col) in &cg.node_free_cols[node_idx] {
360            for row in 0..n_points {
361                if row < a || row >= b {
362                    data[row * n_free + col] = 0.0;
363                }
364            }
365        }
366    }
367}
368
369/// Compute weighted residuals `r[i] = (ŷ[i] − y[i]) / σ[i]` into `out` using
370/// pre-computed per-node parameter buffers. Honours per-node `dataset_index`
371/// scoping for simultaneous multi-dataset fits (a no-op for single-dataset /
372/// fully-global graphs).
373///
374/// # Errors
375/// Returns [`CoreError::Eval`] if: `x.len()` is not an exact multiple of the
376/// graph's coordinate dimensionality ([`GraphError::XBufferStrideMismatch`]);
377/// the graph's nodes disagree on `n_dims` ([`GraphError::DimensionalityMismatch`]);
378/// `out`, `y`, or `sigma` does not have exactly one entry per point
379/// ([`GraphError::OutputBufferShape`]); or, when multi-dataset scoping is
380/// active, a node's `dataset_index` is out of range
381/// ([`GraphError::DatasetIndexOutOfRange`]).
382pub fn residuals_compiled_indexed_into(
383    cg: &CompiledGraph,
384    node_params: &[Vec<f64>],
385    x: &[f64],
386    y: &[f64],
387    sigma: &[f64],
388    out: &mut [f64],
389) -> Result<(), CoreError> {
390    let (stride, n_points) = coord_layout(cg, x.len())?;
391
392    if out.len() != n_points || y.len() != n_points || sigma.len() != n_points {
393        return Err(GraphError::OutputBufferShape.into());
394    }
395
396    // Simultaneous multi-dataset scoping (cold path — only with ≥2 datasets AND
397    // a local node): a node with `dataset_index = Some(i)` contributes only to
398    // dataset i's points. Uses the scalar `eval` for any stride; the optimized
399    // all-global paths below are left untouched.
400    if scoping_active(cg) {
401        validate_dataset_scope(cg)?;
402        let offsets = cg.dataset_offsets.as_slice();
403        for (p, (slot, (coord, (&obs, &s)))) in out
404            .iter_mut()
405            .zip(x.chunks_exact(stride).zip(y.iter().zip(sigma.iter())))
406            .enumerate()
407        {
408            let ds = dataset_of_point(offsets, p);
409            let mut sum = 0.0_f64;
410            for (node, params) in cg.nodes.iter().zip(node_params.iter()) {
411                if let Some(i) = node.dataset_index {
412                    if i != ds {
413                        continue;
414                    }
415                }
416                sum += node.model.eval(coord, params);
417            }
418            *slot = (sum - obs) / s;
419        }
420        return Ok(());
421    }
422
423    // 1-D batched path (any number of nodes): batch-evaluate each node with
424    // `eval_slice_into`, accumulate the node sum, then apply (out - obs) / sigma.
425    // `stride == 1` implies every node is 1-D (stride = cg.n_dims()), so this
426    // replaces the per-(point, node) virtual `eval` dispatch with one batched
427    // call per node — hoisting each model's loop-invariant constants. The scalar
428    // path below is kept only for n-D models (stride > 1).
429    if stride == 1 && !cg.nodes.is_empty() {
430        debug_assert_eq!(out.len(), x.len());
431        // First node overwrites `out`; remaining nodes accumulate via scratch.
432        cg.nodes[0].model.eval_slice_into(x, &node_params[0], out);
433        if cg.nodes.len() > 1 {
434            let mut scratch = vec![0.0_f64; n_points];
435            for (node, params) in cg.nodes.iter().zip(node_params.iter()).skip(1) {
436                node.model.eval_slice_into(x, params, &mut scratch);
437                for (slot, &c) in out.iter_mut().zip(scratch.iter()) {
438                    *slot += c;
439                }
440            }
441        }
442        for ((slot, &obs), &s) in out.iter_mut().zip(y.iter()).zip(sigma.iter()) {
443            *slot = (*slot - obs) / s;
444        }
445        return Ok(());
446    }
447
448    let eval_point = |coord: &[f64]| {
449        let mut sum = 0.0_f64;
450        for (node, params) in cg.nodes.iter().zip(node_params.iter()) {
451            sum += node.model.eval(coord, params);
452        }
453        sum
454    };
455
456    if should_parallel(n_points, cg.nodes.len().saturating_mul(stride)) {
457        out.par_iter_mut()
458            .zip(
459                x.par_chunks_exact(stride)
460                    .zip(y.par_iter().zip(sigma.par_iter())),
461            )
462            .for_each(|(slot, (coord, (&obs, &s)))| *slot = (eval_point(coord) - obs) / s);
463    } else {
464        for (slot, (coord, (&obs, &s))) in out
465            .iter_mut()
466            .zip(x.chunks_exact(stride).zip(y.iter().zip(sigma.iter())))
467        {
468            *slot = (eval_point(coord) - obs) / s;
469        }
470    }
471
472    Ok(())
473}
474
475// ---------------------------------------------------------------------------
476// jacobian — full analytical Jacobian [n_points × n_free_params]
477// ---------------------------------------------------------------------------
478
479/// Compute the analytical Jacobian matrix using a pre-built flat param map.
480///
481/// Layout: row `i` = x-point `i`; column `j` = free parameter `j`
482/// (ordered as in `cg.free_keys`).
483///
484/// Only parameters with `free_mask == true` contribute a column.
485/// Fixed and expression-bound parameters are excluded.
486///
487/// Uses `cg.node_free_cols` (pre-computed at compile time) to avoid
488/// string-parsing `free_keys` on every call.
489///
490/// # Errors
491/// Returns [`CoreError::Eval`] if: `flat` is missing a required
492/// `"node_id.param_name"` entry for some node ([`GraphError::MissingParamKey`]);
493/// `x.len()` is not an exact multiple of the graph's coordinate
494/// dimensionality ([`GraphError::XBufferStrideMismatch`]); the graph's nodes
495/// disagree on `n_dims` ([`GraphError::DimensionalityMismatch`]); or, when
496/// multi-dataset scoping is active, a node's `dataset_index` is out of range
497/// ([`GraphError::DatasetIndexOutOfRange`]).
498pub fn jacobian_compiled(
499    cg: &CompiledGraph,
500    flat: &HashMap<String, f64>,
501    x: &[f64],
502) -> Result<DMatrix<f64>, CoreError> {
503    // Pre-extract param vectors once.
504    let node_params: Vec<Vec<f64>> = (0..cg.nodes.len())
505        .map(|i| cg.node_params(i, flat))
506        .collect::<Result<_, _>>()?;
507
508    jacobian_compiled_indexed(cg, &node_params, x)
509}
510
511/// Compute the analytical Jacobian using pre-computed per-node param buffers.
512///
513/// Hot-path companion to [`jacobian_compiled`]: the caller maintains
514/// `node_params[i]` and passes it directly, bypassing all `HashMap` lookups.
515///
516/// # Errors
517/// Returns [`CoreError::Eval`] if: `x.len()` is not an exact multiple of the
518/// graph's coordinate dimensionality ([`GraphError::XBufferStrideMismatch`]);
519/// the graph's nodes disagree on `n_dims`
520/// ([`GraphError::DimensionalityMismatch`]); or, when multi-dataset scoping is
521/// active, a node's `dataset_index` is out of range
522/// ([`GraphError::DatasetIndexOutOfRange`]).
523pub fn jacobian_compiled_indexed(
524    cg: &CompiledGraph,
525    node_params: &[Vec<f64>],
526    x: &[f64],
527) -> Result<DMatrix<f64>, CoreError> {
528    let mut data = Vec::new();
529    jacobian_compiled_indexed_into(cg, node_params, x, &mut data)?;
530    let (_stride, n_points) = coord_layout(cg, x.len())?;
531    let n_free = cg.free_keys.len();
532    if n_free == 0 {
533        Ok(DMatrix::zeros(n_points, 0))
534    } else {
535        Ok(DMatrix::from_row_slice(n_points, n_free, &data))
536    }
537}
538
539// NOTE: `jacobian_compiled_indexed_weighted` (the allocating counterpart of
540// `..._weighted_into`) was removed when the crate's modules became private.
541// It had no caller anywhere in the workspace — invisible while `pub mod
542// executor;` made every item reachable, so rustc could not prove it unused.
543// Restore from git history if an allocating variant is ever needed.
544
545/// Compute the analytical Jacobian using a reusable row-major scratch buffer.
546///
547/// Callers with iterative solve loops can reuse ``data`` across iterations to
548/// reduce repeated heap allocation churn.
549///
550/// # Errors
551/// Returns [`CoreError::Eval`] if: `x.len()` is not an exact multiple of the
552/// graph's coordinate dimensionality ([`GraphError::XBufferStrideMismatch`]);
553/// the graph's nodes disagree on `n_dims`
554/// ([`GraphError::DimensionalityMismatch`]); or a node's `dataset_index` is
555/// out of range for the recorded `dataset_offsets`
556/// ([`GraphError::DatasetIndexOutOfRange`]).
557pub fn jacobian_compiled_indexed_into(
558    cg: &CompiledGraph,
559    node_params: &[Vec<f64>],
560    x: &[f64],
561    data: &mut Vec<f64>,
562) -> Result<(), CoreError> {
563    let (stride, n_points) = coord_layout(cg, x.len())?;
564    let n_free = cg.free_keys.len();
565
566    // Reject out-of-range `dataset_index` before the scope post-pass indexes
567    // `dataset_offsets[di + 1]`.
568    validate_dataset_scope(cg)?;
569
570    if n_free == 0 {
571        data.clear();
572        return Ok(());
573    }
574
575    // `cg.node_free_cols` was pre-computed during `CompiledGraph::compile()` —
576    // no string-parsing or HashMap construction needed here.
577    let required = n_points.saturating_mul(n_free);
578    if data.len() != required {
579        data.resize(required, 0.0);
580    } else {
581        data.fill(0.0);
582    }
583
584    // Maximum parameter count across all nodes — determines scratch buffer size.
585    let max_params = cg
586        .nodes
587        .iter()
588        .map(|n| n.param_names.len())
589        .max()
590        .unwrap_or(0);
591
592    if should_parallel(n_points, cg.nodes.len().saturating_mul(n_free)) {
593        // Parallel: each rayon worker thread gets its own scratch buffer (cloned
594        // once per thread, not once per point).  Avoids heap allocations in the
595        // inner loop while keeping the data-race-free par_chunks_mut layout.
596        data.par_chunks_mut(n_free)
597            .zip(x.par_chunks_exact(stride))
598            .for_each_with(vec![0.0_f64; max_params], |scratch, (row_buf, coord)| {
599                for (node_idx, node) in cg.nodes.iter().enumerate() {
600                    let free_cols = &cg.node_free_cols[node_idx];
601                    if free_cols.is_empty() {
602                        continue;
603                    }
604                    let params = &node_params[node_idx];
605                    node.model
606                        .jacobian_into(coord, params, &mut scratch[..params.len()]);
607                    for &(local_idx, col) in free_cols {
608                        row_buf[col] = scratch[local_idx];
609                    }
610                }
611            });
612    } else {
613        // Sequential: one scratch buffer reused for every (x-point, node) pair —
614        // zero heap allocations inside the loop.
615        let mut scratch = vec![0.0_f64; max_params];
616        for (i, coord) in x.chunks_exact(stride).enumerate() {
617            let row_buf = &mut data[i * n_free..(i + 1) * n_free];
618            for (node_idx, node) in cg.nodes.iter().enumerate() {
619                let free_cols = &cg.node_free_cols[node_idx];
620                if free_cols.is_empty() {
621                    continue;
622                }
623                let params = &node_params[node_idx];
624                node.model
625                    .jacobian_into(coord, params, &mut scratch[..params.len()]);
626                for &(local_idx, col) in free_cols {
627                    row_buf[col] = scratch[local_idx];
628                }
629            }
630        }
631    }
632
633    apply_jacobian_dataset_scope(cg, n_points, n_free, data);
634    Ok(())
635}
636
637/// Compute the weighted analytical Jacobian into a reusable row-major buffer.
638///
639/// Each generated row is divided by the corresponding sigma weight before
640/// storing into `data`, avoiding a second pass over the dense matrix.
641///
642/// # Errors
643/// Returns [`CoreError::Eval`] if: `x.len()` is not an exact multiple of the
644/// graph's coordinate dimensionality ([`GraphError::XBufferStrideMismatch`]);
645/// the graph's nodes disagree on `n_dims`
646/// ([`GraphError::DimensionalityMismatch`]); `sigma.len()` does not match the
647/// computed point count ([`GraphError::OutputBufferLength`]); or a node's
648/// `dataset_index` is out of range for the recorded `dataset_offsets`
649/// ([`GraphError::DatasetIndexOutOfRange`]).
650pub fn jacobian_compiled_indexed_weighted_into(
651    cg: &CompiledGraph,
652    node_params: &[Vec<f64>],
653    x: &[f64],
654    sigma: &[f64],
655    data: &mut Vec<f64>,
656) -> Result<(), CoreError> {
657    let (stride, n_points) = coord_layout(cg, x.len())?;
658    let n_free = cg.free_keys.len();
659
660    if sigma.len() != n_points {
661        return Err(GraphError::OutputBufferLength {
662            actual: sigma.len(),
663            expected: n_points,
664        }
665        .into());
666    }
667
668    // Reject out-of-range `dataset_index` before the scope post-pass indexes
669    // `dataset_offsets[di + 1]`.
670    validate_dataset_scope(cg)?;
671
672    if n_free == 0 {
673        data.clear();
674        return Ok(());
675    }
676
677    let required = n_points.saturating_mul(n_free);
678    if data.len() != required {
679        data.resize(required, 0.0);
680    } else {
681        data.fill(0.0);
682    }
683
684    // Single-node, 1-D fast path: uses `jac_slice_into` to hoist model
685    // invariants.  Only applicable when free_cols is an identity mapping so
686    // we can write directly into `data` without a scatter step.  `jac_slice_into`
687    // is a 1-D-only contract, so it is gated on stride == 1.
688    if !scoping_active(cg)
689        && stride == 1
690        && cg.nodes.len() == 1
691        && cg.nodes[0].model.n_dims() == 1
692        && n_free == cg.nodes[0].param_names.len()
693        && cg.node_free_cols[0]
694            .iter()
695            .enumerate()
696            .all(|(i, &(local, col))| local == i && col == i)
697    {
698        let node = &cg.nodes[0];
699        node.model.jac_slice_into(x, &node_params[0], data);
700        // Apply sigma weighting in a second tight pass.
701        for i in 0..n_points {
702            let inv_s = 1.0 / sigma[i];
703            for v in &mut data[i * n_free..(i + 1) * n_free] {
704                *v *= inv_s;
705            }
706        }
707        return Ok(());
708    }
709
710    // General 1-D batched path (multi-node, or single node with a non-identity
711    // free-column map): batch each node's Jacobian with `jac_slice_into`, then
712    // scatter its columns into the free-parameter layout with sigma weighting.
713    // `stride == 1` implies every node is 1-D, so this replaces the per-(point,
714    // node) virtual `jacobian_into` dispatch with one batched call per node.
715    // The scalar path below is kept only for n-D models (stride > 1).
716    // Under dataset scoping this batched path is skipped — fall through to the
717    // scalar path so the post-pass can restrict local nodes to their dataset.
718    if stride == 1 && !scoping_active(cg) {
719        // Reused across nodes; sized to the widest node's [n_points × n_local].
720        let mut node_scratch: Vec<f64> = Vec::new();
721        for (node_idx, node) in cg.nodes.iter().enumerate() {
722            let free_cols = &cg.node_free_cols[node_idx];
723            if free_cols.is_empty() {
724                continue;
725            }
726            let n_local = node.param_names.len();
727            node_scratch.clear();
728            node_scratch.resize(n_points * n_local, 0.0);
729            node.model
730                .jac_slice_into(x, &node_params[node_idx], &mut node_scratch);
731            for i in 0..n_points {
732                let inv_s = 1.0 / sigma[i];
733                let base = i * n_local;
734                let row = &mut data[i * n_free..(i + 1) * n_free];
735                for &(local_idx, col) in free_cols {
736                    row[col] = node_scratch[base + local_idx] * inv_s;
737                }
738            }
739        }
740        return Ok(());
741    }
742
743    let max_params = cg
744        .nodes
745        .iter()
746        .map(|n| n.param_names.len())
747        .max()
748        .unwrap_or(0);
749
750    if should_parallel(n_points, cg.nodes.len().saturating_mul(n_free)) {
751        data.par_chunks_mut(n_free)
752            .zip(x.par_chunks_exact(stride).zip(sigma.par_iter()))
753            .for_each_with(
754                vec![0.0_f64; max_params],
755                |scratch, (row_buf, (coord, &s))| {
756                    for (node_idx, node) in cg.nodes.iter().enumerate() {
757                        let free_cols = &cg.node_free_cols[node_idx];
758                        if free_cols.is_empty() {
759                            continue;
760                        }
761                        let params = &node_params[node_idx];
762                        node.model
763                            .jacobian_into(coord, params, &mut scratch[..params.len()]);
764                        for &(local_idx, col) in free_cols {
765                            row_buf[col] = scratch[local_idx] / s;
766                        }
767                    }
768                },
769            );
770    } else {
771        let mut scratch = vec![0.0_f64; max_params];
772        for (i, (coord, &s)) in x.chunks_exact(stride).zip(sigma.iter()).enumerate() {
773            let row_buf = &mut data[i * n_free..(i + 1) * n_free];
774            for (node_idx, node) in cg.nodes.iter().enumerate() {
775                let free_cols = &cg.node_free_cols[node_idx];
776                if free_cols.is_empty() {
777                    continue;
778                }
779                let params = &node_params[node_idx];
780                node.model
781                    .jacobian_into(coord, params, &mut scratch[..params.len()]);
782                for &(local_idx, col) in free_cols {
783                    row_buf[col] = scratch[local_idx] / s;
784                }
785            }
786        }
787    }
788
789    apply_jacobian_dataset_scope(cg, n_points, n_free, data);
790    Ok(())
791}
792
793// ---------------------------------------------------------------------------
794// Unit tests
795// ---------------------------------------------------------------------------
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use crate::compiler::CompiledGraph;
800    use approx::assert_relative_eq;
801    use spectrafit_types::{FitGraphSpec, ModelNodeSpec, ModelTypeStr, ParameterSpec};
802
803    fn make_param(value: f64, vary: bool) -> ParameterSpec {
804        ParameterSpec {
805            value,
806            min: f64::NEG_INFINITY,
807            max: f64::INFINITY,
808            vary,
809            expr: None,
810            scale: None,
811        }
812    }
813
814    fn flat(pairs: &[(&str, f64)]) -> HashMap<String, f64> {
815        pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
816    }
817
818    fn single_gaussian() -> (FitGraphSpec, HashMap<String, f64>) {
819        let mut params = HashMap::new();
820        params.insert("amplitude".to_string(), make_param(2.0, true));
821        params.insert("center".to_string(), make_param(1.0, true));
822        params.insert("sigma".to_string(), make_param(0.5, true));
823        let graph = FitGraphSpec {
824            schema_version: "0.1".to_string(),
825            nodes: vec![ModelNodeSpec {
826                id: "g".to_string(),
827                model_type: ModelTypeStr::Gaussian,
828                dataset_index: None,
829                parameters: params,
830            }],
831            expr_edges: vec![],
832        };
833        let pf = flat(&[("g.amplitude", 2.0), ("g.center", 1.0), ("g.sigma", 0.5)]);
834        (graph, pf)
835    }
836
837    #[test]
838    fn evaluate_single_gaussian_at_center() {
839        let (graph, pf) = single_gaussian();
840        let cg = CompiledGraph::compile(&graph).unwrap();
841        let result = evaluate_compiled(&cg, &pf, &[1.0]).unwrap();
842        // At x=center: amplitude * exp(0) = 2.0
843        assert_relative_eq!(result[0], 2.0, epsilon = 1e-12);
844    }
845
846    #[test]
847    fn evaluate_multi_point() {
848        let (graph, pf) = single_gaussian();
849        let cg = CompiledGraph::compile(&graph).unwrap();
850        let x = vec![0.0, 1.0, 2.0];
851        let result = evaluate_compiled(&cg, &pf, &x).unwrap();
852        assert_eq!(result.len(), 3);
853        // Each value should be positive
854        for v in &result {
855            assert!(*v >= 0.0);
856        }
857    }
858
859    #[test]
860    fn evaluate_components_returns_all_nodes() {
861        let mut g_params = HashMap::new();
862        g_params.insert("amplitude".to_string(), make_param(1.0, true));
863        g_params.insert("center".to_string(), make_param(0.0, true));
864        g_params.insert("sigma".to_string(), make_param(1.0, true));
865        let mut c_params = HashMap::new();
866        c_params.insert("c".to_string(), make_param(3.0, true));
867
868        let graph = FitGraphSpec {
869            schema_version: "0.1".to_string(),
870            nodes: vec![
871                ModelNodeSpec {
872                    id: "peak".to_string(),
873                    model_type: ModelTypeStr::Gaussian,
874                    dataset_index: None,
875                    parameters: g_params,
876                },
877                ModelNodeSpec {
878                    id: "bg".to_string(),
879                    model_type: ModelTypeStr::Constant,
880                    dataset_index: None,
881                    parameters: c_params,
882                },
883            ],
884            expr_edges: vec![],
885        };
886        let pf = flat(&[
887            ("peak.amplitude", 1.0),
888            ("peak.center", 0.0),
889            ("peak.sigma", 1.0),
890            ("bg.c", 3.0),
891        ]);
892        let cg = CompiledGraph::compile(&graph).unwrap();
893        let comps = evaluate_components_compiled(&cg, &pf, &[0.0]).unwrap();
894        assert_eq!(comps.len(), 2);
895        assert_relative_eq!(comps["peak"][0], 1.0, epsilon = 1e-12);
896        assert_relative_eq!(comps["bg"][0], 3.0, epsilon = 1e-12);
897    }
898
899    #[test]
900    fn jacobian_shape_single_gaussian() {
901        let (graph, pf) = single_gaussian();
902        let cg = CompiledGraph::compile(&graph).unwrap();
903        let x = vec![0.0, 0.5, 1.0, 1.5, 2.0];
904        let jac = jacobian_compiled(&cg, &pf, &x).unwrap();
905        assert_eq!(jac.nrows(), 5);
906        assert_eq!(jac.ncols(), 3); // amplitude, center, sigma
907    }
908
909    #[test]
910    fn jacobian_matches_finite_difference() {
911        let (graph, pf) = single_gaussian();
912        let cg = CompiledGraph::compile(&graph).unwrap();
913        let x = vec![0.8f64]; // off-center for non-trivial derivatives
914        let h = 1e-6;
915        let jac = jacobian_compiled(&cg, &pf, &x).unwrap();
916
917        let keys = ["g.amplitude", "g.center", "g.sigma"];
918        for (col, key) in keys.iter().enumerate() {
919            let mut pf_plus = pf.clone();
920            *pf_plus.get_mut(*key).unwrap() += h;
921            let f_plus = evaluate_compiled(&cg, &pf_plus, &x).unwrap()[0];
922            let f_base = evaluate_compiled(&cg, &pf, &x).unwrap()[0];
923            let fd = (f_plus - f_base) / h;
924            assert!(
925                (jac[(0, col)] - fd).abs() < 1e-5,
926                "col {} ({}) analytical vs FD mismatch: got {}, expected {}",
927                col,
928                key,
929                jac[(0, col)],
930                fd
931            );
932        }
933    }
934
935    #[test]
936    fn jacobian_fixed_param_excluded() {
937        // amplitude is fixed (vary=false) — only 2 free params (center, sigma)
938        let mut params = HashMap::new();
939        params.insert("amplitude".to_string(), make_param(2.0, false)); // fixed
940        params.insert("center".to_string(), make_param(0.0, true));
941        params.insert("sigma".to_string(), make_param(1.0, true));
942        let graph = FitGraphSpec {
943            schema_version: "0.1".to_string(),
944            nodes: vec![ModelNodeSpec {
945                id: "g".to_string(),
946                model_type: ModelTypeStr::Gaussian,
947                dataset_index: None,
948                parameters: params,
949            }],
950            expr_edges: vec![],
951        };
952        let pf = flat(&[("g.amplitude", 2.0), ("g.center", 0.0), ("g.sigma", 1.0)]);
953        let cg = CompiledGraph::compile(&graph).unwrap();
954        let jac = jacobian_compiled(&cg, &pf, &[0.0]).unwrap();
955        assert_eq!(jac.ncols(), 2, "only 2 free params (center, sigma)");
956    }
957
958    // ── M1: multi-node batched path == scalar reference ────────────────────
959
960    /// Two Gaussians + a constant background, with one fixed parameter, over a
961    /// multi-point grid with non-trivial sigma. Exercises the batched 1-D
962    /// residual and Jacobian paths (multi-node accumulation + free-col scatter)
963    /// and pins them against an independent per-point scalar reference.
964    fn two_gauss_plus_const() -> (FitGraphSpec, HashMap<String, f64>) {
965        let mut g1 = HashMap::new();
966        g1.insert("amplitude".to_string(), make_param(2.0, true));
967        g1.insert("center".to_string(), make_param(-1.0, true));
968        g1.insert("sigma".to_string(), make_param(0.7, false)); // fixed → scatter gap
969        let mut g2 = HashMap::new();
970        g2.insert("amplitude".to_string(), make_param(1.3, true));
971        g2.insert("center".to_string(), make_param(1.2, true));
972        g2.insert("sigma".to_string(), make_param(0.5, true));
973        let mut bg = HashMap::new();
974        bg.insert("c".to_string(), make_param(0.4, true));
975        let graph = FitGraphSpec {
976            schema_version: "0.1".to_string(),
977            nodes: vec![
978                ModelNodeSpec {
979                    id: "a".to_string(),
980                    model_type: ModelTypeStr::Gaussian,
981                    dataset_index: None,
982                    parameters: g1,
983                },
984                ModelNodeSpec {
985                    id: "b".to_string(),
986                    model_type: ModelTypeStr::Gaussian,
987                    dataset_index: None,
988                    parameters: g2,
989                },
990                ModelNodeSpec {
991                    id: "k".to_string(),
992                    model_type: ModelTypeStr::Constant,
993                    dataset_index: None,
994                    parameters: bg,
995                },
996            ],
997            expr_edges: vec![],
998        };
999        let pf = flat(&[
1000            ("a.amplitude", 2.0),
1001            ("a.center", -1.0),
1002            ("a.sigma", 0.7),
1003            ("b.amplitude", 1.3),
1004            ("b.center", 1.2),
1005            ("b.sigma", 0.5),
1006            ("k.c", 0.4),
1007        ]);
1008        (graph, pf)
1009    }
1010
1011    #[test]
1012    fn multi_node_residuals_and_jacobian_match_scalar_reference() {
1013        let (graph, pf) = two_gauss_plus_const();
1014        let cg = CompiledGraph::compile(&graph).unwrap();
1015        let n = 9usize;
1016        let x: Vec<f64> = (0..n)
1017            .map(|i| -2.0 + 4.0 * i as f64 / (n - 1) as f64)
1018            .collect();
1019        let y: Vec<f64> = x.iter().map(|xi| 0.3 * xi + 0.1).collect();
1020        let sigma: Vec<f64> = (0..n).map(|i| 0.5 + 0.1 * i as f64).collect();
1021
1022        let node_params: Vec<Vec<f64>> = (0..cg.nodes.len())
1023            .map(|i| cg.node_params(i, &pf).unwrap())
1024            .collect();
1025        let n_free = cg.free_keys.len();
1026
1027        // Batched (production) path.
1028        let mut res = vec![0.0; n];
1029        residuals_compiled_indexed_into(&cg, &node_params, &x, &y, &sigma, &mut res).unwrap();
1030        let mut jac = Vec::new();
1031        jacobian_compiled_indexed_weighted_into(&cg, &node_params, &x, &sigma, &mut jac).unwrap();
1032
1033        // Independent scalar reference.
1034        let mut res_ref = vec![0.0; n];
1035        let mut jac_ref = vec![0.0; n * n_free];
1036        for i in 0..n {
1037            let mut sum = 0.0;
1038            for (ni, node) in cg.nodes.iter().enumerate() {
1039                sum += node.model.eval(&[x[i]], &node_params[ni]);
1040                let jn = node.model.jacobian(&[x[i]], &node_params[ni]);
1041                for &(local, col) in &cg.node_free_cols[ni] {
1042                    jac_ref[i * n_free + col] = jn[local] / sigma[i];
1043                }
1044            }
1045            res_ref[i] = (sum - y[i]) / sigma[i];
1046        }
1047
1048        for i in 0..n {
1049            assert_relative_eq!(res[i], res_ref[i], epsilon = 1e-12);
1050        }
1051        for k in 0..n * n_free {
1052            assert_relative_eq!(jac[k], jac_ref[k], epsilon = 1e-12);
1053        }
1054        // 7 params total minus the fixed a.sigma → 6 free:
1055        // a.amplitude, a.center, b.amplitude, b.center, b.sigma, k.c
1056        assert_eq!(n_free, 6);
1057    }
1058
1059    // ── 2-D executor striding (U2) ─────────────────────────────────────────
1060
1061    /// Build a single-node Gaussian2D graph plus its flat param map.
1062    fn single_gaussian2d() -> (FitGraphSpec, HashMap<String, f64>) {
1063        let mut params = HashMap::new();
1064        params.insert("amplitude".to_string(), make_param(3.0, true));
1065        params.insert("center_x".to_string(), make_param(0.5, true));
1066        params.insert("center_y".to_string(), make_param(-1.0, true));
1067        params.insert("sigma_x".to_string(), make_param(1.0, true));
1068        params.insert("sigma_y".to_string(), make_param(1.5, true));
1069        let graph = FitGraphSpec {
1070            schema_version: "0.1".to_string(),
1071            nodes: vec![ModelNodeSpec {
1072                id: "g2".to_string(),
1073                model_type: ModelTypeStr::Gaussian2D,
1074                dataset_index: None,
1075                parameters: params,
1076            }],
1077            expr_edges: vec![],
1078        };
1079        let pf = flat(&[
1080            ("g2.amplitude", 3.0),
1081            ("g2.center_x", 0.5),
1082            ("g2.center_y", -1.0),
1083            ("g2.sigma_x", 1.0),
1084            ("g2.sigma_y", 1.5),
1085        ]);
1086        (graph, pf)
1087    }
1088
1089    /// Real (passing) test: the executor must stride a 2-column flat `x` so each
1090    /// point receives its full `[x, y]` coordinate.  A constant model ignores
1091    /// coordinates, so this asserts the *layout* contract (n_points = len/stride)
1092    /// holds for stride > 1 while the 1-D path stays identical.
1093    #[test]
1094    fn executor_strides_two_column_x_for_constant_model() {
1095        // Constant is a 1-D model (n_dims == 1); to exercise stride==2 layout we
1096        // pair it with a Gaussian2D node that fixes the graph dimensionality to 2.
1097        // Here we instead use a pure-2D graph and a constant offset is emulated by
1098        // a Gaussian2D with zero amplitude is overkill — use Gaussian2D directly
1099        // but assert the *point count*, which is the striding invariant.
1100        let (graph, pf) = single_gaussian2d();
1101        let cg = CompiledGraph::compile(&graph).unwrap();
1102        assert_eq!(cg.n_dims().unwrap(), 2);
1103
1104        // Three points laid out point-major: [x0,y0, x1,y1, x2,y2].
1105        let x_flat = vec![0.5, -1.0, 10.0, 10.0, 0.5, -1.0];
1106        let vals = evaluate_compiled(&cg, &pf, &x_flat).unwrap();
1107        assert_eq!(vals.len(), 3, "len/stride = 6/2 = 3 points");
1108
1109        // Point 0 and 2 are at the center → value == amplitude (3.0).
1110        assert_relative_eq!(vals[0], 3.0, epsilon = 1e-12);
1111        assert_relative_eq!(vals[2], 3.0, epsilon = 1e-12);
1112        // Point 1 is far from center → strongly attenuated, strictly < amplitude.
1113        assert!(
1114            vals[1] < 1e-6,
1115            "far-field point should be ~0, got {}",
1116            vals[1]
1117        );
1118
1119        // Sanity: a non-multiple-of-stride buffer is rejected.
1120        assert!(evaluate_compiled(&cg, &pf, &[0.0, 0.0, 0.0]).is_err());
1121    }
1122
1123    /// Real (passing) test: the 1-D path is unchanged — a constant model over a
1124    /// plain 1-column `x` still yields one value per element.
1125    #[test]
1126    fn executor_one_d_constant_path_unchanged() {
1127        let mut params = HashMap::new();
1128        params.insert("c".to_string(), make_param(7.0, true));
1129        let graph = FitGraphSpec {
1130            schema_version: "0.1".to_string(),
1131            nodes: vec![ModelNodeSpec {
1132                id: "bg".to_string(),
1133                model_type: ModelTypeStr::Constant,
1134                dataset_index: None,
1135                parameters: params,
1136            }],
1137            expr_edges: vec![],
1138        };
1139        let pf = flat(&[("bg.c", 7.0)]);
1140        let cg = CompiledGraph::compile(&graph).unwrap();
1141        assert_eq!(cg.n_dims().unwrap(), 1);
1142        let vals = evaluate_compiled(&cg, &pf, &[0.0, 1.0, 2.0, 3.0]).unwrap();
1143        assert_eq!(vals.len(), 4);
1144        for v in &vals {
1145            assert_relative_eq!(*v, 7.0, epsilon = 1e-12);
1146        }
1147    }
1148
1149    #[test]
1150    fn gaussian2d_grid_evaluation_is_peaked_at_center() {
1151        // The 2-D *evaluation* half of a round-trip: a Gaussian2D over a strided
1152        // 5x5 grid yields one value per point, all positive, peaking at the grid
1153        // node nearest the true center (0.5, -1.0). The full solver-side round
1154        // trip (perturb start → fit → recover params) lives in the solver crate
1155        // (`crates/spectrafit-solver/tests/gaussian2d.rs`), which — unlike this
1156        // graph crate — depends on the solver. 2-D fitting works end-to-end; the
1157        // strided-x plumbing (`point_major_x`, point-count residual/Jacobian
1158        // sizing) was already complete (U2 / R4).
1159        let (graph, pf_true) = single_gaussian2d();
1160        let cg = CompiledGraph::compile(&graph).unwrap();
1161
1162        // Build a synthetic grid (5x5) flattened point-major: [xᵢ, yᵢ] per point.
1163        let mut x_flat = Vec::new();
1164        let mut coords = Vec::new();
1165        for i in 0..5 {
1166            for j in 0..5 {
1167                let xi = -2.0 + i as f64;
1168                let yj = -3.0 + j as f64;
1169                x_flat.push(xi);
1170                x_flat.push(yj);
1171                coords.push((xi, yj));
1172            }
1173        }
1174        let y = evaluate_compiled(&cg, &pf_true, &x_flat).unwrap();
1175        assert_eq!(y.len(), 25, "len/stride = 50/2 = 25 points");
1176
1177        // Every value is positive and bounded by the amplitude (3.0).
1178        for &v in &y {
1179            assert!(v > 0.0 && v <= 3.0 + 1e-12, "value out of range: {v}");
1180        }
1181
1182        // The peak grid value sits at the node nearest the true center (0.5,-1.0):
1183        // (cx≈0.0..1.0, cy=-1.0) → grid index (2,2) = coord (0.0, -1.0).
1184        let (peak_idx, &peak) = y
1185            .iter()
1186            .enumerate()
1187            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
1188            .unwrap();
1189        assert!(peak > 0.0);
1190        let (px, py) = coords[peak_idx];
1191        assert!(
1192            (px - 0.0).abs() <= 1.0 && (py - (-1.0)).abs() <= 1e-12,
1193            "peak at ({px},{py}) not nearest true center (0.5,-1.0)"
1194        );
1195    }
1196
1197    #[test]
1198    fn gaussian2d_jacobian_matches_finite_difference() {
1199        // Mirror of `jacobian_matches_finite_difference`, but over a strided
1200        // 2-D coordinate.  Verifies the executor passes the full [x, y] coord to
1201        // the analytical Jacobian and that it matches a forward difference.
1202        let (graph, pf) = single_gaussian2d();
1203        let cg = CompiledGraph::compile(&graph).unwrap();
1204        // Single off-center point [x, y].
1205        let x = vec![0.9f64, -0.2f64];
1206        let h = 1e-6;
1207        let jac = jacobian_compiled(&cg, &pf, &x).unwrap();
1208        assert_eq!(jac.nrows(), 1);
1209        assert_eq!(jac.ncols(), 5);
1210
1211        let keys = [
1212            "g2.amplitude",
1213            "g2.center_x",
1214            "g2.center_y",
1215            "g2.sigma_x",
1216            "g2.sigma_y",
1217        ];
1218        for (col, key) in keys.iter().enumerate() {
1219            let mut pf_plus = pf.clone();
1220            *pf_plus.get_mut(*key).unwrap() += h;
1221            let f_plus = evaluate_compiled(&cg, &pf_plus, &x).unwrap()[0];
1222            let f_base = evaluate_compiled(&cg, &pf, &x).unwrap()[0];
1223            let fd = (f_plus - f_base) / h;
1224            assert!(
1225                (jac[(0, col)] - fd).abs() < 1e-5,
1226                "col {} ({}) analytical vs FD mismatch: got {}, expected {}",
1227                col,
1228                key,
1229                jac[(0, col)],
1230                fd
1231            );
1232        }
1233    }
1234
1235    #[test]
1236    fn dataset_index_scopes_local_node_to_its_dataset() {
1237        // Two datasets of 3 points each, x concatenated (offsets [0, 3, 6]).
1238        // "gg" is global (dataset_index=None) → contributes to all 6 points.
1239        // "gl" is local to dataset 1 (dataset_index=Some(1)) → only points 3..6.
1240        let mk_node = |id: &str, ds: Option<usize>, c: f64| ModelNodeSpec {
1241            id: id.to_string(),
1242            model_type: ModelTypeStr::Gaussian,
1243            dataset_index: ds,
1244            parameters: {
1245                let mut p = HashMap::new();
1246                p.insert("amplitude".to_string(), make_param(1.0, true));
1247                p.insert("center".to_string(), make_param(c, true));
1248                p.insert("sigma".to_string(), make_param(0.5, true));
1249                p
1250            },
1251        };
1252        let graph = FitGraphSpec {
1253            schema_version: "0.1".to_string(),
1254            nodes: vec![mk_node("gg", None, 0.0), mk_node("gl", Some(1), 5.0)],
1255            expr_edges: vec![],
1256        };
1257        let mut cg = CompiledGraph::compile(&graph).unwrap();
1258        cg.dataset_offsets = vec![0, 3, 6];
1259
1260        let x = vec![-0.5, 0.0, 0.5, 4.5, 5.0, 5.5];
1261        let pf = flat(&[
1262            ("gg.amplitude", 1.0),
1263            ("gg.center", 0.0),
1264            ("gg.sigma", 0.5),
1265            ("gl.amplitude", 1.0),
1266            ("gl.center", 5.0),
1267            ("gl.sigma", 0.5),
1268        ]);
1269
1270        // Components: the local node is zero on dataset 0, nonzero on dataset 1.
1271        let comps = evaluate_components_compiled(&cg, &pf, &x).unwrap();
1272        assert!(
1273            comps["gl"][0..3].iter().all(|&v| v == 0.0),
1274            "local node must contribute zero outside its dataset"
1275        );
1276        assert!(
1277            comps["gl"][3..6].iter().any(|&v| v.abs() > 1e-6),
1278            "local node must contribute inside its dataset"
1279        );
1280        assert!(comps["gg"][0..3].iter().any(|&v| v.abs() > 1e-6));
1281
1282        // best_fit on a dataset-0 point excludes the local node (= gg only).
1283        let best = evaluate_compiled(&cg, &pf, &x).unwrap();
1284        assert!(
1285            (best[1] - comps["gg"][1]).abs() < 1e-12,
1286            "best_fit on dataset 0 must exclude the local node"
1287        );
1288
1289        // Jacobian: the local node's free columns are zero on dataset-0 rows.
1290        let node_params: Vec<Vec<f64>> = (0..cg.nodes.len())
1291            .map(|i| cg.node_params(i, &pf).unwrap())
1292            .collect();
1293        let mut jdata = Vec::new();
1294        jacobian_compiled_indexed_into(&cg, &node_params, &x, &mut jdata).unwrap();
1295        let n_free = cg.free_keys.len();
1296        let gl_cols: Vec<usize> = cg
1297            .free_keys
1298            .iter()
1299            .enumerate()
1300            .filter(|(_, k)| k.starts_with("gl."))
1301            .map(|(i, _)| i)
1302            .collect();
1303        assert!(!gl_cols.is_empty());
1304        for row in 0..3 {
1305            for &col in &gl_cols {
1306                assert_eq!(
1307                    jdata[row * n_free + col],
1308                    0.0,
1309                    "local node Jacobian must be zero outside its dataset"
1310                );
1311            }
1312        }
1313    }
1314
1315    /// G1 regression: a node whose `dataset_index` points beyond the recorded
1316    /// `dataset_offsets` must produce a `CoreError`, not an index panic, in the
1317    /// scoped eval/residual/Jacobian paths.
1318    #[test]
1319    fn out_of_range_dataset_index_errors_not_panics() {
1320        let mk_node = |id: &str, ds: Option<usize>, c: f64| ModelNodeSpec {
1321            id: id.to_string(),
1322            model_type: ModelTypeStr::Gaussian,
1323            dataset_index: ds,
1324            parameters: {
1325                let mut p = HashMap::new();
1326                p.insert("amplitude".to_string(), make_param(1.0, true));
1327                p.insert("center".to_string(), make_param(c, true));
1328                p.insert("sigma".to_string(), make_param(0.5, true));
1329                p
1330            },
1331        };
1332        // Two datasets recorded (offsets [0, 3, 6] → valid indices 0 and 1),
1333        // but "gl" references dataset 2 — beyond the offsets array.
1334        let graph = FitGraphSpec {
1335            schema_version: "0.1".to_string(),
1336            nodes: vec![mk_node("gg", None, 0.0), mk_node("gl", Some(2), 5.0)],
1337            expr_edges: vec![],
1338        };
1339        let mut cg = CompiledGraph::compile(&graph).unwrap();
1340        cg.dataset_offsets = vec![0, 3, 6]; // 2 datasets → index 2 is out of range
1341
1342        let x = vec![-0.5, 0.0, 0.5, 4.5, 5.0, 5.5];
1343        let y = vec![0.0; 6];
1344        let sigma = vec![1.0; 6];
1345        let pf = flat(&[
1346            ("gg.amplitude", 1.0),
1347            ("gg.center", 0.0),
1348            ("gg.sigma", 0.5),
1349            ("gl.amplitude", 1.0),
1350            ("gl.center", 5.0),
1351            ("gl.sigma", 0.5),
1352        ]);
1353
1354        // best_fit (scoped sum) must error, not panic.
1355        let err = evaluate_compiled(&cg, &pf, &x).unwrap_err();
1356        assert!(
1357            format!("{err}").contains("dataset_index"),
1358            "expected a dataset_index range error, got: {err}"
1359        );
1360
1361        // Components path must error too.
1362        assert!(evaluate_components_compiled(&cg, &pf, &x).is_err());
1363
1364        // Residual path must error.
1365        let node_params: Vec<Vec<f64>> = (0..cg.nodes.len())
1366            .map(|i| cg.node_params(i, &pf).unwrap())
1367            .collect();
1368        let mut res = vec![0.0; 6];
1369        assert!(
1370            residuals_compiled_indexed_into(&cg, &node_params, &x, &y, &sigma, &mut res).is_err()
1371        );
1372
1373        // Jacobian (unweighted and weighted) post-pass must error.
1374        let mut jdata = Vec::new();
1375        assert!(jacobian_compiled_indexed_into(&cg, &node_params, &x, &mut jdata).is_err());
1376        let mut jw = Vec::new();
1377        assert!(
1378            jacobian_compiled_indexed_weighted_into(&cg, &node_params, &x, &sigma, &mut jw)
1379                .is_err()
1380        );
1381    }
1382
1383    /// A2 follow-up: an `x` buffer whose length is not a multiple of `n_dims`
1384    /// must surface the typed `GraphError::XBufferStrideMismatch` variant via
1385    /// the CoreError boundary conversion (a stale stringly-typed message would
1386    /// silently regress the typed-error contract).
1387    #[test]
1388    fn x_buffer_stride_mismatch_emits_graph_error_variant() {
1389        // Single Gaussian2D: n_dims = 2. Hand it an 7-long buffer (odd → not a
1390        // multiple of 2) so `coord_layout` rejects it.
1391        let mut params = HashMap::new();
1392        params.insert("amplitude".to_string(), make_param(1.0, true));
1393        params.insert("center_x".to_string(), make_param(0.0, true));
1394        params.insert("center_y".to_string(), make_param(0.0, true));
1395        params.insert("sigma_x".to_string(), make_param(1.0, true));
1396        params.insert("sigma_y".to_string(), make_param(1.0, true));
1397        let graph = FitGraphSpec {
1398            schema_version: "0.1".to_string(),
1399            nodes: vec![ModelNodeSpec {
1400                id: "g".to_string(),
1401                model_type: ModelTypeStr::Gaussian2D,
1402                dataset_index: None,
1403                parameters: params,
1404            }],
1405            expr_edges: vec![],
1406        };
1407        let cg = CompiledGraph::compile(&graph).unwrap();
1408        let pf = flat(&[
1409            ("g.amplitude", 1.0),
1410            ("g.center_x", 0.0),
1411            ("g.center_y", 0.0),
1412            ("g.sigma_x", 1.0),
1413            ("g.sigma_y", 1.0),
1414        ]);
1415
1416        let bad_x: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // length 7, n_dims = 2
1417        let err = evaluate_compiled(&cg, &pf, &bad_x).unwrap_err();
1418        let expected: CoreError = GraphError::XBufferStrideMismatch {
1419            x_len: 7,
1420            n_dims: 2,
1421        }
1422        .into();
1423        assert_eq!(format!("{err}"), format!("{expected}"));
1424    }
1425}