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 from the *multilinear powers* of the non-constant part rather than a
30//! direct 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//! The powers themselves are built by the **pointed (lowest-set-bit)
42//! recurrence**, not by full subset convolutions. Write `ℓ` for the lowest
43//! set bit of `mask`. In any partition of `mask` exactly one block owns `ℓ`,
44//! and the `k` blocks of an ordered `k`-tuple are interchangeable, so pinning
45//! the outer block to the one containing `ℓ` counts each partition once
46//! instead of `k` times:
47//!
48//! ```text
49//! v^{⊛k}[mask] = k · Σ_{B ⊆ mask, ℓ ∈ B} v[B] · v^{⊛(k-1)}[mask \ B]
50//! ```
51//!
52//! This is an exact identity (`k = 2, 3, 4` reproduce `v²`, `v³`, `v⁴` to
53//! roundoff against a brute-force partition sum, gated in `tests`), and it is
54//! what makes the schedule cheap at small `K`: the complements `mask \ B`
55//! range over submasks of `mask ^ ℓ` rather than of `mask`, and the surviving
56//! term set shrinks with `k` because `v^{⊛(k-1)}` vanishes below popcount
57//! `k-1`. All three powers therefore share **one** descending submask walk
58//! whose `k = 3` and `k = 4` chains are popcount-suffixes of the `k = 2`
59//! chain — one walk, one `v[B]` load, and three independent Dot2 chains to
60//! interleave.
61//!
62//! Each accumulation is a compensated dot product (Ogita–Rump–Oishi Dot2,
63//! FMA-split products + TwoSum carry) so the result is computed in ~double
64//! the working precision and the rounding of `v²` cannot compound through
65//! `v³`/`v⁴`; the integer multiplicity `k` is applied with its own FMA split
66//! so it costs one rounding rather than discarding the compensated tail; the
67//! final per-mask combine is Neumaier-compensated and `wide::f64x4`-vectorised;
68//! and the whole call runs on reused thread-local scratch with no per-call
69//! heap traffic.
70//!
71//! ### What this schedule costs
72//!
73//! Everything below is recomputed from the enumeration itself by
74//! `compose_unary_work_model_matches_the_closed_form`, which replays the walk and
75//! counts its steps. A counted model cannot drift the way a prose factor did:
76//! this header once claimed "~3× fewer FLOPs than the partition gather" for a
77//! schedule that in fact cost ~9× as much at the `K` production runs at.
78//!
79//! * the pointed recurrence walks `Σ_{p ≥ 2} C(K,p)·Σ_{k=2..4, k ≤ p} Σ_{j ≥ k-1}
80//! C(p-1,j)` terms, i.e. `Θ(3^K)`, each a compensated Dot2 (10 flops), plus a
81//! 5-flop multiplicity epilogue per power per mask;
82//! * the partition gather walks `Σ_p C(K,p)·B_{≤4}(p)` terms, i.e. `Θ(5^K/4!)`,
83//! each `|π|` plain multiplies and an add. (That count is a *lower bound* on
84//! the gather: it omits the `2^p` per-mask index remap the gather also needs,
85//! so every comparison below is stated against the gather at its best.)
86//!
87//! ```text
88//! K 2 3 4 6 8 9 10 12
89//! pointed terms 1 7 34 534 6514 21589 69886 696810
90//! gather terms 5 15 52 855 18002 86472 422005 10306752
91//! pointed/gather flops 2.5x 3.5x 3.3x 2.0x 0.91x 0.59x 0.37x 0.15x
92//! ```
93//!
94//! Two facts worth carrying:
95//!
96//! * The three-full-subset-convolution schedule this replaced walked **exactly
97//! 4×** as many terms at every `K ≤ 4` (136 against 34 at `K = 4`) — one factor
98//! of 2 from pinning the block that owns `ℓ`, one from walking submasks of
99//! `mask ^ ℓ` instead of `mask`. Its flop crossover against the gather sat at
100//! `K = 10`; the pointed recurrence moves it to `K = 8`.
101//! * At `K = 4` the schedule walks 34 terms against the gather's 52. It does
102//! strictly *less* combinatorial work than the partition sum it replaced —
103//! at every `K`, not just past a crossover — and the residual 3.3× flop ratio
104//! at `K = 4` is entirely the Dot2 compensation: 10 flops a term against ~3.
105//! That is the accuracy the double-double gate pins, and the only reason to
106//! prefer this schedule; it is not free, and a reader sizing a new call site
107//! should plan for it.
108use std::cell::RefCell;
109use std::sync::atomic::{AtomicU64, Ordering};
110use wide::f64x4;
111
112pub static COMPOSE_UNARY_CALLS: AtomicU64 = AtomicU64::new(0);
113pub static MUL_CALLS: AtomicU64 = AtomicU64::new(0);
114
115/// Length of the unary derivative stack `[f, f', f'', f''', f'''']`: composition
116/// is exact through order 4, partitions into `>= 5` blocks are truncated.
117const DERIVS: usize = 5;
118
119#[derive(Clone)]
120pub struct MultiDirJet {
121 pub coeffs: Vec<f64>,
122}
123
124impl MultiDirJet {
125 pub fn zero(n_dirs: usize) -> Self {
126 Self {
127 coeffs: vec![0.0; 1usize << n_dirs],
128 }
129 }
130
131 pub fn constant(n_dirs: usize, value: f64) -> Self {
132 let mut out = Self::zero(n_dirs);
133 out.coeffs[0] = value;
134 out
135 }
136
137 pub fn linear(n_dirs: usize, base: f64, first: &[f64]) -> Self {
138 let mut out = Self::constant(n_dirs, base);
139 for (idx, &value) in first.iter().take(n_dirs).enumerate() {
140 out.coeffs[1usize << idx] = value;
141 }
142 out
143 }
144
145 pub fn with_coeffs(n_dirs: usize, coeffs: &[(usize, f64)]) -> Self {
146 let mut out = Self::zero(n_dirs);
147 for &(mask, value) in coeffs {
148 if mask < out.coeffs.len() {
149 out.coeffs[mask] = value;
150 }
151 }
152 out
153 }
154
155 #[inline]
156 pub fn coeff(&self, mask: usize) -> f64 {
157 self.coeffs[mask]
158 }
159
160 pub fn add(&self, other: &Self) -> Self {
161 Self {
162 coeffs: self
163 .coeffs
164 .iter()
165 .zip(other.coeffs.iter())
166 .map(|(lhs, rhs)| lhs + rhs)
167 .collect(),
168 }
169 }
170
171 pub fn scale(&self, scalar: f64) -> Self {
172 Self {
173 coeffs: self.coeffs.iter().map(|value| scalar * value).collect(),
174 }
175 }
176
177 /// Subset-convolution product `out[mask] = Σ_{sub ⊆ mask} a[sub]·b[mask^sub]`.
178 ///
179 /// Bit-identical to the shared `crate::jet_algebra::leibniz_product` walker
180 /// (the submasks are enumerated in the same ascending order — the walker's
181 /// compacted subset index is a monotone bit-deposit of the submask) while
182 /// dropping its per-subset `SlotBuf`/closure/`mask_of` overhead. The scalar
183 /// `n_dirs == 0` case keeps the shared walker live as its reference.
184 pub fn mul(&self, other: &Self) -> Self {
185 MUL_CALLS.fetch_add(1, Ordering::Relaxed);
186 let count = self.coeffs.len();
187 if count <= 1 {
188 return self.mul_reference(other);
189 }
190 let a = &self.coeffs;
191 let b = &other.coeffs;
192 // Both operands carry the same direction set, so `b` is `count` long too.
193 // With that established once, every `a[sub]`/`b[mask ^ sub]` below is
194 // provably in bounds (`sub, mask ^ sub ⊆ mask < count`), so the inner
195 // submask walk can drop its per-load bounds checks.
196 assert_eq!(
197 b.len(),
198 count,
199 "MultiDirJet::mul operands must share n_dirs"
200 );
201 let mut out = vec![0.0; count];
202 for (mask, slot) in out.iter_mut().enumerate() {
203 // Walk every submask of `mask` in ascending numeric order — the same
204 // order `leibniz_product` accumulates — via the classic gap-fill
205 // increment `next = ((sub | !mask) + 1) & mask`.
206 let mut acc = 0.0;
207 let mut sub = 0usize;
208 // SAFETY: `sub ⊆ mask < count` and `mask ^ sub ⊆ mask < count`, and
209 // both `a` and `b` are `count` long (asserted above).
210 unsafe {
211 loop {
212 acc += *a.get_unchecked(sub) * *b.get_unchecked(mask ^ sub);
213 if sub == mask {
214 break;
215 }
216 sub = (sub | !mask).wrapping_add(1) & mask;
217 }
218 }
219 *slot = acc;
220 }
221 Self { coeffs: out }
222 }
223
224 /// The pre-#perf shared-walker product, retained verbatim as the scalar-case
225 /// implementation and as the bit-exact reference for `mul`.
226 fn mul_reference(&self, other: &Self) -> Self {
227 let count = self.coeffs.len();
228 let mut out = vec![0.0; count];
229 for (mask, slot) in out.iter_mut().enumerate() {
230 let bits = bit_positions(mask);
231 *slot = crate::jet_algebra::leibniz_product(
232 bits.as_slice(),
233 |t| self.coeffs[mask_of(t)],
234 |c| other.coeffs[mask_of(c)],
235 );
236 }
237 Self { coeffs: out }
238 }
239
240 /// Exact (order-4 truncated) unary composition `f(self)` from the Taylor
241 /// stack `[f, f', f'', f''', f'''']` at `self.coeff(0)`.
242 ///
243 /// Computed by the truncated-Taylor reassociation (see the module note):
244 /// `f(self) = Σ_{k=0}^{4} (f^{(k)}/k!)·v^{⊛k}` with `v` the non-constant
245 /// part of `self`. The three subset-convolution powers `v²`, `v³`, `v⁴`
246 /// are compensated (Dot2) and the per-mask combine is Neumaier-compensated
247 /// and vectorised, so the result is *more* accurate vs. the true
248 /// real-arithmetic value than the prior naive partition sum (proven against
249 /// a double-double oracle in `tests`). The scalar `n_dirs == 0` case keeps
250 /// the shared Faà di Bruno walker live as its reference.
251 pub fn compose_unary(&self, derivs: [f64; DERIVS]) -> Self {
252 COMPOSE_UNARY_CALLS.fetch_add(1, Ordering::Relaxed);
253 let count = self.coeffs.len();
254 if count <= 1 {
255 return <Self as crate::jet_algebra::JetAlgebra<DERIVS>>::compose_unary(self, derivs);
256 }
257 let mut out = vec![0.0; count];
258 COMPOSE_SCRATCH.with(|cell| {
259 let mut buf = cell.borrow_mut();
260 buf.clear();
261 buf.resize(4 * count, 0.0);
262 compose_unary_coefficients_into(&self.coeffs, derivs, buf.as_mut_slice(), &mut out);
263 });
264 Self { coeffs: out }
265 }
266}
267
268thread_local! {
269 /// Reused composition scratch (`4·count` f64s: v, v², v³, v⁴). Sized up on
270 /// demand and never freed, so a steady-state `compose_unary` does zero heap
271 /// work beyond the owned output `Vec`.
272 static COMPOSE_SCRATCH: RefCell<Vec<f64>> = const { RefCell::new(Vec::new()) };
273}
274
275#[inline]
276fn compose_unary_coefficients_into(
277 coefficients: &[f64],
278 derivs: [f64; DERIVS],
279 scratch: &mut [f64],
280 out: &mut [f64],
281) {
282 let count = coefficients.len();
283 assert!(count > 1 && count.is_power_of_two());
284 assert!(scratch.len() == 4 * count && out.len() == count);
285 let (vbuf, tail) = scratch.split_at_mut(count);
286 let (p2, tail) = tail.split_at_mut(count);
287 let (p3, p4) = tail.split_at_mut(count);
288
289 // v is the non-constant part of the input. The k=0 Taylor term owns the
290 // constant coefficient, so the zero mask must not enter any power.
291 vbuf.copy_from_slice(coefficients);
292 vbuf[0] = 0.0;
293
294 // The three multilinear powers, by the pointed recurrence (module header).
295 multilinear_powers_into(vbuf, p2, p3, p4);
296 // `1/k!` undoes the ordered-tuple overcount of each k-fold subset power
297 // relative to the unordered set-partition sum.
298 let coefficients_by_order = [
299 derivs[1],
300 derivs[2] * 0.5,
301 derivs[3] * (1.0 / 6.0),
302 derivs[4] * (1.0 / 24.0),
303 ];
304 combine_powers(vbuf, p2, p3, p4, coefficients_by_order, out);
305 out[0] = derivs[0];
306}
307
308/// Branchless TwoSum: returns `(s, e)` with `s = fl(a+b)` and `a+b = s+e`
309/// exactly (Knuth/Møller). Used by the compensated power recurrence and combine.
310#[inline(always)]
311fn two_sum(a: f64, b: f64) -> (f64, f64) {
312 let s = a + b;
313 let bb = s - a;
314 let e = (a - (s - bb)) + (b - bb);
315 (s, e)
316}
317
318/// One step of an Ogita–Rump–Oishi Dot2: accumulate `x·y` into `(s, c)` so that
319/// `s + c` carries the running sum in ~twice the working precision. The product
320/// is split into head plus exact FMA error, and the addition's rounding error is
321/// recovered by TwoSum, so neither the product nor the sum silently drops bits.
322#[inline(always)]
323fn dot2_step(s: &mut f64, c: &mut f64, x: f64, y: f64) {
324 let prod = x * y;
325 let prod_err = x.mul_add(y, -prod); // exact: prod + prod_err == x*y
326 let (t, sum_err) = two_sum(*s, prod);
327 *s = t;
328 *c += prod_err + sum_err;
329}
330
331/// `k·(s + c)` for a small integer multiplicity `k`, with `k·s` split into head
332/// plus exact FMA error so the multiplicity costs one final rounding rather than
333/// discarding the compensated tail. For `k ∈ {2, 4}` the split is identically
334/// zero (both are exact scalings); `k = 3` is the case that needs it.
335#[inline(always)]
336fn scaled_compensated(k: f64, s: f64, c: f64) -> f64 {
337 let hi = k * s;
338 let lo = k.mul_add(s, -hi); // exact: hi + lo == k*s
339 hi + (lo + k * c)
340}
341
342/// The multilinear powers `v^{⊛2}`, `v^{⊛3}`, `v^{⊛4}` of the non-constant part
343/// `v`, by the **pointed (lowest-set-bit) recurrence** derived in the module
344/// header:
345///
346/// ```text
347/// v^{⊛k}[mask] = k · Σ_{t ⊊ mask, ℓ ∉ t} v[mask \ t] · v^{⊛(k-1)}[t]
348/// ```
349///
350/// with `ℓ` the lowest set bit of `mask`. Pinning the block that owns `ℓ` counts
351/// each partition once instead of `k` times, and the surviving `t` range over
352/// submasks of `mask ^ ℓ` rather than of `mask` — together an exactly 4× shorter
353/// walk than the three full subset convolutions this replaced, at every
354/// `K ≤ 4` (see `compose_unary_work_model_matches_the_closed_form`).
355///
356/// All three powers share **one** descending walk over the submasks of
357/// `mask ^ ℓ`, because their term sets are nested: `v^{⊛(k-1)}[t]` vanishes
358/// below `popcount(t) = k - 1`, so the `k = 3` and `k = 4` chains are the
359/// `popcount ≥ 2` and `popcount ≥ 3` suffixes of the `k = 2` chain. Sharing the
360/// walk also shares the `v[mask \ t]` load and gives three independent Dot2
361/// dependency chains to interleave, which is what the old kernel's four-way
362/// unroll was buying separately.
363///
364/// Every accumulation is a compensated Dot2, so the rounding of `v²` cannot
365/// compound through `v³`/`v⁴`. Masks below popcount `k` are left at zero: the
366/// `k`-fold multilinear power vanishes there, so the prune is exact.
367#[inline]
368fn multilinear_powers_into(v: &[f64], p2: &mut [f64], p3: &mut [f64], p4: &mut [f64]) {
369 let count = v.len();
370 // SAFETY precondition for the `get_unchecked` loads below, pinned once per
371 // call (negligible next to the walk): all four buffers are `count` long.
372 // Every index read is either `t` or `mask ^ t` for `t ⊆ mask < count`, and
373 // both are submasks of `mask`, hence `< count`. The per-load bounds checks
374 // LLVM cannot elide (the indices are data-dependent) are a real cost across
375 // the exponential walk, and eliding them measured ~20% on the kernel this
376 // replaced.
377 assert!(p2.len() == count && p3.len() == count && p4.len() == count);
378 if count > 0 {
379 p2[0] = 0.0;
380 p3[0] = 0.0;
381 p4[0] = 0.0;
382 }
383 for mask in 1..count {
384 // `v^{⊛k}` vanishes below popcount k, so a popcount-1 mask is all-zero
385 // in every power and never enters a walk.
386 let lowest = mask & mask.wrapping_neg();
387 let rest = mask ^ lowest;
388 if rest == 0 {
389 p2[mask] = 0.0;
390 p3[mask] = 0.0;
391 p4[mask] = 0.0;
392 continue;
393 }
394 let (mut s2, mut c2) = (0.0f64, 0.0f64);
395 let (mut s3, mut c3) = (0.0f64, 0.0f64);
396 let (mut s4, mut c4) = (0.0f64, 0.0f64);
397 // Descending submask walk `t = (t - 1) & rest` over the NONZERO submasks
398 // of `rest` (the classic Gosper-style enumeration). `t = 0` is skipped
399 // because it is the one term whose complement is the whole mask, and
400 // `v^{⊛(k-1)}[0] = 0` for every `k ≥ 2`.
401 let mut t = rest;
402 while t != 0 {
403 // SAFETY: `t ⊆ rest ⊂ mask < count` and `mask ^ t ⊆ mask < count`,
404 // and all four buffers are `count` long (asserted above).
405 unsafe {
406 let block = *v.get_unchecked(mask ^ t);
407 dot2_step(&mut s2, &mut c2, block, *v.get_unchecked(t));
408 let popcount = (t as u64).count_ones();
409 if popcount >= 2 {
410 dot2_step(&mut s3, &mut c3, block, *p2.get_unchecked(t));
411 if popcount >= 3 {
412 dot2_step(&mut s4, &mut c4, block, *p3.get_unchecked(t));
413 }
414 }
415 }
416 t = (t - 1) & rest;
417 }
418 // The pointed recurrence's multiplicity. `v^{⊛k}[mask]` must be written
419 // before the `k+1` chain of any LATER mask reads it, and `t < mask`
420 // strictly for every `t ⊆ mask ^ ℓ`, so writing all three here keeps the
421 // recurrence's read-before-write order across the ascending mask loop.
422 p2[mask] = scaled_compensated(2.0, s2, c2);
423 p3[mask] = scaled_compensated(3.0, s3, c3);
424 p4[mask] = scaled_compensated(4.0, s4, c4);
425 }
426}
427
428/// `out[mask] = c[0]·p1 + c[1]·p2 + c[2]·p3 + c[3]·p4` for `mask ≥ 1`, with a
429/// Neumaier-compensated four-term accumulation (the powers span growing
430/// magnitudes, so the compensation recovers the bits a naive `+=` would drop)
431/// and a `wide::f64x4` body over four masks at a time. `out[0]` is overwritten
432/// by the caller with the value channel.
433#[inline]
434fn combine_powers(p1: &[f64], p2: &[f64], p3: &[f64], p4: &[f64], c: [f64; 4], out: &mut [f64]) {
435 let n = out.len();
436 let (c1, c2, c3, c4) = (c[0], c[1], c[2], c[3]);
437 let (v1, v2, v3, v4) = (
438 f64x4::splat(c1),
439 f64x4::splat(c2),
440 f64x4::splat(c3),
441 f64x4::splat(c4),
442 );
443 let mut mask = 0usize;
444 // Vector body: four contiguous masks per step. Neumaier compensation is
445 // applied lane-wise; pick the larger magnitude to subtract first.
446 while mask + 4 <= n {
447 let load = |p: &[f64]| f64x4::new([p[mask], p[mask + 1], p[mask + 2], p[mask + 3]]);
448 let mut s = v1 * load(p1);
449 let mut comp = f64x4::splat(0.0);
450 for (cv, pv) in [(v2, p2), (v3, p3), (v4, p4)] {
451 let term = cv * load(pv);
452 let t = s + term;
453 let big_s = s.abs().simd_ge(term.abs());
454 let lost = big_s.blend((s - t) + term, (term - t) + s);
455 comp += lost;
456 s = t;
457 }
458 let res = s + comp;
459 out[mask..mask + 4].copy_from_slice(&res.to_array());
460 mask += 4;
461 }
462 // Scalar tail (and the small-K path where `n < 4`).
463 while mask < n {
464 let mut s = c1 * p1[mask];
465 let mut comp = 0.0f64;
466 for (cv, pv) in [(c2, p2), (c3, p3), (c4, p4)] {
467 let term = cv * pv[mask];
468 let (t, e) = two_sum(s, term);
469 comp += e;
470 s = t;
471 }
472 out[mask] = s + comp;
473 mask += 1;
474 }
475}
476
477impl crate::jet_algebra::JetAlgebra<DERIVS> for MultiDirJet {
478 #[inline]
479 fn derivative(&self, slots: &[usize]) -> f64 {
480 self.coeffs[mask_of(slots)]
481 }
482
483 fn map_derivatives<F>(&self, mut f: F) -> Self
484 where
485 F: FnMut(&[usize]) -> f64,
486 {
487 let mut out = vec![0.0; self.coeffs.len()];
488 for (mask, value) in out.iter_mut().enumerate() {
489 let bits = bit_positions(mask);
490 *value = f(bits.as_slice());
491 }
492 Self { coeffs: out }
493 }
494}
495
496/// The set-bit positions of `mask`, low to high — the differentiation slots of
497/// that coefficient.
498fn bit_positions(mask: usize) -> crate::jet_algebra::SlotBuf {
499 let mut out = crate::jet_algebra::SlotBuf::new();
500 let mut m = mask;
501 while m != 0 {
502 let bit = m.trailing_zeros() as usize;
503 out.push_slot(bit);
504 m &= m - 1;
505 }
506 out
507}
508
509/// Combine a slot-group (list of bit positions) back into a sub-mask.
510fn mask_of(slots: &[usize]) -> usize {
511 slots.iter().fold(0usize, |acc, &b| acc | (1usize << b))
512}
513
514// #932-2 cutover: `MultiDirJet::bilinear` (the 4-coeff `[base, d1, d2, d12]`
515// constructor) and `MultiDirJet::sub` are consumed ONLY by the now test-only hand
516// survival directional/bidirectional oracle (the production flex jet path uses the
517// `flex_jet` runtime jet algebra, not `MultiDirJet`). After the #1521 crate split
518// moved `MultiDirJet` into `gam-math`, those oracle tests live in the dependent
519// `gam` crate, where a `#[cfg(test)]` gate in *this* crate is inactive — so the
520// methods must be plain `pub` inherent methods to be reachable cross-crate. They
521// carry no dead-code cost because `pub` items are part of the crate's public API.
522// Bodies are byte-identical to their former gated form.
523impl MultiDirJet {
524 pub fn bilinear(base: f64, d1: f64, d2: f64, d12: f64) -> Self {
525 Self {
526 coeffs: vec![base, d1, d2, d12],
527 }
528 }
529
530 pub fn sub(&self, other: &Self) -> Self {
531 Self {
532 coeffs: self
533 .coeffs
534 .iter()
535 .zip(other.coeffs.iter())
536 .map(|(lhs, rhs)| lhs - rhs)
537 .collect(),
538 }
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 /// A flattened set-partition table for a fixed slot count. `parts[i] = (off,
547 /// order)` describes one partition: its `order` block submasks (compacted) are
548 /// `flat[off .. off + order]`.
549 ///
550 /// This direct set-partition sum is the previous production `compose_unary`
551 /// implementation, retained as the **accuracy reference** the new
552 /// truncated-Taylor path is graded against: a double-double oracle is the
553 /// truth, and the test asserts the new path's error-vs-truth is `≤` this naive
554 /// partition sum's error-vs-truth on every randomised program.
555 struct PartTable {
556 flat: Vec<u32>,
557 parts: Vec<(usize, u8)>,
558 }
559
560 thread_local! {
561 /// Cached set-partition tables, indexed by slot count `m`. Entry `m` holds
562 /// every partition of `{0..m}` into `< DERIVS` blocks, in the shared
563 /// walker's recursion order, each block a compacted submask. Pure function
564 /// of `m`, so caching is sound and deterministic.
565 static PARTITION_TABLES: RefCell<Vec<std::rc::Rc<PartTable>>> =
566 const { RefCell::new(Vec::new()) };
567 }
568
569 /// Return cached partition tables for slot counts `0..=n_dirs`.
570 fn partition_tables(n_dirs: usize) -> Vec<std::rc::Rc<PartTable>> {
571 PARTITION_TABLES.with(|cell| {
572 let mut tables = cell.borrow_mut();
573 while tables.len() <= n_dirs {
574 let m = tables.len();
575 tables.push(std::rc::Rc::new(build_partitions(m)));
576 }
577 (0..=n_dirs)
578 .map(|m| std::rc::Rc::clone(&tables[m]))
579 .collect()
580 })
581 }
582
583 /// The previous production `compose_unary`: a direct set-partition (Faà di
584 /// Bruno) sum per output mask, retained as the accuracy reference.
585 fn compose_unary_partition_reference(coeffs: &[f64], derivs: [f64; DERIVS]) -> Vec<f64> {
586 let count = coeffs.len();
587 let n_dirs = count.trailing_zeros() as usize;
588 let tables = partition_tables(n_dirs);
589 let mut out = vec![0.0; count];
590 let mut remap = vec![0usize; count];
591 let mut pos = [0usize; usize::BITS as usize];
592 for (mask, slot) in out.iter_mut().enumerate() {
593 if mask == 0 {
594 *slot = derivs[0];
595 continue;
596 }
597 let mut npos = 0usize;
598 let mut m = mask;
599 while m != 0 {
600 pos[npos] = m.trailing_zeros() as usize;
601 npos += 1;
602 m &= m - 1;
603 }
604 remap[0] = 0;
605 for cb in 1usize..(1usize << npos) {
606 let low = cb.trailing_zeros() as usize;
607 remap[cb] = remap[cb & (cb - 1)] | (1usize << pos[low]);
608 }
609 let table = &tables[npos];
610 let flat = &table.flat;
611 let mut total = 0.0;
612 for &(off, order) in table.parts.iter() {
613 let order = order as usize;
614 let mut prod = derivs[order];
615 for &cb in &flat[off..off + order] {
616 prod *= coeffs[remap[cb as usize]];
617 }
618 total += prod;
619 }
620 *slot = total;
621 }
622 out
623 }
624
625 /// Enumerate the set-partitions of `{0..m}` with fewer than `DERIVS` blocks, in
626 /// the exact DFS order of [`crate::jet_algebra`]'s `for_each_partition`
627 /// recursion ("place each element into an existing block, else open a new one"),
628 /// each block recorded as a compacted submask of `{0..m}`, flattened.
629 fn build_partitions(m: usize) -> PartTable {
630 fn recurse(
631 elem: usize,
632 m: usize,
633 blocks: &mut [u32; 8],
634 n_blocks: usize,
635 out: &mut PartTable,
636 ) {
637 // Partitions with `>= DERIVS` blocks are truncated (their `f^{(order)}`
638 // is beyond the stack); the block count never decreases, so the whole
639 // subtree contributes nothing and is pruned — matching the walker's
640 // per-partition `order >= derivs.len()` skip.
641 if n_blocks >= DERIVS {
642 return;
643 }
644 if elem == m {
645 let off = out.flat.len();
646 out.flat.extend_from_slice(&blocks[..n_blocks]);
647 out.parts.push((off, n_blocks as u8));
648 return;
649 }
650 for b in 0..n_blocks {
651 blocks[b] |= 1u32 << elem;
652 recurse(elem + 1, m, blocks, n_blocks, out);
653 blocks[b] &= !(1u32 << elem);
654 }
655 blocks[n_blocks] = 1u32 << elem;
656 recurse(elem + 1, m, blocks, n_blocks + 1, out);
657 }
658 let mut out = PartTable {
659 flat: Vec::new(),
660 parts: Vec::new(),
661 };
662 let mut blocks = [0u32; 8];
663 recurse(0, m, &mut blocks, 0, &mut out);
664 out
665 }
666
667 // ── constructors ─────────────────────────────────────────────────────────
668
669 #[test]
670 fn zero_has_correct_length_and_all_zero_coefficients() {
671 let j = MultiDirJet::zero(3);
672 assert_eq!(j.coeffs.len(), 8);
673 assert!(j.coeffs.iter().all(|&v| v == 0.0));
674 }
675
676 #[test]
677 fn constant_has_value_at_mask_zero_and_zeros_elsewhere() {
678 let j = MultiDirJet::constant(2, 5.0);
679 assert_eq!(j.coeffs.len(), 4);
680 assert_eq!(j.coeff(0), 5.0);
681 assert_eq!(j.coeff(1), 0.0);
682 assert_eq!(j.coeff(2), 0.0);
683 assert_eq!(j.coeff(3), 0.0);
684 }
685
686 #[test]
687 fn linear_sets_base_and_per_direction_slots() {
688 let j = MultiDirJet::linear(2, 1.0, &[2.0, 3.0]);
689 assert_eq!(j.coeff(0), 1.0); // constant
690 assert_eq!(j.coeff(1), 2.0); // mask 0b01 — direction 0
691 assert_eq!(j.coeff(2), 3.0); // mask 0b10 — direction 1
692 assert_eq!(j.coeff(3), 0.0); // cross term is zero
693 }
694
695 #[test]
696 fn bilinear_sets_all_four_slots() {
697 let j = MultiDirJet::bilinear(1.0, 2.0, 3.0, 4.0);
698 assert_eq!(j.coeff(0), 1.0);
699 assert_eq!(j.coeff(1), 2.0);
700 assert_eq!(j.coeff(2), 3.0);
701 assert_eq!(j.coeff(3), 4.0);
702 }
703
704 #[test]
705 fn with_coeffs_sets_only_specified_entries() {
706 let j = MultiDirJet::with_coeffs(2, &[(0, 9.0), (3, -1.0)]);
707 assert_eq!(j.coeff(0), 9.0);
708 assert_eq!(j.coeff(1), 0.0);
709 assert_eq!(j.coeff(2), 0.0);
710 assert_eq!(j.coeff(3), -1.0);
711 }
712
713 // ── elementwise arithmetic ────────────────────────────────────────────────
714
715 #[test]
716 fn add_is_elementwise() {
717 let a = MultiDirJet::linear(2, 1.0, &[2.0, 3.0]);
718 let b = MultiDirJet::linear(2, 4.0, &[5.0, 6.0]);
719 let c = a.add(&b);
720 assert_eq!(c.coeff(0), 5.0);
721 assert_eq!(c.coeff(1), 7.0);
722 assert_eq!(c.coeff(2), 9.0);
723 assert_eq!(c.coeff(3), 0.0);
724 }
725
726 #[test]
727 fn scale_multiplies_all_coefficients() {
728 let j = MultiDirJet::linear(2, 1.0, &[2.0, 3.0]);
729 let s = j.scale(2.0);
730 assert_eq!(s.coeff(0), 2.0);
731 assert_eq!(s.coeff(1), 4.0);
732 assert_eq!(s.coeff(2), 6.0);
733 assert_eq!(s.coeff(3), 0.0);
734 }
735
736 #[test]
737 fn sub_is_elementwise_difference() {
738 let a = MultiDirJet::constant(2, 5.0);
739 let b = MultiDirJet::constant(2, 3.0);
740 let c = a.sub(&b);
741 assert_eq!(c.coeff(0), 2.0);
742 assert_eq!(c.coeff(1), 0.0);
743 assert_eq!(c.coeff(2), 0.0);
744 assert_eq!(c.coeff(3), 0.0);
745 }
746
747 // ── mul (subset-convolution) ──────────────────────────────────────────────
748
749 #[test]
750 fn mul_of_constants_is_scalar_product() {
751 let a = MultiDirJet::constant(2, 2.0);
752 let b = MultiDirJet::constant(2, 3.0);
753 let c = a.mul(&b);
754 assert_eq!(c.coeff(0), 6.0);
755 assert_eq!(c.coeff(1), 0.0);
756 assert_eq!(c.coeff(2), 0.0);
757 assert_eq!(c.coeff(3), 0.0);
758 }
759
760 #[test]
761 fn mul_satisfies_leibniz_rule_single_direction() {
762 // (1 + ε) * (1 + ε) = 1 + 2ε
763 let x = MultiDirJet::linear(1, 1.0, &[1.0]);
764 let y = MultiDirJet::linear(1, 1.0, &[1.0]);
765 let z = x.mul(&y);
766 assert_eq!(z.coeff(0), 1.0);
767 assert_eq!(z.coeff(1), 2.0);
768 }
769
770 #[test]
771 fn mul_cross_term_two_independent_directions() {
772 // (1 + ε₁)(1 + ε₂) = 1 + ε₁ + ε₂ + ε₁ε₂
773 let x = MultiDirJet::linear(2, 1.0, &[1.0, 0.0]);
774 let y = MultiDirJet::linear(2, 1.0, &[0.0, 1.0]);
775 let z = x.mul(&y);
776 assert_eq!(z.coeff(0), 1.0);
777 assert_eq!(z.coeff(1), 1.0);
778 assert_eq!(z.coeff(2), 1.0);
779 assert_eq!(z.coeff(3), 1.0);
780 }
781
782 // ── compose_unary: truncated-Taylor reassociation ─────────────────────────
783 //
784 // The new `compose_unary` reassociates the per-mask Faà di Bruno set-partition
785 // sum into a degree-4 polynomial in the subset-convolution power of the
786 // non-constant part. These tests are the accuracy gate: a double-double
787 // oracle is the truth, and the new path's error-vs-truth must be `≤` the old
788 // naive partition sum's error-vs-truth on every randomised program.
789
790 /// Deterministic xorshift64* — no `rand` dependency in the test.
791 struct Rng(u64);
792 impl Rng {
793 fn next_u64(&mut self) -> u64 {
794 let mut x = self.0;
795 x ^= x >> 12;
796 x ^= x << 25;
797 x ^= x >> 27;
798 self.0 = x;
799 x.wrapping_mul(0x2545F4914F6CDD1D)
800 }
801 /// Uniform in `[-scale, scale]`.
802 fn signed(&mut self, scale: f64) -> f64 {
803 let u = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64; // [0,1)
804 (2.0 * u - 1.0) * scale
805 }
806 }
807
808 // ── A double-double oracle for the exact (order-4 truncated) composition ──
809
810 #[inline]
811 fn two_prod(a: f64, b: f64) -> (f64, f64) {
812 let p = a * b;
813 (p, a.mul_add(b, -p))
814 }
815 #[inline]
816 fn dd_two_sum(a: f64, b: f64) -> (f64, f64) {
817 let s = a + b;
818 let bb = s - a;
819 (s, (a - (s - bb)) + (b - bb))
820 }
821 #[derive(Clone, Copy)]
822 struct Dd {
823 hi: f64,
824 lo: f64,
825 }
826 impl Dd {
827 fn from(x: f64) -> Self {
828 Self { hi: x, lo: 0.0 }
829 }
830 fn mul_f64(self, b: f64) -> Self {
831 let (p, e) = two_prod(self.hi, b);
832 let lo = self.lo.mul_add(b, e);
833 let s = p + lo;
834 Self {
835 hi: s,
836 lo: (p - s) + lo,
837 }
838 }
839 fn add(self, o: Self) -> Self {
840 let (s, e) = dd_two_sum(self.hi, o.hi);
841 let (s2, e2) = dd_two_sum(self.lo, o.lo);
842 let lo = e + s2;
843 let h1 = s + lo;
844 let l1 = (s - h1) + lo;
845 let lo2 = l1 + e2;
846 let h = h1 + lo2;
847 Self {
848 hi: h,
849 lo: (h1 - h) + lo2,
850 }
851 }
852 /// `|self - x|` to ~double precision in the residual (Sterbenz: `x` and
853 /// `hi` agree to ~53 bits, so `x - hi` is essentially exact).
854 fn abs_err_to(self, x: f64) -> f64 {
855 ((x - self.hi) - self.lo).abs()
856 }
857 }
858
859 /// High-precision truth for `compose_unary` via the set-partition reference,
860 /// every product and sum carried in double-double.
861 fn compose_truth(coeffs: &[f64], derivs: [f64; DERIVS]) -> Vec<Dd> {
862 let count = coeffs.len();
863 let n_dirs = count.trailing_zeros() as usize;
864 let tables = partition_tables(n_dirs);
865 let mut out = vec![Dd::from(0.0); count];
866 let mut remap = vec![0usize; count];
867 let mut pos = [0usize; 64];
868 for (mask, slot) in out.iter_mut().enumerate() {
869 if mask == 0 {
870 *slot = Dd::from(derivs[0]);
871 continue;
872 }
873 let mut npos = 0usize;
874 let mut m = mask;
875 while m != 0 {
876 pos[npos] = m.trailing_zeros() as usize;
877 npos += 1;
878 m &= m - 1;
879 }
880 remap[0] = 0;
881 for cb in 1usize..(1usize << npos) {
882 let low = cb.trailing_zeros() as usize;
883 remap[cb] = remap[cb & (cb - 1)] | (1usize << pos[low]);
884 }
885 let table = &tables[npos];
886 let mut total = Dd::from(0.0);
887 for &(off, order) in table.parts.iter() {
888 let order = order as usize;
889 let mut prod = Dd::from(derivs[order]);
890 for &cb in &table.flat[off..off + order] {
891 prod = prod.mul_f64(coeffs[remap[cb as usize]]);
892 }
893 total = total.add(prod);
894 }
895 *slot = total;
896 }
897 out
898 }
899
900 /// Build a random composite jet so the composition input is a realistic
901 /// non-trivial multilinear element (not just seeded directions).
902 fn random_inner(n_dirs: usize, rng: &mut Rng) -> MultiDirJet {
903 let base = rng.signed(0.8);
904 let first: Vec<f64> = (0..n_dirs).map(|_| rng.signed(0.6)).collect();
905 let a = MultiDirJet::linear(n_dirs, base, &first);
906 let b = MultiDirJet::linear(
907 n_dirs,
908 rng.signed(0.7),
909 &(0..n_dirs).map(|_| rng.signed(0.5)).collect::<Vec<_>>(),
910 );
911 // a*b + a populates the full cross-mask spectrum.
912 a.mul(&b).add(&a)
913 }
914
915 #[test]
916 fn compose_unary_matches_partition_reference_simple() {
917 // exp-like stack on a 2-direction cross jet: every coeff agrees with the
918 // direct set-partition reference to a tight tolerance.
919 let j = MultiDirJet::linear(2, 0.3, &[0.5, -0.4]).mul(&MultiDirJet::linear(
920 2,
921 -0.2,
922 &[0.1, 0.7],
923 ));
924 let d = [0.9_f64, 1.1, -0.7, 0.4, -0.25];
925 let got = j.compose_unary(d);
926 let want = compose_unary_partition_reference(&j.coeffs, d);
927 for (mask, (&g, &w)) in got.coeffs.iter().zip(want.iter()).enumerate() {
928 let tol = 1e-13 * w.abs().max(1.0);
929 assert!(
930 (g - w).abs() <= tol,
931 "mask {mask}: got={g:.17e} want={w:.17e}"
932 );
933 }
934 }
935
936 #[test]
937 fn compose_unary_accuracy_beats_partition_sum_vs_double_double() {
938 // The accuracy gate. Over many random programs at every K used in
939 // production, the new path's error-vs-truth is never worse than the old
940 // naive partition sum's, and is a strict improvement in aggregate.
941 let mut rng = Rng(0x1234_5678_9abc_def0);
942 let mut sum_new = 0.0f64;
943 let mut sum_old = 0.0f64;
944 for &n_dirs in &[2usize, 3, 4, 6, 8] {
945 for _ in 0..200 {
946 let inner = random_inner(n_dirs, &mut rng);
947 let d = [
948 rng.signed(1.5),
949 rng.signed(1.5),
950 rng.signed(2.0),
951 rng.signed(3.0),
952 rng.signed(4.0),
953 ];
954 let new = inner.compose_unary(d);
955 let old = compose_unary_partition_reference(&inner.coeffs, d);
956 let truth = compose_truth(&inner.coeffs, d);
957 for mask in 0..inner.coeffs.len() {
958 let en = truth[mask].abs_err_to(new.coeffs[mask]);
959 let eo = truth[mask].abs_err_to(old[mask]);
960 sum_new += en;
961 sum_old += eo;
962 // Per-coefficient: new is never materially worse. The 4 ULP
963 // slack absorbs the rare tie where a differently-grouped but
964 // equally-valid rounding lands one ULP either way.
965 let scale = truth[mask].hi.abs().max(1.0);
966 assert!(
967 en <= eo + 4.0 * f64::EPSILON * scale,
968 "K={n_dirs} mask={mask}: new_err={en:.3e} old_err={eo:.3e}"
969 );
970 }
971 }
972 }
973 // Aggregate: the compensated reassociation is a real improvement.
974 assert!(
975 sum_new <= sum_old,
976 "aggregate error regressed: new={sum_new:.6e} old={sum_old:.6e}"
977 );
978 eprintln!(
979 "compose_unary accuracy: total |err| new={sum_new:.6e} old={sum_old:.6e} \
980 (improvement {:.2}x)",
981 sum_old / sum_new.max(f64::MIN_POSITIVE)
982 );
983 }
984
985 /// `v^{⊛k}[mask] = k! · Σ_{π ⊢ mask, |π| = k} Π_{B ∈ π} v[B]` by direct
986 /// enumeration of the set partitions of `mask` — the *definition* the pointed
987 /// recurrence claims to compute.
988 ///
989 /// Returns `[v², v³, v⁴]` and, per mask, the forward error the pair of
990 /// evaluations is jointly entitled to: this reference accumulates `n` terms
991 /// naively (Wilkinson: `n·u` times the sum of the term magnitudes) after up to
992 /// three roundings per product, and the compensated walk is good to ~`u`, so
993 /// `(n + 4)·EPSILON·Σ|term|` bounds their difference. It is derived from the
994 /// enumeration, not fitted to an observed failure.
995 fn brute_force_multilinear_powers(v: &[f64]) -> ([Vec<f64>; 3], [Vec<f64>; 3]) {
996 fn recurse(
997 elem: usize,
998 bits: &[usize],
999 blocks: &mut Vec<usize>,
1000 v: &[f64],
1001 acc: &mut [f64; 5],
1002 acc_abs: &mut [f64; 5],
1003 acc_count: &mut [u32; 5],
1004 ) {
1005 if blocks.len() > 4 {
1006 return;
1007 }
1008 if elem == bits.len() {
1009 let order = blocks.len();
1010 if order >= 2 {
1011 let product: f64 = blocks.iter().map(|&b| v[b]).product();
1012 acc[order] += product;
1013 acc_abs[order] += product.abs();
1014 acc_count[order] += 1;
1015 }
1016 return;
1017 }
1018 let bit = 1usize << bits[elem];
1019 for slot in 0..blocks.len() {
1020 blocks[slot] |= bit;
1021 recurse(elem + 1, bits, blocks, v, acc, acc_abs, acc_count);
1022 blocks[slot] &= !bit;
1023 }
1024 blocks.push(bit);
1025 recurse(elem + 1, bits, blocks, v, acc, acc_abs, acc_count);
1026 blocks.pop();
1027 }
1028 let count = v.len();
1029 let mut powers = [vec![0.0; count], vec![0.0; count], vec![0.0; count]];
1030 let mut entitled = [vec![0.0; count], vec![0.0; count], vec![0.0; count]];
1031 let factorial = [1.0, 1.0, 2.0, 6.0, 24.0];
1032 for mask in 1..count {
1033 let mut bits = Vec::new();
1034 let mut rest = mask;
1035 while rest != 0 {
1036 bits.push(rest.trailing_zeros() as usize);
1037 rest &= rest - 1;
1038 }
1039 let mut acc = [0.0f64; 5];
1040 let mut acc_abs = [0.0f64; 5];
1041 let mut acc_count = [0u32; 5];
1042 recurse(
1043 0,
1044 &bits,
1045 &mut Vec::new(),
1046 v,
1047 &mut acc,
1048 &mut acc_abs,
1049 &mut acc_count,
1050 );
1051 for order in 2..=4usize {
1052 powers[order - 2][mask] = factorial[order] * acc[order];
1053 entitled[order - 2][mask] = (f64::from(acc_count[order]) + 4.0)
1054 * f64::EPSILON
1055 * factorial[order]
1056 * acc_abs[order];
1057 }
1058 }
1059 (powers, entitled)
1060 }
1061
1062 /// The pointed (lowest-set-bit) recurrence is an *identity*, not an
1063 /// approximation: pinning the block that owns the lowest set bit and
1064 /// multiplying by `k` reproduces the `k`-fold multilinear power of the
1065 /// brute-force set-partition definition, to the forward error the two
1066 /// evaluations are jointly entitled to.
1067 ///
1068 /// This is the gate on the recurrence the module header derives. The header's
1069 /// cost claims are only worth anything if the cheaper walk computes the same
1070 /// quantity, and that is what this pins.
1071 #[test]
1072 fn pointed_recurrence_reproduces_the_brute_force_multilinear_powers() {
1073 let mut rng = Rng(0x0be1_1ab0_1a5e_c001);
1074 for n_dirs in 1usize..=6 {
1075 for _ in 0..24 {
1076 let count = 1usize << n_dirs;
1077 let mut v: Vec<f64> = (0..count).map(|_| rng.signed(1.0)).collect();
1078 v[0] = 0.0;
1079 let mut p2 = vec![f64::NAN; count];
1080 let mut p3 = vec![f64::NAN; count];
1081 let mut p4 = vec![f64::NAN; count];
1082 multilinear_powers_into(&v, &mut p2, &mut p3, &mut p4);
1083 let (want, entitled) = brute_force_multilinear_powers(&v);
1084 for (order, got) in [&p2, &p3, &p4].iter().enumerate() {
1085 for mask in 0..count {
1086 // Graded against the forward error the two evaluations are
1087 // jointly entitled to (see the reference above), which is a
1088 // derived bound rather than a fitted tolerance. Where the
1089 // power vanishes identically the bound is zero and the
1090 // agreement must be exact.
1091 let tolerance = entitled[order][mask];
1092 assert!(
1093 (got[mask] - want[order][mask]).abs() <= tolerance,
1094 "K={n_dirs} k={} mask={mask}: pointed={:.17e} partitions={:.17e} \
1095 tol={tolerance:.3e}",
1096 order + 2,
1097 got[mask],
1098 want[order][mask]
1099 );
1100 }
1101 }
1102 }
1103 }
1104 }
1105
1106 /// The two schedules' operation counts, recomputed from the enumeration
1107 /// itself. This is the machine-independent statement of what each schedule
1108 /// costs and where the convolution path becomes the cheaper one; the
1109 /// wall-clock test below can only corroborate it.
1110 ///
1111 /// It exists because the header once claimed the convolution schedule was
1112 /// "~3× fewer FLOPs than the per-mask partition gather" full stop, and a
1113 /// wall-clock test asserted a speedup from `K = 6`. Both were false of the
1114 /// schedule then in the file: three full subset convolutions cost ~9× the
1115 /// gather at the `K = 4` the production entry point uses, and did not come
1116 /// out ahead until `K = 10`. A counted model cannot drift the way a prose
1117 /// factor did.
1118 #[test]
1119 fn compose_unary_work_model_matches_the_closed_form() {
1120 // Dot2: mul, FMA error, TwoSum (6), two carry adds.
1121 const DOT2_FLOPS: u64 = 10;
1122 // `scaled_compensated`: k·s, its FMA error, k·c, and two adds.
1123 const MULTIPLICITY_FLOPS: u64 = 5;
1124
1125 let binom = |n: u32, r: u32| -> u64 {
1126 (0..r).fold(1u64, |acc, i| acc * u64::from(n - i) / (u64::from(i) + 1))
1127 };
1128
1129 // Replay of `multilinear_powers_into`'s enumeration — same mask loop,
1130 // same lowest-bit pin, same descending walk over the submasks of
1131 // `mask ^ lowest`, same popcount gates — counting Dot2 steps instead of
1132 // performing them. This is what makes the closed form below a claim about
1133 // the kernel rather than about itself.
1134 fn walked_terms(n_dirs: u32) -> u64 {
1135 let count = 1usize << n_dirs;
1136 let mut steps = 0u64;
1137 for mask in 1..count {
1138 let lowest = mask & mask.wrapping_neg();
1139 let rest = mask ^ lowest;
1140 if rest == 0 {
1141 continue;
1142 }
1143 let mut t = rest;
1144 while t != 0 {
1145 steps += 1;
1146 let popcount = (t as u64).count_ones();
1147 if popcount >= 2 {
1148 steps += 1;
1149 if popcount >= 3 {
1150 steps += 1;
1151 }
1152 }
1153 t = (t - 1) & rest;
1154 }
1155 }
1156 steps
1157 }
1158
1159 // Closed form: per mask of popcount p ≥ 2 and each power k ≤ p, the
1160 // submasks t of `mask ^ ℓ` (a (p-1)-set) with popcount(t) ≥ k-1.
1161 let pointed_terms = |n_dirs: u32| -> u64 {
1162 (2..=n_dirs)
1163 .map(|p| {
1164 let per_mask: u64 = (2..=4u32)
1165 .filter(|k| *k <= p)
1166 .map(|k| (k - 1..=p - 1).map(|j| binom(p - 1, j)).sum::<u64>())
1167 .sum();
1168 binom(n_dirs, p) * per_mask
1169 })
1170 .sum()
1171 };
1172 let pointed_flops = |n_dirs: u32| -> u64 {
1173 (2..=n_dirs)
1174 .map(|p| {
1175 let per_mask: u64 = (2..=4u32)
1176 .filter(|k| *k <= p)
1177 .map(|k| (k - 1..=p - 1).map(|j| binom(p - 1, j)).sum::<u64>())
1178 .sum();
1179 binom(n_dirs, p) * (per_mask * DOT2_FLOPS + 3 * MULTIPLICITY_FLOPS)
1180 })
1181 .sum()
1182 };
1183
1184 // The schedule this replaced: three full subset convolutions v², v³=v²⊛v,
1185 // v⁴=v²⊛v², each pruned at popcount < k, each surviving mask walking all
1186 // 2^popcount of its submasks.
1187 let full_convolution_terms = |n_dirs: u32| -> u64 {
1188 (2..=4u32)
1189 .map(|k| {
1190 (k..=n_dirs)
1191 .map(|p| binom(n_dirs, p) * (1u64 << p))
1192 .sum::<u64>()
1193 })
1194 .sum()
1195 };
1196
1197 // Partition gather: per mask of popcount p, every set partition of a
1198 // p-set into 1..=4 blocks contributes |π| multiplies and one add. This
1199 // omits the gather's own `2^p` per-mask index remap, so it is a lower
1200 // bound on the gather and every comparison below is against its best case.
1201 fn stirling(n: u32, blocks: u32) -> u64 {
1202 let (n, blocks) = (n as usize, blocks as usize);
1203 let mut s = vec![vec![0u64; blocks + 1]; n + 1];
1204 s[0][0] = 1;
1205 for i in 1..=n {
1206 for j in 1..=blocks {
1207 s[i][j] = (j as u64) * s[i - 1][j] + s[i - 1][j - 1];
1208 }
1209 }
1210 s[n][blocks]
1211 }
1212 let gather_terms = |n_dirs: u32| -> u64 {
1213 1 + (1..=n_dirs)
1214 .map(|p| binom(n_dirs, p) * (1..=4u32).map(|b| stirling(p, b)).sum::<u64>())
1215 .sum::<u64>()
1216 };
1217 let gather_flops = |n_dirs: u32| -> u64 {
1218 1 + (1..=n_dirs)
1219 .map(|p| {
1220 binom(n_dirs, p)
1221 * (1..=4u32)
1222 .map(|b| stirling(p, b) * (u64::from(b) + 1))
1223 .sum::<u64>()
1224 })
1225 .sum::<u64>()
1226 };
1227
1228 // (a) The closed form describes the walk the kernel actually performs.
1229 for n_dirs in 2..=12u32 {
1230 assert_eq!(
1231 walked_terms(n_dirs),
1232 pointed_terms(n_dirs),
1233 "K={n_dirs}: closed form disagrees with the replayed enumeration"
1234 );
1235 }
1236
1237 // (b) Against the three full subset convolutions this replaced: exactly
1238 // 4× fewer terms across the whole range production runs at — one factor
1239 // of 2 from pinning the block that owns the lowest set bit, one from
1240 // walking submasks of `mask ^ ℓ` rather than of `mask`.
1241 for n_dirs in 2..=4u32 {
1242 assert_eq!(
1243 full_convolution_terms(n_dirs),
1244 4 * pointed_terms(n_dirs),
1245 "K={n_dirs}: the replaced schedule should be exactly 4x this one"
1246 );
1247 }
1248 for n_dirs in 2..=14u32 {
1249 assert!(
1250 pointed_terms(n_dirs) < full_convolution_terms(n_dirs),
1251 "K={n_dirs}: the pointed recurrence must never walk more terms \
1252 than the full convolutions it replaced ({} vs {})",
1253 pointed_terms(n_dirs),
1254 full_convolution_terms(n_dirs)
1255 );
1256 }
1257
1258 // (c) Against the partition gather: strictly fewer terms at every K,
1259 // including the production K = 4 (34 against 52). The combinatorial
1260 // deficit that made the old schedule indefensible at small K is gone.
1261 for n_dirs in 2..=14u32 {
1262 assert!(
1263 pointed_terms(n_dirs) < gather_terms(n_dirs),
1264 "K={n_dirs}: pointed schedule should walk fewer terms than the \
1265 partition gather ({} vs {})",
1266 pointed_terms(n_dirs),
1267 gather_terms(n_dirs)
1268 );
1269 }
1270 assert_eq!((pointed_terms(4), gather_terms(4)), (34, 52));
1271
1272 // (d) What remains at the production K = 4 is the compensation premium
1273 // and nothing else: 10 flops a term against the gather's ~3, over fewer
1274 // terms, for a 3.3x flop ratio. That is the price of the ~double-precision
1275 // accumulation the accuracy gate pins — it is bought, not free.
1276 let production_ratio = pointed_flops(4) as f64 / gather_flops(4) as f64;
1277 assert!(
1278 (production_ratio - 3.34).abs() < 0.05,
1279 "at the production K=4 the pointed schedule should cost ~3.34x the \
1280 partition gather's flops, got {production_ratio:.2}x"
1281 );
1282
1283 // (e) The flop crossover, which the pointed recurrence moves from K = 10
1284 // (three full convolutions) to K = 8.
1285 for n_dirs in 2..=7u32 {
1286 assert!(
1287 pointed_flops(n_dirs) > gather_flops(n_dirs),
1288 "K={n_dirs}: below the crossover the compensated schedule is still \
1289 the more expensive one ({} vs {})",
1290 pointed_flops(n_dirs),
1291 gather_flops(n_dirs)
1292 );
1293 }
1294 for n_dirs in 8..=14u32 {
1295 assert!(
1296 pointed_flops(n_dirs) < gather_flops(n_dirs),
1297 "K={n_dirs}: at and above the K=8 crossover the compensated \
1298 schedule should also be the cheaper one ({} vs {})",
1299 pointed_flops(n_dirs),
1300 gather_flops(n_dirs)
1301 );
1302 }
1303 }
1304
1305 #[test]
1306 fn compose_unary_speedup_over_partition_sum() {
1307 // Measure ns/call new vs. the previous partition-sum implementation.
1308 // Prints the multiple at every K; the assert is placed where the win
1309 // is large enough that no box can argue with it.
1310 //
1311 // This is corroboration, not the contract. The contract is
1312 // `compose_unary_work_model_matches_the_closed_form`, which counts
1313 // operations: a count is the same on every machine, and a wall clock is
1314 // not. The assert used to sit at `n_dirs >= 6` and was red at main,
1315 // because the schedule then in the file cost ~6x the gather's flops
1316 // there and could not win however the threshold was moved. Keep any
1317 // guard here far past the crossover the counted model reports (K = 8
1318 // for the pointed recurrence) and let the printed line carry the rest.
1319 use std::time::Instant;
1320 let mut rng = Rng(0xfeed_face_dead_beef);
1321 for &n_dirs in &[2usize, 4, 6, 8, 12] {
1322 // The per-call cost spans ~5 orders of magnitude across this K
1323 // range (both schedules are exponential in K), so the sample count
1324 // has to shrink with K or the K=12 arm alone would run for hours.
1325 let n_inputs = if n_dirs >= 12 { 4usize } else { 256 };
1326 let inputs: Vec<(MultiDirJet, [f64; DERIVS])> = (0..n_inputs)
1327 .map(|_| {
1328 (
1329 random_inner(n_dirs, &mut rng),
1330 [
1331 rng.signed(1.5),
1332 rng.signed(1.5),
1333 rng.signed(2.0),
1334 rng.signed(3.0),
1335 rng.signed(4.0),
1336 ],
1337 )
1338 })
1339 .collect();
1340 let iters = if n_dirs >= 12 { 3usize } else { 200 };
1341 // Warm the scratch / partition tables.
1342 for (j, d) in &inputs {
1343 std::hint::black_box(j.compose_unary(*d));
1344 std::hint::black_box(compose_unary_partition_reference(&j.coeffs, *d));
1345 }
1346 let t0 = Instant::now();
1347 for _ in 0..iters {
1348 for (j, d) in &inputs {
1349 std::hint::black_box(j.compose_unary(*d));
1350 }
1351 }
1352 let new_ns = t0.elapsed().as_nanos() as f64 / (iters * inputs.len()) as f64;
1353 let t1 = Instant::now();
1354 for _ in 0..iters {
1355 for (j, d) in &inputs {
1356 std::hint::black_box(compose_unary_partition_reference(&j.coeffs, *d));
1357 }
1358 }
1359 let old_ns = t1.elapsed().as_nanos() as f64 / (iters * inputs.len()) as f64;
1360 eprintln!(
1361 "compose_unary K={n_dirs}: new={new_ns:.1} ns/call old={old_ns:.1} ns/call \
1362 speedup={:.2}x",
1363 old_ns / new_ns
1364 );
1365 // Guard only in an optimised build, and only where the counted
1366 // model says the compensated schedule does ~7x less arithmetic —
1367 // a margin no shared box's load can invert. Debug builds are
1368 // dominated by fixed per-call overhead and are not a guard either.
1369 if !cfg!(debug_assertions) && n_dirs >= 12 {
1370 assert!(
1371 new_ns < old_ns,
1372 "K={n_dirs} is far past the crossover the work model reports, \
1373 so the compensated schedule must win here: new={new_ns:.1}ns \
1374 old={old_ns:.1}ns"
1375 );
1376 }
1377 }
1378 }
1379}