Skip to main content

echidna/
diffop.rs

1//! Arbitrary differential operator evaluation via jet coefficients.
2//!
3//! Evaluate any mixed partial derivative of a recorded tape by constructing
4//! higher-order Taylor jets with carefully chosen input coefficients. A single
5//! forward pushforward extracts the derivative from a specific output jet
6//! coefficient, scaled by a known prefactor from the multivariate Faa di Bruno
7//! formula.
8//!
9//! # How it works
10//!
11//! For a function `u: R^n -> R` recorded as a [`BytecodeTape`], we want to
12//! compute an arbitrary mixed partial:
13//!
14//! ```text
15//! ∂^Q u / (∂x_{i₁}^{q₁} ... ∂x_{iT}^{qT})
16//! ```
17//!
18//! The method parameterises a curve `g(t) = u(x₀ + v⁽¹⁾t + v⁽²⁾t²/2! + ...)`
19//! where each active variable is assigned a distinct polynomial "slot" `j_t`,
20//! with `coeffs[j_t] = 1/j_t!` for that variable's input. The output jet
21//! coefficient at index `k = Σ j_t · q_t` then equals the target derivative
22//! divided by a known prefactor.
23//!
24//! # Usage
25//!
26//! ```ignore
27//! use echidna::diffop::{JetPlan, MultiIndex};
28//!
29//! // Record a tape
30//! let (tape, _) = echidna::record(|x| x[0] * x[0] * x[1], &[1.0, 2.0]);
31//!
32//! // Plan: compute ∂²u/∂x₀² and ∂u/∂x₁
33//! let indices = vec![
34//!     MultiIndex::diagonal(2, 0, 2), // d²/dx₀²
35//!     MultiIndex::partial(2, 1),      // d/dx₁
36//! ];
37//! let plan = JetPlan::plan(2, &indices);
38//!
39//! // Evaluate
40//! let result = echidna::diffop::eval_dyn(&plan, &tape, &[1.0, 2.0]);
41//! // result.derivatives[0] = 2*x₁ = 4.0  (∂²(x₀²x₁)/∂x₀²)
42//! // result.derivatives[1] = x₀² = 1.0    (∂(x₀²x₁)/∂x₁)
43//! ```
44//!
45//! # Design
46//!
47//! - **Plan once, evaluate many**: [`JetPlan::plan`] precomputes slot assignments,
48//!   jet order, and extraction prefactors. Reuse the plan across evaluation points.
49//! - **`TaylorDyn`** for runtime jet order: the required order depends on the
50//!   differential operator and cannot be known at compile time.
51//! - **Pushforward groups**: Multi-indices that share the same set of active
52//!   variables are batched into one forward pass. Multi-indices with different
53//!   active variables get separate pushforwards to avoid slot contamination.
54//! - **Panics on misuse**: dimension mismatches panic, following existing API
55//!   conventions.
56//!
57//! # Differential Operators
58//!
59//! [`DiffOp`] represents a linear differential operator `L = Σ C_α D^α`.
60//! It supports exact evaluation via [`DiffOp::eval`] (delegates to `JetPlan`)
61//! and construction of a [`SparseSamplingDistribution`] for stochastic
62//! estimation via [`stde::stde_sparse`](crate::stde::stde_sparse) (requires
63//! `stde` feature). Convenience constructors are provided for common operators:
64//! [`DiffOp::laplacian`], [`DiffOp::biharmonic`], [`DiffOp::diagonal`].
65//! Inhomogeneous operators can be decomposed with [`DiffOp::split_by_order`].
66
67use crate::bytecode_tape::BytecodeTape;
68use crate::taylor_dyn::{TaylorArenaLocal, TaylorDyn, TaylorDynGuard};
69use crate::Float;
70
71// ══════════════════════════════════════════════
72//  MultiIndex
73// ══════════════════════════════════════════════
74
75/// A multi-index specifying which mixed partial derivative to compute.
76///
77/// `orders[i]` = how many times to differentiate with respect to variable `x_i`.
78///
79/// # Examples
80///
81/// - `[2, 0, 1]` represents `∂³u/(∂x₀²∂x₂)` (total order 3).
82/// - `[0, 1]` represents `∂u/∂x₁` (first partial).
83#[derive(Clone, Debug, PartialEq, Eq, Hash)]
84pub struct MultiIndex {
85    orders: Vec<u8>,
86}
87
88impl MultiIndex {
89    /// Create a multi-index from a slice of per-variable differentiation orders.
90    ///
91    /// # Panics
92    ///
93    /// Panics if `orders` is empty.
94    #[must_use]
95    pub fn new(orders: &[u8]) -> Self {
96        assert!(
97            !orders.is_empty(),
98            "multi-index must have at least one variable"
99        );
100        MultiIndex {
101            orders: orders.to_vec(),
102        }
103    }
104
105    /// Multi-index for a single-variable diagonal derivative: `d^order u / dx_var^order`.
106    ///
107    /// # Panics
108    ///
109    /// Panics if `var >= num_vars` or `order == 0`.
110    #[must_use]
111    pub fn diagonal(num_vars: usize, var: usize, order: u8) -> Self {
112        assert!(var < num_vars, "var ({}) >= num_vars ({})", var, num_vars);
113        assert!(order > 0, "order must be > 0");
114        let mut orders = vec![0u8; num_vars];
115        orders[var] = order;
116        MultiIndex { orders }
117    }
118
119    /// Multi-index for a first partial: `∂u/∂x_var`.
120    ///
121    /// # Panics
122    ///
123    /// Panics if `var >= num_vars`.
124    #[must_use]
125    pub fn partial(num_vars: usize, var: usize) -> Self {
126        Self::diagonal(num_vars, var, 1)
127    }
128
129    /// Total differentiation order: `Σ orders[i]`.
130    #[must_use]
131    pub fn total_order(&self) -> usize {
132        self.orders.iter().map(|&o| o as usize).sum()
133    }
134
135    /// Active variables: indices where `orders[i] > 0`, paired with their order.
136    #[must_use]
137    pub fn active_vars(&self) -> Vec<(usize, u8)> {
138        self.orders
139            .iter()
140            .enumerate()
141            .filter(|(_, &o)| o > 0)
142            .map(|(i, &o)| (i, o))
143            .collect()
144    }
145
146    /// Number of variables in this multi-index.
147    #[must_use]
148    pub fn num_vars(&self) -> usize {
149        self.orders.len()
150    }
151
152    /// The per-variable differentiation orders.
153    #[must_use]
154    pub fn orders(&self) -> &[u8] {
155        &self.orders
156    }
157
158    /// Active variable indices only (sorted).
159    fn active_var_set(&self) -> Vec<usize> {
160        self.orders
161            .iter()
162            .enumerate()
163            .filter(|(_, &o)| o > 0)
164            .map(|(i, _)| i)
165            .collect()
166    }
167}
168
169// ══════════════════════════════════════════════
170//  Partition utilities (internal)
171// ══════════════════════════════════════════════
172
173/// Enumerate all partitions of integer `k` using only the given slot values as parts.
174///
175/// Each partition is a list of `(slot, multiplicity)` pairs sorted by slot.
176fn partitions_with_support(k: usize, slots: &[usize]) -> Vec<Vec<(usize, usize)>> {
177    let mut results = Vec::new();
178    let mut current = Vec::new();
179    partitions_recurse(k, slots, 0, &mut current, &mut results);
180    results
181}
182
183fn partitions_recurse(
184    remaining: usize,
185    slots: &[usize],
186    start_idx: usize,
187    current: &mut Vec<(usize, usize)>,
188    results: &mut Vec<Vec<(usize, usize)>>,
189) {
190    if remaining == 0 {
191        results.push(current.clone());
192        return;
193    }
194    for idx in start_idx..slots.len() {
195        let s = slots[idx];
196        if s > remaining {
197            continue;
198        }
199        let max_mult = remaining / s;
200        for mult in 1..=max_mult {
201            current.push((s, mult));
202            partitions_recurse(remaining - s * mult, slots, idx + 1, current, results);
203            current.pop();
204        }
205    }
206}
207
208/// Compute the extraction prefactor: `Π_t (q_t! · (j_t!)^{q_t})`.
209///
210/// Uses direct integer-like products for typical jet orders (exact in f64 for
211/// factorials up to 18!) and falls back to a log-domain accumulation if the
212/// product overflows. The direct path avoids the sub-ULP noise that
213/// `exp(sum(log(i)))` would introduce for small orders, while the fallback
214/// ensures that high-order calls return a clean `+inf` instead of NaN from
215/// `inf * 1` or similar intermediate patterns.
216fn extraction_prefactor<F: Float>(slot_assignments: &[(usize, u8)]) -> F {
217    let mut prefactor = F::one();
218    for &(slot, order) in slot_assignments {
219        let mut q_fact = F::one();
220        for i in 2..=(order as usize) {
221            q_fact = q_fact * F::from(i).unwrap();
222        }
223        let mut j_fact = F::one();
224        for i in 2..=slot {
225            j_fact = j_fact * F::from(i).unwrap();
226        }
227        let mut j_fact_pow = F::one();
228        for _ in 0..order {
229            j_fact_pow = j_fact_pow * j_fact;
230        }
231        prefactor = prefactor * q_fact * j_fact_pow;
232    }
233    if prefactor.is_finite() {
234        return prefactor;
235    }
236    // Integer-path overflow. Recompute in log-domain; if that also overflows,
237    // we return `+inf` (exp saturates cleanly) rather than propagating NaN
238    // from any earlier `inf * 1` multiply.
239    let mut log_pref = F::zero();
240    for &(slot, order) in slot_assignments {
241        for i in 2..=(order as usize) {
242            log_pref = log_pref + F::from(i).unwrap().ln();
243        }
244        let mut log_j_fact = F::zero();
245        for i in 2..=slot {
246            log_j_fact = log_j_fact + F::from(i).unwrap().ln();
247        }
248        log_pref = log_pref + F::from(order as usize).unwrap() * log_j_fact;
249    }
250    log_pref.exp()
251}
252
253// ══════════════════════════════════════════════
254//  JetPlan
255// ══════════════════════════════════════════════
256
257/// A single extraction from a pushforward's output coefficients.
258#[derive(Clone, Debug)]
259struct Extraction<F> {
260    /// Index into the final derivatives vector.
261    result_index: usize,
262    /// Which output coefficient to read.
263    output_coeff_index: usize,
264    /// Multiply `coeffs[k]` by this to get the derivative value.
265    prefactor: F,
266}
267
268/// A group of multi-indices that share one pushforward.
269///
270/// All multi-indices in a group must have the same set of active variables
271/// (though possibly different orders).
272#[derive(Clone, Debug)]
273struct PushforwardGroup<F> {
274    /// Number of Taylor coefficients for this group.
275    jet_order: usize,
276    /// Input coefficient assignments: `(var_index, slot, 1/slot!)`.
277    input_coeffs: Vec<(usize, usize, F)>,
278    /// Extractions from this group's output.
279    extractions: Vec<Extraction<F>>,
280}
281
282/// Immutable plan for jet evaluation. Constructed once, reused across points.
283///
284/// Use [`JetPlan::plan`] to create a plan from a set of multi-indices, then
285/// pass it to [`eval_dyn`] to evaluate at specific points.
286#[derive(Clone, Debug)]
287pub struct JetPlan<F> {
288    /// Max jet order across all groups.
289    max_jet_order: usize,
290    /// Pushforward groups.
291    groups: Vec<PushforwardGroup<F>>,
292    /// The multi-indices, in order.
293    multi_indices: Vec<MultiIndex>,
294}
295
296/// First primes for slot assignment.
297const PRIMES: [usize; 20] = [
298    2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71,
299];
300
301/// Check whether ALL multi-indices in a group can be cleanly extracted
302/// with the given variable-to-slot mapping. Returns `Ok(extractions, max_k)`
303/// if collision-free, or `Err(())` if any collision exists.
304fn try_slots<F: Float>(
305    var_slot: &[(usize, usize)],
306    multi_indices_with_idx: &[(usize, &MultiIndex)],
307) -> Result<(Vec<Extraction<F>>, usize), ()> {
308    let group_slots: Vec<usize> = var_slot.iter().map(|&(_, s)| s).collect();
309    let mut extractions = Vec::new();
310    let mut max_k = 0usize;
311
312    for &(result_index, mi) in multi_indices_with_idx {
313        let active = mi.active_vars();
314
315        if active.is_empty() {
316            extractions.push(Extraction {
317                result_index,
318                output_coeff_index: 0,
319                prefactor: F::one(),
320            });
321            continue;
322        }
323
324        let slot_orders: Vec<(usize, u8)> = active
325            .iter()
326            .map(|&(var, order)| {
327                let slot = var_slot.iter().find(|(v, _)| *v == var).unwrap().1;
328                (slot, order)
329            })
330            .collect();
331
332        let k: usize = slot_orders.iter().map(|&(s, q)| s * q as usize).sum();
333
334        let partitions = partitions_with_support(k, &group_slots);
335
336        let mut target_partition: Vec<(usize, usize)> = slot_orders
337            .iter()
338            .map(|&(slot, order)| (slot, order as usize))
339            .collect();
340        target_partition.sort_by_key(|&(s, _)| s);
341
342        let collision = partitions.iter().any(|p| {
343            let mut sorted = p.clone();
344            sorted.sort_by_key(|&(s, _)| s);
345            sorted != target_partition
346        });
347
348        if collision {
349            return Err(());
350        }
351
352        let prefactor = extraction_prefactor::<F>(&slot_orders);
353        max_k = max_k.max(k);
354
355        extractions.push(Extraction {
356            result_index,
357            output_coeff_index: k,
358            prefactor,
359        });
360    }
361
362    Ok((extractions, max_k))
363}
364
365/// Plan slot assignment for a single group of multi-indices that share
366/// the same set of active variables.
367fn plan_group<F: Float>(
368    active_var_set: &[usize],
369    multi_indices_with_idx: &[(usize, &MultiIndex)],
370) -> PushforwardGroup<F> {
371    let t = active_var_set.len();
372    assert!(
373        t <= PRIMES.len(),
374        "too many active variables ({}) — max supported is {}",
375        t,
376        PRIMES.len()
377    );
378
379    // Sort active variables by max order descending (highest-order gets smallest prime)
380    let mut var_max_order: Vec<(usize, u8)> = active_var_set
381        .iter()
382        .map(|&var| {
383            let max_ord = multi_indices_with_idx
384                .iter()
385                .map(|(_, mi)| mi.orders()[var])
386                .max()
387                .unwrap_or(0);
388            (var, max_ord)
389        })
390        .collect();
391    var_max_order.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
392
393    // Try prime windows: PRIMES[offset..offset+t], incrementing offset on collision
394    let max_offset = PRIMES.len() - t;
395    for offset in 0..=max_offset {
396        let var_slot: Vec<(usize, usize)> = var_max_order
397            .iter()
398            .enumerate()
399            .map(|(i, &(var, _))| (var, PRIMES[offset + i]))
400            .collect();
401
402        if let Ok((extractions, max_k)) = try_slots::<F>(&var_slot, multi_indices_with_idx) {
403            let input_coeffs: Vec<(usize, usize, F)> = var_slot
404                .iter()
405                .map(|&(var, slot)| {
406                    let mut factorial = F::one();
407                    for i in 2..=slot {
408                        factorial = factorial * F::from(i).unwrap();
409                    }
410                    (var, slot, F::one() / factorial)
411                })
412                .collect();
413
414            return PushforwardGroup {
415                jet_order: max_k + 1,
416                input_coeffs,
417                extractions,
418            };
419        }
420    }
421
422    panic!(
423        "failed to find collision-free slot assignment for active vars {:?}",
424        active_var_set
425    );
426}
427
428impl<F: Float> JetPlan<F> {
429    /// Plan jet evaluation for a set of multi-indices.
430    ///
431    /// Groups multi-indices by their active variable set, assigns collision-free
432    /// slots within each group, and precomputes extraction prefactors.
433    ///
434    /// # Panics
435    ///
436    /// Panics if `multi_indices` is empty, if any multi-index has wrong `num_vars`,
437    /// or if slot assignment fails.
438    #[must_use]
439    pub fn plan(num_vars: usize, multi_indices: &[MultiIndex]) -> Self {
440        assert!(
441            !multi_indices.is_empty(),
442            "must provide at least one multi-index"
443        );
444        for mi in multi_indices {
445            assert_eq!(
446                mi.num_vars(),
447                num_vars,
448                "multi-index num_vars ({}) != expected ({})",
449                mi.num_vars(),
450                num_vars
451            );
452        }
453
454        // Group multi-indices by their active variable set
455        type GroupEntry<'a> = (Vec<usize>, Vec<(usize, &'a MultiIndex)>);
456        let mut group_map: Vec<GroupEntry<'_>> = Vec::new();
457
458        for (i, mi) in multi_indices.iter().enumerate() {
459            let active_set = mi.active_var_set();
460            if let Some(entry) = group_map.iter_mut().find(|(set, _)| *set == active_set) {
461                entry.1.push((i, mi));
462            } else {
463                group_map.push((active_set, vec![(i, mi)]));
464            }
465        }
466
467        // Plan each group
468        let mut groups = Vec::with_capacity(group_map.len());
469        let mut max_jet_order = 1;
470
471        for (active_set, members) in &group_map {
472            let group = plan_group::<F>(active_set, members);
473            max_jet_order = max_jet_order.max(group.jet_order);
474            groups.push(group);
475        }
476
477        JetPlan {
478            max_jet_order,
479            groups,
480            multi_indices: multi_indices.to_vec(),
481        }
482    }
483
484    /// The maximum jet order across all groups.
485    #[must_use]
486    pub fn jet_order(&self) -> usize {
487        self.max_jet_order
488    }
489
490    /// The multi-indices this plan computes, in order.
491    #[must_use]
492    pub fn multi_indices(&self) -> Vec<MultiIndex> {
493        self.multi_indices.clone()
494    }
495}
496
497// ══════════════════════════════════════════════
498//  Result type
499// ══════════════════════════════════════════════
500
501/// Result of evaluating a differential operator via jet coefficients.
502#[derive(Clone, Debug)]
503pub struct DiffOpResult<F> {
504    /// Function value `u(x)`.
505    pub value: F,
506    /// Computed derivatives, in the same order as the plan's multi-indices.
507    pub derivatives: Vec<F>,
508    /// The multi-indices that were computed.
509    pub multi_indices: Vec<MultiIndex>,
510}
511
512// ══════════════════════════════════════════════
513//  Evaluation
514// ══════════════════════════════════════════════
515
516/// Evaluate a differential operator plan using `TaylorDyn` (runtime jet order).
517///
518/// Each pushforward group gets its own forward pass with only the relevant
519/// slot coefficients set. This ensures clean extraction without slot
520/// contamination from non-active variables.
521///
522/// # Panics
523///
524/// Panics if `x.len()` does not match `tape.num_inputs()`.
525pub fn eval_dyn<F: Float + TaylorArenaLocal>(
526    plan: &JetPlan<F>,
527    tape: &BytecodeTape<F>,
528    x: &[F],
529) -> DiffOpResult<F> {
530    let n = tape.num_inputs();
531    assert_eq!(
532        x.len(),
533        n,
534        "x.len() ({}) must match tape.num_inputs() ({})",
535        x.len(),
536        n
537    );
538
539    let num_results = plan.multi_indices.len();
540    let mut derivatives = vec![F::zero(); num_results];
541    // `value` is overwritten by the first non-empty group's pushforward.
542    // For the degenerate empty-plan case (no groups), zero is a defensible
543    // default — the previous `Σx[i]` placeholder looked plausible but
544    // silently returned the sum of inputs if the plan had no groups,
545    // which is a latent wrong-answer hazard.
546    let mut value = F::zero();
547
548    for group in &plan.groups {
549        let _guard = TaylorDynGuard::<F>::new(group.jet_order);
550
551        // Build inputs: only set slot coefficients for this group's active variables
552        let inputs: Vec<TaylorDyn<F>> = (0..n)
553            .map(|i| {
554                let mut coeffs = vec![F::zero(); group.jet_order];
555                coeffs[0] = x[i];
556                for &(var, slot, inv_fact) in &group.input_coeffs {
557                    if var == i && slot < group.jet_order {
558                        coeffs[slot] = inv_fact;
559                    }
560                }
561                TaylorDyn::from_coeffs(&coeffs)
562            })
563            .collect();
564
565        let mut buf = Vec::new();
566        tape.forward_tangent(&inputs, &mut buf);
567
568        let out_coeffs = buf[tape.output_index()].coeffs();
569        value = out_coeffs[0];
570
571        for extraction in &group.extractions {
572            derivatives[extraction.result_index] =
573                out_coeffs[extraction.output_coeff_index] * extraction.prefactor;
574        }
575    }
576
577    DiffOpResult {
578        value,
579        derivatives,
580        multi_indices: plan.multi_indices.clone(),
581    }
582}
583
584// ══════════════════════════════════════════════
585//  Convenience functions
586// ══════════════════════════════════════════════
587
588/// Compute a single mixed partial derivative (plans + evaluates in one call).
589///
590/// Returns `(value, derivative)` where `value = u(x)` and `derivative` is the
591/// mixed partial specified by `orders`.
592///
593/// When every order in `orders` is zero, the function returns
594/// `(u(x), u(x))` — an all-zero multi-index is the identity operator, so
595/// the "derivative" is just `u(x)` itself. This is the mathematically
596/// correct answer and not an error. An earlier version of this docstring
597/// claimed a panic; no such panic exists.
598///
599/// # Panics
600///
601/// Panics if `orders.len()` does not match `tape.num_inputs()`.
602pub fn mixed_partial<F: Float + TaylorArenaLocal>(
603    tape: &BytecodeTape<F>,
604    x: &[F],
605    orders: &[u8],
606) -> (F, F) {
607    // `eval_dyn` will assert `x.len() == tape.num_inputs()`, but a mismatch
608    // between `orders.len()` and `tape.num_inputs()` silently generates a
609    // MultiIndex of the wrong length, which then indexes past the tape's
610    // input count during planning and yields a garbage partial derivative
611    // without panicking. Catch the shape mismatch up front.
612    assert_eq!(
613        orders.len(),
614        tape.num_inputs(),
615        "mixed_partial: orders.len() must equal tape.num_inputs() \
616         (got orders.len()={}, tape.num_inputs()={})",
617        orders.len(),
618        tape.num_inputs(),
619    );
620    let mi = MultiIndex::new(orders);
621    let plan = JetPlan::plan(orders.len(), &[mi]);
622    let result = eval_dyn(&plan, tape, x);
623    (result.value, result.derivatives[0])
624}
625
626/// Compute the full Hessian (all second-order partial derivatives).
627///
628/// Returns `(value, gradient, hessian)` where:
629/// - `gradient[i]` = `∂u/∂x_i`
630/// - `hessian[i][j]` = `∂²u/(∂x_i ∂x_j)`
631///
632/// Each derivative requires its own pushforward group, so this performs
633/// `n + n*(n+1)/2` forward passes. For large n, consider using
634/// `tape.hessian()` instead.
635///
636/// # Panics
637///
638/// Panics if `x.len()` does not match `tape.num_inputs()`.
639// Index variables i, j are used to construct MultiIndex values, index into `orders`,
640// and fill both triangles of the symmetric Hessian matrix — iterators would obscure the
641// mathematical indexing logic.
642#[allow(clippy::needless_range_loop)]
643pub fn hessian<F: Float + TaylorArenaLocal>(
644    tape: &BytecodeTape<F>,
645    x: &[F],
646) -> (F, Vec<F>, Vec<Vec<F>>) {
647    let n = tape.num_inputs();
648    assert_eq!(x.len(), n, "x.len() must match tape.num_inputs()");
649
650    // Constant-output tape (n == 0): there are no derivatives. Recover the
651    // constant via the primal forward pass and return an empty gradient and
652    // Hessian — matching `BytecodeTape::hessian` — instead of panicking in
653    // `JetPlan::plan`, which rejects the empty multi-index list this would build.
654    if n == 0 {
655        let mut values_buf = Vec::new();
656        tape.forward_into(&[], &mut values_buf);
657        let value = values_buf
658            .get(tape.output_index())
659            .copied()
660            .unwrap_or_else(F::zero);
661        return (value, Vec::new(), Vec::new());
662    }
663
664    let mut indices = Vec::with_capacity(n + n * (n + 1) / 2);
665
666    // First-order partials
667    for i in 0..n {
668        indices.push(MultiIndex::partial(n, i));
669    }
670
671    // Second-order: diagonal and upper-triangle
672    for i in 0..n {
673        for j in i..n {
674            let mut orders = vec![0u8; n];
675            if i == j {
676                orders[i] = 2;
677            } else {
678                orders[i] = 1;
679                orders[j] = 1;
680            }
681            indices.push(MultiIndex::new(&orders));
682        }
683    }
684
685    let plan = JetPlan::plan(n, &indices);
686    let result = eval_dyn(&plan, tape, x);
687
688    let gradient: Vec<F> = result.derivatives[..n].to_vec();
689
690    let mut hess = vec![vec![F::zero(); n]; n];
691    let mut idx = n;
692    for i in 0..n {
693        for j in i..n {
694            let val = result.derivatives[idx];
695            hess[i][j] = val;
696            hess[j][i] = val;
697            idx += 1;
698        }
699    }
700
701    (result.value, gradient, hess)
702}
703
704// ══════════════════════════════════════════════
705//  DiffOp: differential operator type
706// ══════════════════════════════════════════════
707
708/// A linear differential operator `L = Σ C_α D^α`.
709///
710/// Each term is a `(coefficient, multi-index)` pair. The operator can be
711/// evaluated exactly via [`DiffOp::eval`] using [`JetPlan`], or used to build
712/// a [`SparseSamplingDistribution`] for stochastic estimation.
713///
714/// # Examples
715///
716/// ```ignore
717/// use echidna::diffop::DiffOp;
718///
719/// // Laplacian in 3 variables: ∂²/∂x₀² + ∂²/∂x₁² + ∂²/∂x₂²
720/// let lap = DiffOp::laplacian(3);
721///
722/// // Biharmonic: ∂⁴/∂x₀⁴ + ∂⁴/∂x₁⁴ + ∂⁴/∂x₂⁴
723/// let bih = DiffOp::biharmonic(3);
724/// ```
725#[derive(Clone, Debug)]
726pub struct DiffOp<F> {
727    terms: Vec<(F, MultiIndex)>,
728    num_vars: usize,
729}
730
731impl<F: Float> DiffOp<F> {
732    /// Create a differential operator from explicit `(coefficient, multi-index)` pairs.
733    ///
734    /// # Panics
735    ///
736    /// Panics if `terms` is empty or any multi-index has wrong `num_vars`.
737    #[must_use]
738    pub fn new(num_vars: usize, terms: Vec<(F, MultiIndex)>) -> Self {
739        assert!(!terms.is_empty(), "DiffOp must have at least one term");
740        for (_, mi) in &terms {
741            assert_eq!(
742                mi.num_vars(),
743                num_vars,
744                "multi-index num_vars ({}) != expected ({})",
745                mi.num_vars(),
746                num_vars
747            );
748        }
749        DiffOp { terms, num_vars }
750    }
751
752    /// Create a differential operator from raw order slices.
753    ///
754    /// Each entry is `(coefficient, orders_slice)`.
755    pub fn from_orders(num_vars: usize, terms: &[(F, &[u8])]) -> Self {
756        let terms: Vec<(F, MultiIndex)> = terms
757            .iter()
758            .map(|&(c, orders)| (c, MultiIndex::new(orders)))
759            .collect();
760        Self::new(num_vars, terms)
761    }
762
763    /// Laplacian: `Σ_j ∂²/∂x_j²`.
764    #[must_use]
765    pub fn laplacian(n: usize) -> Self {
766        let terms = (0..n)
767            .map(|j| (F::one(), MultiIndex::diagonal(n, j, 2)))
768            .collect();
769        DiffOp { terms, num_vars: n }
770    }
771
772    /// Biharmonic operator: `Δ² = (Σ_j ∂²/∂x_j²)²`.
773    ///
774    /// Expands to `Σ_j ∂⁴/∂x_j⁴ + 2 Σ_{j<k} ∂⁴/(∂x_j² ∂x_k²)`.
775    ///
776    /// For n=1, equivalent to `diagonal(1, 4)`. For n≥2, includes cross terms.
777    /// Evaluation via [`eval`] uses exact jet arithmetic. Stochastic estimation
778    /// via `stde_sparse` requires importance sampling (full deterministic sampling
779    /// is biased when coefficients are non-uniform).
780    #[must_use]
781    pub fn biharmonic(n: usize) -> Self {
782        let two = F::one() + F::one();
783        let mut terms: Vec<(F, MultiIndex)> = (0..n)
784            .map(|j| (F::one(), MultiIndex::diagonal(n, j, 4)))
785            .collect();
786        for j in 0..n {
787            for k in (j + 1)..n {
788                let mut orders = vec![0u8; n];
789                orders[j] = 2;
790                orders[k] = 2;
791                terms.push((two, MultiIndex::new(&orders)));
792            }
793        }
794        DiffOp { terms, num_vars: n }
795    }
796
797    /// k-th order diagonal: `Σ_j ∂^k/∂x_j^k`.
798    #[must_use]
799    pub fn diagonal(n: usize, k: u8) -> Self {
800        assert!(k >= 1, "diagonal order must be >= 1");
801        let terms = (0..n)
802            .map(|j| (F::one(), MultiIndex::diagonal(n, j, k)))
803            .collect();
804        DiffOp { terms, num_vars: n }
805    }
806
807    /// The terms of the operator.
808    #[must_use]
809    pub fn terms(&self) -> &[(F, MultiIndex)] {
810        &self.terms
811    }
812
813    /// Number of variables.
814    #[must_use]
815    pub fn num_vars(&self) -> usize {
816        self.num_vars
817    }
818
819    /// Maximum total order across all terms.
820    #[must_use]
821    pub fn order(&self) -> usize {
822        self.terms
823            .iter()
824            .map(|(_, mi)| mi.total_order())
825            .max()
826            .unwrap_or(0)
827    }
828
829    /// True if every term has exactly one active variable (no mixed partials).
830    #[must_use]
831    pub fn is_diagonal(&self) -> bool {
832        self.terms.iter().all(|(_, mi)| mi.active_vars().len() <= 1)
833    }
834
835    /// Split an inhomogeneous operator into groups of the same total order.
836    ///
837    /// Returns a vector of `DiffOp`, each containing terms with the same
838    /// total order, sorted by increasing order.
839    #[must_use]
840    pub fn split_by_order(&self) -> Vec<DiffOp<F>> {
841        let mut order_map: Vec<(usize, Vec<(F, MultiIndex)>)> = Vec::new();
842        for (c, mi) in &self.terms {
843            let ord = mi.total_order();
844            if let Some(entry) = order_map.iter_mut().find(|(o, _)| *o == ord) {
845                entry.1.push((*c, mi.clone()));
846            } else {
847                order_map.push((ord, vec![(*c, mi.clone())]));
848            }
849        }
850        order_map.sort_by_key(|(o, _)| *o);
851        order_map
852            .into_iter()
853            .map(|(_, terms)| DiffOp {
854                terms,
855                num_vars: self.num_vars,
856            })
857            .collect()
858    }
859}
860
861impl<F: Float + TaylorArenaLocal> DiffOp<F> {
862    /// Exact evaluation: compute `Lu(x)` via [`JetPlan`].
863    ///
864    /// Returns `(value, operator_value)`.
865    pub fn eval(&self, tape: &BytecodeTape<F>, x: &[F]) -> (F, F) {
866        let multi_indices: Vec<MultiIndex> = self.terms.iter().map(|(_, mi)| mi.clone()).collect();
867        let plan = JetPlan::plan(self.num_vars, &multi_indices);
868        let result = eval_dyn(&plan, tape, x);
869
870        let mut op_value = F::zero();
871        for (i, (c, _)) in self.terms.iter().enumerate() {
872            op_value = op_value + *c * result.derivatives[i];
873        }
874
875        (result.value, op_value)
876    }
877
878    /// Build a [`SparseSamplingDistribution`] for stochastic estimation.
879    ///
880    /// Requires all terms to have the same total order k (homogeneous operator).
881    /// Use [`split_by_order`](DiffOp::split_by_order) to decompose inhomogeneous
882    /// operators first.
883    ///
884    /// # Panics
885    ///
886    /// Panics if the operator is not homogeneous (mixed total orders).
887    #[must_use]
888    pub fn sparse_distribution(&self) -> SparseSamplingDistribution<F> {
889        let k = self.terms[0].1.total_order();
890        for (_, mi) in &self.terms {
891            assert_eq!(
892                mi.total_order(),
893                k,
894                "sparse_distribution requires homogeneous operator: \
895                 found order {} and order {}",
896                k,
897                mi.total_order()
898            );
899        }
900
901        let mut entries = Vec::with_capacity(self.terms.len());
902        let mut cumulative = F::zero();
903
904        for (coeff, mi) in &self.terms {
905            let abs_c = coeff.abs();
906            cumulative = cumulative + abs_c;
907
908            // Use plan_group to get collision-free slot assignments
909            let active_set = mi.active_vars().iter().map(|&(v, _)| v).collect::<Vec<_>>();
910            let group = plan_group::<F>(&active_set, &[(0, mi)]);
911
912            // There should be exactly one extraction
913            let extraction = &group.extractions[0];
914
915            entries.push(SparseJetEntry {
916                cumulative_weight: cumulative,
917                input_coeffs: group.input_coeffs.clone(),
918                output_coeff_index: extraction.output_coeff_index,
919                extraction_prefactor: extraction.prefactor,
920                sign: coeff.signum(),
921            });
922        }
923
924        SparseSamplingDistribution {
925            jet_order: entries
926                .iter()
927                .map(|e| e.output_coeff_index)
928                .max()
929                .unwrap_or(1),
930            entries,
931            total_weight: cumulative,
932        }
933    }
934}
935
936// ══════════════════════════════════════════════
937//  SparseSamplingDistribution
938// ══════════════════════════════════════════════
939
940/// Pre-computed discrete distribution over sparse k-jets for STDE.
941///
942/// Built from a homogeneous-order [`DiffOp`] via [`DiffOp::sparse_distribution`].
943/// The normalization constant `Z = Σ|C_α|` quantifies estimator quality —
944/// larger Z means more samples needed for a given accuracy.
945#[derive(Clone, Debug)]
946pub struct SparseSamplingDistribution<F> {
947    jet_order: usize,
948    entries: Vec<SparseJetEntry<F>>,
949    total_weight: F,
950}
951
952/// A single entry in the sparse sampling distribution.
953#[derive(Clone, Debug)]
954struct SparseJetEntry<F> {
955    cumulative_weight: F,
956    /// Slot assignments: `(var_index, slot, 1/slot!)`.
957    input_coeffs: Vec<(usize, usize, F)>,
958    /// Which output coefficient to read.
959    output_coeff_index: usize,
960    /// Multiply `coeffs[output_coeff_index]` by this to get the derivative.
961    extraction_prefactor: F,
962    /// `sign(C_α)` — the sign of the operator coefficient.
963    sign: F,
964}
965
966/// Read-only view of a [`SparseJetEntry`] for use by [`stde_sparse`](crate::stde::stde_sparse).
967pub struct SparseJetEntryRef<'a, F> {
968    entry: &'a SparseJetEntry<F>,
969}
970
971impl<'a, F: Float> SparseJetEntryRef<'a, F> {
972    /// Slot assignments: `(var_index, slot, 1/slot!)`.
973    #[must_use]
974    pub fn input_coeffs(&self) -> &[(usize, usize, F)] {
975        &self.entry.input_coeffs
976    }
977
978    /// Which output coefficient to read.
979    #[must_use]
980    pub fn output_coeff_index(&self) -> usize {
981        self.entry.output_coeff_index
982    }
983
984    /// Extraction prefactor from the Faà di Bruno formula.
985    #[must_use]
986    pub fn extraction_prefactor(&self) -> F {
987        self.entry.extraction_prefactor
988    }
989
990    /// Sign of the operator coefficient `C_α`.
991    #[must_use]
992    pub fn sign(&self) -> F {
993        self.entry.sign
994    }
995}
996
997impl<F: Float> SparseSamplingDistribution<F> {
998    /// Inverse-CDF sampling: given `u ~ Uniform(0, 1)`, return entry index.
999    ///
1000    /// Caller generates the uniform variate (no `rand` dependency).
1001    pub fn sample_index(&self, uniform_01: F) -> usize {
1002        let target = uniform_01 * self.total_weight;
1003        // Binary search on cumulative weights
1004        let mut lo = 0;
1005        let mut hi = self.entries.len();
1006        while lo < hi {
1007            let mid = lo + (hi - lo) / 2;
1008            if self.entries[mid].cumulative_weight <= target {
1009                lo = mid + 1;
1010            } else {
1011                hi = mid;
1012            }
1013        }
1014        lo.min(self.entries.len() - 1)
1015    }
1016
1017    /// The normalization constant `Z = Σ|C_α|`.
1018    pub fn normalization(&self) -> F {
1019        self.total_weight
1020    }
1021
1022    /// Number of entries in the distribution.
1023    pub fn len(&self) -> usize {
1024        self.entries.len()
1025    }
1026
1027    /// Whether the distribution has no entries.
1028    pub fn is_empty(&self) -> bool {
1029        self.entries.is_empty()
1030    }
1031
1032    /// Maximum jet order needed (the output coefficient index to read).
1033    pub fn jet_order(&self) -> usize {
1034        self.jet_order
1035    }
1036
1037    /// Access entry by index (for use by [`stde_sparse`](crate::stde::stde_sparse)).
1038    pub fn entry(&self, index: usize) -> SparseJetEntryRef<'_, F> {
1039        SparseJetEntryRef {
1040            entry: &self.entries[index],
1041        }
1042    }
1043}