gam_math/jet_partitions.rs
1//! Bitmask-coefficient multi-directional jets used by marginal-slope and
2//! latent-survival row kernels.
3//!
4//! The layout stores one coefficient per direction mask. The calculus itself
5//! lives in [`crate::jet_algebra`]: that module owns the layout-agnostic
6//! Leibniz / Faà di Bruno *combinatorics* once, and the scalar (`n_dirs <= 1`)
7//! path here still routes through it so a fix to the rule is a fix to both
8//! representations.
9//!
10//! ## Why this layout is special (and how the hot path exploits it)
11//!
12//! Each direction is seeded *linearly* (one first-derivative slot), so every
13//! direction variable squares to zero. The coefficients therefore form the
14//! commutative **multilinear / set-function algebra**: `coeffs[mask]` is the
15//! coefficient of `Π_{i ∈ mask} ε_i`. In that algebra two facts collapse the
16//! generic combinatorial walkers into tight branch-free arithmetic:
17//!
18//! * **`mul` is the subset (zeta-style) convolution**
19//! `out[mask] = Σ_{sub ⊆ mask} a[sub] · b[mask \ sub]`.
20//! The shared `leibniz_product` walker rebuilds two `SlotBuf`s and folds bit
21//! lists back into masks (`mask_of`) *per subset*; here we enumerate the
22//! submasks of `mask` directly — `mask \ sub == mask ^ sub` because
23//! `sub ⊆ mask` — in the **same ascending order** the walker used, so the
24//! floating-point accumulation is bit-for-bit identical while every
25//! `SlotBuf`/closure/`mask_of` allocation and indirection disappears
26//! (`3^K` pure FMAs, no heap, no `dyn`).
27//!
28//! * **`compose_unary` is the truncated Faà di Bruno composition**, computed
29//! here by the exact **truncated-Taylor reassociation** rather than a direct
30//! set-partition sum. Let `v` be the non-constant part of `self`
31//! (`v[0] = 0`, `v[mask] = self[mask]`) and let `v^{⊛k}` be the `k`-fold
32//! *subset convolution* (the multilinear power). The ordered-tuple identity
33//! `v^{⊛k}[mask] = k! · Σ_{π ⊢ mask, |π| = k} Π_{B ∈ π} v[B]` turns the
34//! set-partition sum into a degree-4 polynomial in `v`:
35//!
36//! ```text
37//! f(self)[mask] = Σ_{k=0}^{4} (f^{(k)} / k!) · v^{⊛k}[mask] (mask ≠ 0)
38//! f(self)[0] = f^{(0)}
39//! ```
40//!
41//! so a composition is just **three subset convolutions** (`v²`, `v³=v²⊛v`,
42//! `v⁴=v²⊛v²` — the Motzkin floor for a quartic) plus a five-term combine.
43//! That is ~3× fewer FLOPs than the per-mask partition gather; each
44//! convolution is a four-lane compensated dot product (Ogita–Rump–Oishi
45//! Dot2, FMA-split products + TwoSum carry) so the result is computed in
46//! ~double the working precision and the rounding of `v²` cannot compound
47//! through `v³`/`v⁴`; the final per-mask combine is Neumaier-compensated and
48//! `wide::f64x4`-vectorised; and the whole call runs on reused thread-local
49//! scratch with no per-call heap traffic. The reassociation is algebraically
50//! exact; accuracy-vs-truth (a double-double oracle) is the test gate and is
51//! strictly ≤ the old partition sum's error (see `tests`).
52use std::cell::RefCell;
53use std::sync::atomic::{AtomicU64, Ordering};
54use wide::f64x4;
55
56pub static COMPOSE_UNARY_CALLS: AtomicU64 = AtomicU64::new(0);
57pub static MUL_CALLS: AtomicU64 = AtomicU64::new(0);
58
59/// Length of the unary derivative stack `[f, f', f'', f''', f'''']`: composition
60/// is exact through order 4, partitions into `>= 5` blocks are truncated.
61const DERIVS: usize = 5;
62
63#[derive(Clone)]
64pub struct MultiDirJet {
65 pub coeffs: Vec<f64>,
66}
67
68impl MultiDirJet {
69 pub fn zero(n_dirs: usize) -> Self {
70 Self {
71 coeffs: vec![0.0; 1usize << n_dirs],
72 }
73 }
74
75 pub fn constant(n_dirs: usize, value: f64) -> Self {
76 let mut out = Self::zero(n_dirs);
77 out.coeffs[0] = value;
78 out
79 }
80
81 pub fn linear(n_dirs: usize, base: f64, first: &[f64]) -> Self {
82 let mut out = Self::constant(n_dirs, base);
83 for (idx, &value) in first.iter().take(n_dirs).enumerate() {
84 out.coeffs[1usize << idx] = value;
85 }
86 out
87 }
88
89 pub fn with_coeffs(n_dirs: usize, coeffs: &[(usize, f64)]) -> Self {
90 let mut out = Self::zero(n_dirs);
91 for &(mask, value) in coeffs {
92 if mask < out.coeffs.len() {
93 out.coeffs[mask] = value;
94 }
95 }
96 out
97 }
98
99 #[inline]
100 pub fn coeff(&self, mask: usize) -> f64 {
101 self.coeffs[mask]
102 }
103
104 pub fn add(&self, other: &Self) -> Self {
105 Self {
106 coeffs: self
107 .coeffs
108 .iter()
109 .zip(other.coeffs.iter())
110 .map(|(lhs, rhs)| lhs + rhs)
111 .collect(),
112 }
113 }
114
115 pub fn scale(&self, scalar: f64) -> Self {
116 Self {
117 coeffs: self.coeffs.iter().map(|value| scalar * value).collect(),
118 }
119 }
120
121 /// Subset-convolution product `out[mask] = Σ_{sub ⊆ mask} a[sub]·b[mask^sub]`.
122 ///
123 /// Bit-identical to the shared [`crate::jet_algebra::leibniz_product`] walker
124 /// (the submasks are enumerated in the same ascending order — the walker's
125 /// compacted subset index is a monotone bit-deposit of the submask) while
126 /// dropping its per-subset `SlotBuf`/closure/`mask_of` overhead. The scalar
127 /// `n_dirs == 0` case keeps the shared walker live as its reference.
128 pub fn mul(&self, other: &Self) -> Self {
129 MUL_CALLS.fetch_add(1, Ordering::Relaxed);
130 let count = self.coeffs.len();
131 if count <= 1 {
132 return self.mul_reference(other);
133 }
134 let a = &self.coeffs;
135 let b = &other.coeffs;
136 // Both operands carry the same direction set, so `b` is `count` long too.
137 // With that established once, every `a[sub]`/`b[mask ^ sub]` below is
138 // provably in bounds (`sub, mask ^ sub ⊆ mask < count`), so the inner
139 // submask walk can drop its per-load bounds checks.
140 assert_eq!(
141 b.len(),
142 count,
143 "MultiDirJet::mul operands must share n_dirs"
144 );
145 let mut out = vec![0.0; count];
146 for (mask, slot) in out.iter_mut().enumerate() {
147 // Walk every submask of `mask` in ascending numeric order — the same
148 // order `leibniz_product` accumulates — via the classic gap-fill
149 // increment `next = ((sub | !mask) + 1) & mask`.
150 let mut acc = 0.0;
151 let mut sub = 0usize;
152 // SAFETY: `sub ⊆ mask < count` and `mask ^ sub ⊆ mask < count`, and
153 // both `a` and `b` are `count` long (asserted above).
154 unsafe {
155 loop {
156 acc += *a.get_unchecked(sub) * *b.get_unchecked(mask ^ sub);
157 if sub == mask {
158 break;
159 }
160 sub = (sub | !mask).wrapping_add(1) & mask;
161 }
162 }
163 *slot = acc;
164 }
165 Self { coeffs: out }
166 }
167
168 /// The pre-#perf shared-walker product, retained verbatim as the scalar-case
169 /// implementation and as the bit-exact reference for `mul`.
170 fn mul_reference(&self, other: &Self) -> Self {
171 let count = self.coeffs.len();
172 let mut out = vec![0.0; count];
173 for (mask, slot) in out.iter_mut().enumerate() {
174 let bits = bit_positions(mask);
175 *slot = crate::jet_algebra::leibniz_product(
176 bits.as_slice(),
177 |t| self.coeffs[mask_of(t)],
178 |c| other.coeffs[mask_of(c)],
179 );
180 }
181 Self { coeffs: out }
182 }
183
184 /// Exact (order-4 truncated) unary composition `f(self)` from the Taylor
185 /// stack `[f, f', f'', f''', f'''']` at `self.coeff(0)`.
186 ///
187 /// Computed by the truncated-Taylor reassociation (see the module note):
188 /// `f(self) = Σ_{k=0}^{4} (f^{(k)}/k!)·v^{⊛k}` with `v` the non-constant
189 /// part of `self`. The three subset-convolution powers `v²`, `v³`, `v⁴`
190 /// are compensated (Dot2) and the per-mask combine is Neumaier-compensated
191 /// and vectorised, so the result is *more* accurate vs. the true
192 /// real-arithmetic value than the prior naive partition sum (proven against
193 /// a double-double oracle in `tests`). The scalar `n_dirs == 0` case keeps
194 /// the shared Faà di Bruno walker live as its reference.
195 pub fn compose_unary(&self, derivs: [f64; DERIVS]) -> Self {
196 COMPOSE_UNARY_CALLS.fetch_add(1, Ordering::Relaxed);
197 let count = self.coeffs.len();
198 if count <= 1 {
199 return <Self as crate::jet_algebra::JetAlgebra<DERIVS>>::compose_unary(self, derivs);
200 }
201 let mut out = vec![0.0; count];
202 COMPOSE_SCRATCH.with(|cell| {
203 let mut buf = cell.borrow_mut();
204 buf.clear();
205 buf.resize(4 * count, 0.0);
206 compose_unary_coefficients_into(&self.coeffs, derivs, buf.as_mut_slice(), &mut out);
207 });
208 Self { coeffs: out }
209 }
210}
211
212/// Compose a four-slot multilinear coefficient table through one unary
213/// derivative stack without constructing an owned [`MultiDirJet`].
214///
215/// This is the allocation-free fixed-width entry point to the exact same
216/// compensated truncated-Taylor/subset-convolution schedule used by
217/// [`MultiDirJet::compose_unary`]. Slot-mask `m` in the returned array is the
218/// derivative for the corresponding subset of the four input slots. It exists
219/// for packed analytic primitives that already own their normalized derivative
220/// table and need the shared, double-double-graded composition arithmetic
221/// without adopting the oracle's heap-backed storage layout.
222#[inline]
223pub fn compose_unary_four_slot_coefficients(
224 coefficients: [f64; 16],
225 derivs: [f64; 5],
226) -> [f64; 16] {
227 let mut scratch = [0.0f64; 64];
228 let mut out = [0.0f64; 16];
229 compose_unary_coefficients_into(&coefficients, derivs, &mut scratch, &mut out);
230 out
231}
232
233thread_local! {
234 /// Reused composition scratch (`4·count` f64s: v, v², v³, v⁴). Sized up on
235 /// demand and never freed, so a steady-state `compose_unary` does zero heap
236 /// work beyond the owned output `Vec`.
237 static COMPOSE_SCRATCH: RefCell<Vec<f64>> = const { RefCell::new(Vec::new()) };
238}
239
240#[inline]
241fn compose_unary_coefficients_into(
242 coefficients: &[f64],
243 derivs: [f64; DERIVS],
244 scratch: &mut [f64],
245 out: &mut [f64],
246) {
247 let count = coefficients.len();
248 assert!(count > 1 && count.is_power_of_two());
249 assert!(scratch.len() == 4 * count && out.len() == count);
250 let (vbuf, rest) = scratch.split_at_mut(count);
251 let (p2, rest) = rest.split_at_mut(count);
252 let (p3, p4) = rest.split_at_mut(count);
253
254 // v is the non-constant part of the input. The k=0 Taylor term owns the
255 // constant coefficient, so the zero mask must not enter any power.
256 vbuf.copy_from_slice(coefficients);
257 vbuf[0] = 0.0;
258
259 // These three compensated subset convolutions are the unique Motzkin-floor
260 // schedule for the degree-four truncated Taylor polynomial.
261 subset_conv_into(vbuf, vbuf, p2, 2);
262 subset_conv_into(p2, vbuf, p3, 3);
263 subset_conv_into(p2, p2, p4, 4);
264 // `1/k!` undoes the ordered-tuple overcount of each k-fold subset power
265 // relative to the unordered set-partition sum.
266 let coefficients_by_order = [
267 derivs[1],
268 derivs[2] * 0.5,
269 derivs[3] * (1.0 / 6.0),
270 derivs[4] * (1.0 / 24.0),
271 ];
272 combine_powers(vbuf, p2, p3, p4, coefficients_by_order, out);
273 out[0] = derivs[0];
274}
275
276/// Branchless TwoSum: returns `(s, e)` with `s = fl(a+b)` and `a+b = s+e`
277/// exactly (Knuth/Møller). Used by the compensated convolution and combine.
278#[inline(always)]
279fn two_sum(a: f64, b: f64) -> (f64, f64) {
280 let s = a + b;
281 let bb = s - a;
282 let e = (a - (s - bb)) + (b - bb);
283 (s, e)
284}
285
286/// Subset (zeta-style) convolution `out[mask] = Σ_{sub ⊆ mask} a[sub]·b[mask^sub]`,
287/// evaluated as a **compensated dot product** (Ogita–Rump–Oishi Dot2): each
288/// product is split into head + FMA error (`mul_add`) and the running sum
289/// carries a TwoSum error term, so the result is accurate as if computed in
290/// ~twice the working precision. This stops the rounding of `v²` from
291/// compounding through `v³`/`v⁴`, which a single-rounding accumulation does
292/// not. Output masks with `popcount < min_pop` are left at zero: the
293/// multilinear power `v^{⊛k}` vanishes below popcount `k`, so the prune is exact
294/// and skips the low-order masks entirely.
295#[inline]
296fn subset_conv_into(a: &[f64], b: &[f64], out: &mut [f64], min_pop: u32) {
297 // SAFETY invariant for the `get_unchecked` loads below: every index this
298 // kernel reads is `< out.len()`, and the caller passes `a`/`b` at least as
299 // long as `out` (in `compose_unary` all three are the same `count`-length
300 // slices carved from one scratch buffer). Concretely `mask < out.len()`
301 // (loop bound), and each submask satisfies `sub ⊆ mask` so `sub ≤ mask` and
302 // `mask ^ sub ⊆ mask` so `mask ^ sub ≤ mask` — both `< out.len() ≤ a.len(),
303 // b.len()`. The `assert!` below pins the length precondition (one check per
304 // call, negligible next to the walk) so the `get_unchecked` below is sound;
305 // the bounds checks LLVM cannot elide (the indices are data-dependent) are a
306 // real per-step cost across the `3^K` submask walk (×3 convolutions per
307 // compose), so eliding them via get_unchecked is a measured ~20% at the
308 // marginal-slope direction counts.
309 assert!(a.len() >= out.len() && b.len() >= out.len());
310 for (mask, slot) in out.iter_mut().enumerate() {
311 if (mask as u64).count_ones() < min_pop {
312 *slot = 0.0;
313 continue;
314 }
315 // Descending submask enumeration `sub = (sub-1) & mask`, terminating
316 // after `sub == 0` (the classic Gosper-style submask walk). The Dot2 is
317 // spread across FOUR independent named accumulators (a 4-way unroll) so
318 // the FMA/TwoSum latency chains overlap — the loop becomes throughput-
319 // rather than latency-bound — then the lanes are merged with a final
320 // compensated reduction. Every non-pruned mask has popcount ≥ 2, so its
321 // `2^popcount` submask count is a multiple of 4 and the unroll is exact
322 // (the all-zero submask always lands in the fourth lane). Reassociation
323 // only; the value is the same real sum, in ~double the working precision.
324 #[inline(always)]
325 fn dot2_step(s: &mut f64, c: &mut f64, x: f64, y: f64) {
326 let prod = x * y;
327 let prod_err = x.mul_add(y, -prod); // exact: prod + prod_err == x*y
328 let (t, sum_err) = two_sum(*s, prod);
329 *s = t;
330 *c += prod_err + sum_err;
331 }
332 let (mut s0, mut s1, mut s2, mut s3) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
333 let (mut c0, mut c1, mut c2, mut c3) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
334 let mut sub = mask;
335 // SAFETY: see the invariant comment at the top of the function — `sub`
336 // and `mask ^ sub` are both submasks of `mask < out.len()`, hence in
337 // bounds for `a`/`b` (each ≥ `out.len()` long).
338 unsafe {
339 loop {
340 dot2_step(
341 &mut s0,
342 &mut c0,
343 *a.get_unchecked(sub),
344 *b.get_unchecked(mask ^ sub),
345 );
346 sub = (sub - 1) & mask;
347 dot2_step(
348 &mut s1,
349 &mut c1,
350 *a.get_unchecked(sub),
351 *b.get_unchecked(mask ^ sub),
352 );
353 sub = (sub - 1) & mask;
354 dot2_step(
355 &mut s2,
356 &mut c2,
357 *a.get_unchecked(sub),
358 *b.get_unchecked(mask ^ sub),
359 );
360 sub = (sub - 1) & mask;
361 dot2_step(
362 &mut s3,
363 &mut c3,
364 *a.get_unchecked(sub),
365 *b.get_unchecked(mask ^ sub),
366 );
367 if sub == 0 {
368 break;
369 }
370 sub = (sub - 1) & mask;
371 }
372 }
373 // Merge the four lanes, compensated.
374 let (s01, e01) = two_sum(s0, s1);
375 let (s23, e23) = two_sum(s2, s3);
376 let (total, etot) = two_sum(s01, s23);
377 *slot = total + (etot + e01 + e23 + c0 + c1 + c2 + c3);
378 }
379}
380
381/// `out[mask] = c[0]·p1 + c[1]·p2 + c[2]·p3 + c[3]·p4` for `mask ≥ 1`, with a
382/// Neumaier-compensated four-term accumulation (the powers span growing
383/// magnitudes, so the compensation recovers the bits a naive `+=` would drop)
384/// and a `wide::f64x4` body over four masks at a time. `out[0]` is overwritten
385/// by the caller with the value channel.
386#[inline]
387fn combine_powers(p1: &[f64], p2: &[f64], p3: &[f64], p4: &[f64], c: [f64; 4], out: &mut [f64]) {
388 let n = out.len();
389 let (c1, c2, c3, c4) = (c[0], c[1], c[2], c[3]);
390 let (v1, v2, v3, v4) = (
391 f64x4::splat(c1),
392 f64x4::splat(c2),
393 f64x4::splat(c3),
394 f64x4::splat(c4),
395 );
396 let mut mask = 0usize;
397 // Vector body: four contiguous masks per step. Neumaier compensation is
398 // applied lane-wise; pick the larger magnitude to subtract first.
399 while mask + 4 <= n {
400 let load = |p: &[f64]| f64x4::new([p[mask], p[mask + 1], p[mask + 2], p[mask + 3]]);
401 let mut s = v1 * load(p1);
402 let mut comp = f64x4::splat(0.0);
403 for (cv, pv) in [(v2, p2), (v3, p3), (v4, p4)] {
404 let term = cv * load(pv);
405 let t = s + term;
406 let big_s = s.abs().simd_ge(term.abs());
407 let lost = big_s.blend((s - t) + term, (term - t) + s);
408 comp += lost;
409 s = t;
410 }
411 let res = s + comp;
412 out[mask..mask + 4].copy_from_slice(&res.to_array());
413 mask += 4;
414 }
415 // Scalar tail (and the small-K path where `n < 4`).
416 while mask < n {
417 let mut s = c1 * p1[mask];
418 let mut comp = 0.0f64;
419 for (cv, pv) in [(c2, p2), (c3, p3), (c4, p4)] {
420 let term = cv * pv[mask];
421 let (t, e) = two_sum(s, term);
422 comp += e;
423 s = t;
424 }
425 out[mask] = s + comp;
426 mask += 1;
427 }
428}
429
430impl crate::jet_algebra::JetAlgebra<DERIVS> for MultiDirJet {
431 #[inline]
432 fn derivative(&self, slots: &[usize]) -> f64 {
433 self.coeffs[mask_of(slots)]
434 }
435
436 fn map_derivatives<F>(&self, mut f: F) -> Self
437 where
438 F: FnMut(&[usize]) -> f64,
439 {
440 let mut out = vec![0.0; self.coeffs.len()];
441 for (mask, value) in out.iter_mut().enumerate() {
442 let bits = bit_positions(mask);
443 *value = f(bits.as_slice());
444 }
445 Self { coeffs: out }
446 }
447}
448
449/// The set-bit positions of `mask`, low to high — the differentiation slots of
450/// that coefficient.
451fn bit_positions(mask: usize) -> crate::jet_algebra::SlotBuf {
452 let mut out = crate::jet_algebra::SlotBuf::new();
453 let mut m = mask;
454 while m != 0 {
455 let bit = m.trailing_zeros() as usize;
456 out.push_slot(bit);
457 m &= m - 1;
458 }
459 out
460}
461
462/// Combine a slot-group (list of bit positions) back into a sub-mask.
463fn mask_of(slots: &[usize]) -> usize {
464 slots.iter().fold(0usize, |acc, &b| acc | (1usize << b))
465}
466
467// #932-2 cutover: `MultiDirJet::bilinear` (the 4-coeff `[base, d1, d2, d12]`
468// constructor) and `MultiDirJet::sub` are consumed ONLY by the now test-only hand
469// survival directional/bidirectional oracle (the production flex jet path uses the
470// `flex_jet` runtime jet algebra, not `MultiDirJet`). After the #1521 crate split
471// moved `MultiDirJet` into `gam-math`, those oracle tests live in the dependent
472// `gam` crate, where a `#[cfg(test)]` gate in *this* crate is inactive — so the
473// methods must be plain `pub` inherent methods to be reachable cross-crate. They
474// carry no dead-code cost because `pub` items are part of the crate's public API.
475// Bodies are byte-identical to their former gated form.
476impl MultiDirJet {
477 pub fn bilinear(base: f64, d1: f64, d2: f64, d12: f64) -> Self {
478 Self {
479 coeffs: vec![base, d1, d2, d12],
480 }
481 }
482
483 pub fn sub(&self, other: &Self) -> Self {
484 Self {
485 coeffs: self
486 .coeffs
487 .iter()
488 .zip(other.coeffs.iter())
489 .map(|(lhs, rhs)| lhs - rhs)
490 .collect(),
491 }
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 /// A flattened set-partition table for a fixed slot count. `parts[i] = (off,
500 /// order)` describes one partition: its `order` block submasks (compacted) are
501 /// `flat[off .. off + order]`.
502 ///
503 /// This direct set-partition sum is the previous production `compose_unary`
504 /// implementation, retained as the **accuracy reference** the new
505 /// truncated-Taylor path is graded against: a double-double oracle is the
506 /// truth, and the test asserts the new path's error-vs-truth is `≤` this naive
507 /// partition sum's error-vs-truth on every randomised program.
508 struct PartTable {
509 flat: Vec<u32>,
510 parts: Vec<(usize, u8)>,
511 }
512
513 thread_local! {
514 /// Cached set-partition tables, indexed by slot count `m`. Entry `m` holds
515 /// every partition of `{0..m}` into `< DERIVS` blocks, in the shared
516 /// walker's recursion order, each block a compacted submask. Pure function
517 /// of `m`, so caching is sound and deterministic.
518 static PARTITION_TABLES: RefCell<Vec<std::rc::Rc<PartTable>>> =
519 const { RefCell::new(Vec::new()) };
520 }
521
522 /// Return cached partition tables for slot counts `0..=n_dirs`.
523 fn partition_tables(n_dirs: usize) -> Vec<std::rc::Rc<PartTable>> {
524 PARTITION_TABLES.with(|cell| {
525 let mut tables = cell.borrow_mut();
526 while tables.len() <= n_dirs {
527 let m = tables.len();
528 tables.push(std::rc::Rc::new(build_partitions(m)));
529 }
530 (0..=n_dirs)
531 .map(|m| std::rc::Rc::clone(&tables[m]))
532 .collect()
533 })
534 }
535
536 /// The previous production `compose_unary`: a direct set-partition (Faà di
537 /// Bruno) sum per output mask, retained as the accuracy reference.
538 fn compose_unary_partition_reference(coeffs: &[f64], derivs: [f64; DERIVS]) -> Vec<f64> {
539 let count = coeffs.len();
540 let n_dirs = count.trailing_zeros() as usize;
541 let tables = partition_tables(n_dirs);
542 let mut out = vec![0.0; count];
543 let mut remap = vec![0usize; count];
544 let mut pos = [0usize; usize::BITS as usize];
545 for (mask, slot) in out.iter_mut().enumerate() {
546 if mask == 0 {
547 *slot = derivs[0];
548 continue;
549 }
550 let mut npos = 0usize;
551 let mut m = mask;
552 while m != 0 {
553 pos[npos] = m.trailing_zeros() as usize;
554 npos += 1;
555 m &= m - 1;
556 }
557 remap[0] = 0;
558 for cb in 1usize..(1usize << npos) {
559 let low = cb.trailing_zeros() as usize;
560 remap[cb] = remap[cb & (cb - 1)] | (1usize << pos[low]);
561 }
562 let table = &tables[npos];
563 let flat = &table.flat;
564 let mut total = 0.0;
565 for &(off, order) in table.parts.iter() {
566 let order = order as usize;
567 let mut prod = derivs[order];
568 for &cb in &flat[off..off + order] {
569 prod *= coeffs[remap[cb as usize]];
570 }
571 total += prod;
572 }
573 *slot = total;
574 }
575 out
576 }
577
578 /// Enumerate the set-partitions of `{0..m}` with fewer than `DERIVS` blocks, in
579 /// the exact DFS order of [`crate::jet_algebra`]'s `for_each_partition`
580 /// recursion ("place each element into an existing block, else open a new one"),
581 /// each block recorded as a compacted submask of `{0..m}`, flattened.
582 fn build_partitions(m: usize) -> PartTable {
583 fn recurse(
584 elem: usize,
585 m: usize,
586 blocks: &mut [u32; 8],
587 n_blocks: usize,
588 out: &mut PartTable,
589 ) {
590 // Partitions with `>= DERIVS` blocks are truncated (their `f^{(order)}`
591 // is beyond the stack); the block count never decreases, so the whole
592 // subtree contributes nothing and is pruned — matching the walker's
593 // per-partition `order >= derivs.len()` skip.
594 if n_blocks >= DERIVS {
595 return;
596 }
597 if elem == m {
598 let off = out.flat.len();
599 out.flat.extend_from_slice(&blocks[..n_blocks]);
600 out.parts.push((off, n_blocks as u8));
601 return;
602 }
603 for b in 0..n_blocks {
604 blocks[b] |= 1u32 << elem;
605 recurse(elem + 1, m, blocks, n_blocks, out);
606 blocks[b] &= !(1u32 << elem);
607 }
608 blocks[n_blocks] = 1u32 << elem;
609 recurse(elem + 1, m, blocks, n_blocks + 1, out);
610 }
611 let mut out = PartTable {
612 flat: Vec::new(),
613 parts: Vec::new(),
614 };
615 let mut blocks = [0u32; 8];
616 recurse(0, m, &mut blocks, 0, &mut out);
617 out
618 }
619
620 // ── constructors ─────────────────────────────────────────────────────────
621
622 #[test]
623 fn zero_has_correct_length_and_all_zero_coefficients() {
624 let j = MultiDirJet::zero(3);
625 assert_eq!(j.coeffs.len(), 8);
626 assert!(j.coeffs.iter().all(|&v| v == 0.0));
627 }
628
629 #[test]
630 fn constant_has_value_at_mask_zero_and_zeros_elsewhere() {
631 let j = MultiDirJet::constant(2, 5.0);
632 assert_eq!(j.coeffs.len(), 4);
633 assert_eq!(j.coeff(0), 5.0);
634 assert_eq!(j.coeff(1), 0.0);
635 assert_eq!(j.coeff(2), 0.0);
636 assert_eq!(j.coeff(3), 0.0);
637 }
638
639 #[test]
640 fn linear_sets_base_and_per_direction_slots() {
641 let j = MultiDirJet::linear(2, 1.0, &[2.0, 3.0]);
642 assert_eq!(j.coeff(0), 1.0); // constant
643 assert_eq!(j.coeff(1), 2.0); // mask 0b01 — direction 0
644 assert_eq!(j.coeff(2), 3.0); // mask 0b10 — direction 1
645 assert_eq!(j.coeff(3), 0.0); // cross term is zero
646 }
647
648 #[test]
649 fn bilinear_sets_all_four_slots() {
650 let j = MultiDirJet::bilinear(1.0, 2.0, 3.0, 4.0);
651 assert_eq!(j.coeff(0), 1.0);
652 assert_eq!(j.coeff(1), 2.0);
653 assert_eq!(j.coeff(2), 3.0);
654 assert_eq!(j.coeff(3), 4.0);
655 }
656
657 #[test]
658 fn with_coeffs_sets_only_specified_entries() {
659 let j = MultiDirJet::with_coeffs(2, &[(0, 9.0), (3, -1.0)]);
660 assert_eq!(j.coeff(0), 9.0);
661 assert_eq!(j.coeff(1), 0.0);
662 assert_eq!(j.coeff(2), 0.0);
663 assert_eq!(j.coeff(3), -1.0);
664 }
665
666 // ── elementwise arithmetic ────────────────────────────────────────────────
667
668 #[test]
669 fn add_is_elementwise() {
670 let a = MultiDirJet::linear(2, 1.0, &[2.0, 3.0]);
671 let b = MultiDirJet::linear(2, 4.0, &[5.0, 6.0]);
672 let c = a.add(&b);
673 assert_eq!(c.coeff(0), 5.0);
674 assert_eq!(c.coeff(1), 7.0);
675 assert_eq!(c.coeff(2), 9.0);
676 assert_eq!(c.coeff(3), 0.0);
677 }
678
679 #[test]
680 fn scale_multiplies_all_coefficients() {
681 let j = MultiDirJet::linear(2, 1.0, &[2.0, 3.0]);
682 let s = j.scale(2.0);
683 assert_eq!(s.coeff(0), 2.0);
684 assert_eq!(s.coeff(1), 4.0);
685 assert_eq!(s.coeff(2), 6.0);
686 assert_eq!(s.coeff(3), 0.0);
687 }
688
689 #[test]
690 fn sub_is_elementwise_difference() {
691 let a = MultiDirJet::constant(2, 5.0);
692 let b = MultiDirJet::constant(2, 3.0);
693 let c = a.sub(&b);
694 assert_eq!(c.coeff(0), 2.0);
695 assert_eq!(c.coeff(1), 0.0);
696 assert_eq!(c.coeff(2), 0.0);
697 assert_eq!(c.coeff(3), 0.0);
698 }
699
700 // ── mul (subset-convolution) ──────────────────────────────────────────────
701
702 #[test]
703 fn mul_of_constants_is_scalar_product() {
704 let a = MultiDirJet::constant(2, 2.0);
705 let b = MultiDirJet::constant(2, 3.0);
706 let c = a.mul(&b);
707 assert_eq!(c.coeff(0), 6.0);
708 assert_eq!(c.coeff(1), 0.0);
709 assert_eq!(c.coeff(2), 0.0);
710 assert_eq!(c.coeff(3), 0.0);
711 }
712
713 #[test]
714 fn mul_satisfies_leibniz_rule_single_direction() {
715 // (1 + ε) * (1 + ε) = 1 + 2ε
716 let x = MultiDirJet::linear(1, 1.0, &[1.0]);
717 let y = MultiDirJet::linear(1, 1.0, &[1.0]);
718 let z = x.mul(&y);
719 assert_eq!(z.coeff(0), 1.0);
720 assert_eq!(z.coeff(1), 2.0);
721 }
722
723 #[test]
724 fn mul_cross_term_two_independent_directions() {
725 // (1 + ε₁)(1 + ε₂) = 1 + ε₁ + ε₂ + ε₁ε₂
726 let x = MultiDirJet::linear(2, 1.0, &[1.0, 0.0]);
727 let y = MultiDirJet::linear(2, 1.0, &[0.0, 1.0]);
728 let z = x.mul(&y);
729 assert_eq!(z.coeff(0), 1.0);
730 assert_eq!(z.coeff(1), 1.0);
731 assert_eq!(z.coeff(2), 1.0);
732 assert_eq!(z.coeff(3), 1.0);
733 }
734
735 // ── compose_unary: truncated-Taylor reassociation ─────────────────────────
736 //
737 // The new `compose_unary` reassociates the per-mask Faà di Bruno set-partition
738 // sum into a degree-4 polynomial in the subset-convolution power of the
739 // non-constant part. These tests are the accuracy gate: a double-double
740 // oracle is the truth, and the new path's error-vs-truth must be `≤` the old
741 // naive partition sum's error-vs-truth on every randomised program.
742
743 /// Deterministic xorshift64* — no `rand` dependency in the test.
744 struct Rng(u64);
745 impl Rng {
746 fn next_u64(&mut self) -> u64 {
747 let mut x = self.0;
748 x ^= x >> 12;
749 x ^= x << 25;
750 x ^= x >> 27;
751 self.0 = x;
752 x.wrapping_mul(0x2545F4914F6CDD1D)
753 }
754 /// Uniform in `[-scale, scale]`.
755 fn signed(&mut self, scale: f64) -> f64 {
756 let u = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64; // [0,1)
757 (2.0 * u - 1.0) * scale
758 }
759 }
760
761 // ── A double-double oracle for the exact (order-4 truncated) composition ──
762
763 #[inline]
764 fn two_prod(a: f64, b: f64) -> (f64, f64) {
765 let p = a * b;
766 (p, a.mul_add(b, -p))
767 }
768 #[inline]
769 fn dd_two_sum(a: f64, b: f64) -> (f64, f64) {
770 let s = a + b;
771 let bb = s - a;
772 (s, (a - (s - bb)) + (b - bb))
773 }
774 #[derive(Clone, Copy)]
775 struct Dd {
776 hi: f64,
777 lo: f64,
778 }
779 impl Dd {
780 fn from(x: f64) -> Self {
781 Self { hi: x, lo: 0.0 }
782 }
783 fn mul_f64(self, b: f64) -> Self {
784 let (p, e) = two_prod(self.hi, b);
785 let lo = self.lo.mul_add(b, e);
786 let s = p + lo;
787 Self {
788 hi: s,
789 lo: (p - s) + lo,
790 }
791 }
792 fn add(self, o: Self) -> Self {
793 let (s, e) = dd_two_sum(self.hi, o.hi);
794 let (s2, e2) = dd_two_sum(self.lo, o.lo);
795 let lo = e + s2;
796 let h1 = s + lo;
797 let l1 = (s - h1) + lo;
798 let lo2 = l1 + e2;
799 let h = h1 + lo2;
800 Self {
801 hi: h,
802 lo: (h1 - h) + lo2,
803 }
804 }
805 /// `|self - x|` to ~double precision in the residual (Sterbenz: `x` and
806 /// `hi` agree to ~53 bits, so `x - hi` is essentially exact).
807 fn abs_err_to(self, x: f64) -> f64 {
808 ((x - self.hi) - self.lo).abs()
809 }
810 }
811
812 /// High-precision truth for `compose_unary` via the set-partition reference,
813 /// every product and sum carried in double-double.
814 fn compose_truth(coeffs: &[f64], derivs: [f64; DERIVS]) -> Vec<Dd> {
815 let count = coeffs.len();
816 let n_dirs = count.trailing_zeros() as usize;
817 let tables = partition_tables(n_dirs);
818 let mut out = vec![Dd::from(0.0); count];
819 let mut remap = vec![0usize; count];
820 let mut pos = [0usize; 64];
821 for (mask, slot) in out.iter_mut().enumerate() {
822 if mask == 0 {
823 *slot = Dd::from(derivs[0]);
824 continue;
825 }
826 let mut npos = 0usize;
827 let mut m = mask;
828 while m != 0 {
829 pos[npos] = m.trailing_zeros() as usize;
830 npos += 1;
831 m &= m - 1;
832 }
833 remap[0] = 0;
834 for cb in 1usize..(1usize << npos) {
835 let low = cb.trailing_zeros() as usize;
836 remap[cb] = remap[cb & (cb - 1)] | (1usize << pos[low]);
837 }
838 let table = &tables[npos];
839 let mut total = Dd::from(0.0);
840 for &(off, order) in table.parts.iter() {
841 let order = order as usize;
842 let mut prod = Dd::from(derivs[order]);
843 for &cb in &table.flat[off..off + order] {
844 prod = prod.mul_f64(coeffs[remap[cb as usize]]);
845 }
846 total = total.add(prod);
847 }
848 *slot = total;
849 }
850 out
851 }
852
853 /// Build a random composite jet so the composition input is a realistic
854 /// non-trivial multilinear element (not just seeded directions).
855 fn random_inner(n_dirs: usize, rng: &mut Rng) -> MultiDirJet {
856 let base = rng.signed(0.8);
857 let first: Vec<f64> = (0..n_dirs).map(|_| rng.signed(0.6)).collect();
858 let a = MultiDirJet::linear(n_dirs, base, &first);
859 let b = MultiDirJet::linear(
860 n_dirs,
861 rng.signed(0.7),
862 &(0..n_dirs).map(|_| rng.signed(0.5)).collect::<Vec<_>>(),
863 );
864 // a*b + a populates the full cross-mask spectrum.
865 a.mul(&b).add(&a)
866 }
867
868 #[test]
869 fn compose_unary_matches_partition_reference_simple() {
870 // exp-like stack on a 2-direction cross jet: every coeff agrees with the
871 // direct set-partition reference to a tight tolerance.
872 let j = MultiDirJet::linear(2, 0.3, &[0.5, -0.4]).mul(&MultiDirJet::linear(
873 2,
874 -0.2,
875 &[0.1, 0.7],
876 ));
877 let d = [0.9_f64, 1.1, -0.7, 0.4, -0.25];
878 let got = j.compose_unary(d);
879 let want = compose_unary_partition_reference(&j.coeffs, d);
880 for (mask, (&g, &w)) in got.coeffs.iter().zip(want.iter()).enumerate() {
881 let tol = 1e-13 * w.abs().max(1.0);
882 assert!(
883 (g - w).abs() <= tol,
884 "mask {mask}: got={g:.17e} want={w:.17e}"
885 );
886 }
887 }
888
889 #[test]
890 fn compose_unary_accuracy_beats_partition_sum_vs_double_double() {
891 // The accuracy gate. Over many random programs at every K used in
892 // production, the new path's error-vs-truth is never worse than the old
893 // naive partition sum's, and is a strict improvement in aggregate.
894 let mut rng = Rng(0x1234_5678_9abc_def0);
895 let mut sum_new = 0.0f64;
896 let mut sum_old = 0.0f64;
897 for &n_dirs in &[2usize, 3, 4, 6, 8] {
898 for _ in 0..200 {
899 let inner = random_inner(n_dirs, &mut rng);
900 let d = [
901 rng.signed(1.5),
902 rng.signed(1.5),
903 rng.signed(2.0),
904 rng.signed(3.0),
905 rng.signed(4.0),
906 ];
907 let new = inner.compose_unary(d);
908 let old = compose_unary_partition_reference(&inner.coeffs, d);
909 let truth = compose_truth(&inner.coeffs, d);
910 for mask in 0..inner.coeffs.len() {
911 let en = truth[mask].abs_err_to(new.coeffs[mask]);
912 let eo = truth[mask].abs_err_to(old[mask]);
913 sum_new += en;
914 sum_old += eo;
915 // Per-coefficient: new is never materially worse. The 4 ULP
916 // slack absorbs the rare tie where a differently-grouped but
917 // equally-valid rounding lands one ULP either way.
918 let scale = truth[mask].hi.abs().max(1.0);
919 assert!(
920 en <= eo + 4.0 * f64::EPSILON * scale,
921 "K={n_dirs} mask={mask}: new_err={en:.3e} old_err={eo:.3e}"
922 );
923 }
924 }
925 }
926 // Aggregate: the compensated reassociation is a real improvement.
927 assert!(
928 sum_new <= sum_old,
929 "aggregate error regressed: new={sum_new:.6e} old={sum_old:.6e}"
930 );
931 eprintln!(
932 "compose_unary accuracy: total |err| new={sum_new:.6e} old={sum_old:.6e} \
933 (improvement {:.2}x)",
934 sum_old / sum_new.max(f64::MIN_POSITIVE)
935 );
936 }
937
938 #[test]
939 fn compose_unary_speedup_over_partition_sum() {
940 // Measure ns/call new vs. the previous partition-sum implementation
941 // across the production K range. Prints the multiple; asserts a
942 // conservative floor so CI noise can't make it flaky.
943 use std::time::Instant;
944 let mut rng = Rng(0xfeed_face_dead_beef);
945 for &n_dirs in &[2usize, 4, 6, 8] {
946 let n_inputs = 256usize;
947 let inputs: Vec<(MultiDirJet, [f64; DERIVS])> = (0..n_inputs)
948 .map(|_| {
949 (
950 random_inner(n_dirs, &mut rng),
951 [
952 rng.signed(1.5),
953 rng.signed(1.5),
954 rng.signed(2.0),
955 rng.signed(3.0),
956 rng.signed(4.0),
957 ],
958 )
959 })
960 .collect();
961 let iters = 200usize;
962 // Warm the scratch / partition tables.
963 for (j, d) in &inputs {
964 std::hint::black_box(j.compose_unary(*d));
965 std::hint::black_box(compose_unary_partition_reference(&j.coeffs, *d));
966 }
967 let t0 = Instant::now();
968 for _ in 0..iters {
969 for (j, d) in &inputs {
970 std::hint::black_box(j.compose_unary(*d));
971 }
972 }
973 let new_ns = t0.elapsed().as_nanos() as f64 / (iters * inputs.len()) as f64;
974 let t1 = Instant::now();
975 for _ in 0..iters {
976 for (j, d) in &inputs {
977 std::hint::black_box(compose_unary_partition_reference(&j.coeffs, *d));
978 }
979 }
980 let old_ns = t1.elapsed().as_nanos() as f64 / (iters * inputs.len()) as f64;
981 eprintln!(
982 "compose_unary K={n_dirs}: new={new_ns:.1} ns/call old={old_ns:.1} ns/call \
983 speedup={:.2}x",
984 old_ns / new_ns
985 );
986 // Guard only where the algorithmic win is robust: an optimised build
987 // at the production-dominant K (the partition sum's `Σ_π |π|` work
988 // grows steeply with K, while the new path is three convolutions).
989 // Debug builds and tiny K are dominated by fixed per-call overhead
990 // and the ratio there is not a meaningful guard, so it is printed
991 // but not asserted (and timing asserts must not flake on CI).
992 if !cfg!(debug_assertions) && n_dirs >= 6 {
993 assert!(
994 new_ns < old_ns,
995 "K={n_dirs} new path slower: new={new_ns:.1}ns old={old_ns:.1}ns"
996 );
997 }
998 }
999 }
1000}