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}