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