Skip to main content

gam_math/
order2_graph.rs

1//! Compiled value/gradient/Hessian lowering for runtime row expressions.
2//!
3//! [`Order2Graph`] implements the same [`RuntimeJetScalar`] algebra as the eager
4//! packed jets, but records a small scalar DAG instead of propagating a dense
5//! Hessian through every intermediate. Each node carries its value and primary
6//! gradient. Once the scalar output is known, one reverse sweep computes node
7//! adjoints and accumulates each nonlinear node's local curvature exactly once.
8//!
9//! This is the universal second-order chain rule for a scalar DAG. Families own
10//! only their one generic row expression and certified unary derivative stacks;
11//! this module owns the compiled lowering schedule.
12
13use std::cell::UnsafeCell;
14use std::mem::MaybeUninit;
15
16use crate::jet_scalar::{
17    aggregate_shared_source_derivatives, canonical_shared_source_schedule, Order2,
18    RuntimeJetScalar, SymmetricQuadraticCoefficients,
19};
20
21#[derive(Clone, Copy, Debug)]
22struct GraphNode {
23    value: f64,
24    support: u16,
25    edge_start: u16,
26    edge_len: u16,
27    primary_axis: u8,
28}
29
30#[derive(Clone, Copy, Debug)]
31enum CurvatureEvent {
32    RankOne {
33        owner: u8,
34        input: u8,
35        second: f64,
36    },
37    Cross {
38        owner: u8,
39        left: u8,
40        right: u8,
41    },
42    Diagonal {
43        owner: u8,
44        term_start: u16,
45        len: u16,
46    },
47    Projected {
48        owner: u8,
49        curvature_start: u16,
50        support: u16,
51    },
52}
53
54const MAX_PRIMARY_DIMENSION: usize = 16;
55const MAX_QUADRATIC_ARITY: usize = 32;
56const MAX_GRAPH_NODES: usize = 64;
57const MAX_GRAPH_EDGES: usize = MAX_GRAPH_NODES * MAX_GRAPH_NODES;
58const NO_PRIMARY_AXIS: u8 = u8::MAX;
59const MAX_PROJECTED_CURVATURE_VALUES: usize =
60    MAX_GRAPH_NODES * MAX_PRIMARY_DIMENSION * (MAX_PRIMARY_DIMENSION + 1) / 2;
61const EMPTY_NODE: GraphNode = GraphNode {
62    value: 0.0,
63    support: 0,
64    edge_start: 0,
65    edge_len: 0,
66    primary_axis: NO_PRIMARY_AXIS,
67};
68const EMPTY_CURVATURE_EVENT: CurvatureEvent = CurvatureEvent::RankOne {
69    owner: 0,
70    input: 0,
71    second: 0.0,
72};
73
74#[inline]
75fn packed_index(index: usize) -> u8 {
76    u8::try_from(index).expect("graph index must fit the fixed-capacity tape representation")
77}
78
79#[inline]
80fn packed_offset(offset: usize) -> u16 {
81    u16::try_from(offset).expect("graph offset must fit the fixed-capacity tape representation")
82}
83
84/// Fixed-capacity scalar scratch with only its logical prefix initialized.
85///
86/// Quadratic arity is usually far below the hard node capacity. Keeping the
87/// inactive suffix uninitialized prevents full-capacity scratch clears while
88/// retaining allocation-free, checked storage for the general graph contract.
89struct InlineScalars {
90    slots: [MaybeUninit<f64>; MAX_QUADRATIC_ARITY],
91    len: usize,
92}
93
94impl InlineScalars {
95    #[inline(always)]
96    fn initialize_zeros(storage: &mut MaybeUninit<Self>, len: usize) -> &mut Self {
97        assert!(
98            len <= MAX_QUADRATIC_ARITY,
99            "inline scalar capacity exceeded"
100        );
101        let scalars = storage.as_mut_ptr();
102        // SAFETY: every bit pattern is valid for `MaybeUninit<f64>`, so writing
103        // `len` makes the enclosing `InlineScalars` initialized. Only the
104        // logical prefix is then exposed as `f64` by the accessors below.
105        unsafe {
106            std::ptr::addr_of_mut!((*scalars).len).write(len);
107            let slots = &mut *std::ptr::addr_of_mut!((*scalars).slots);
108            for slot in &mut slots[..len] {
109                slot.write(0.0);
110            }
111            &mut *scalars
112        }
113    }
114
115    #[inline(always)]
116    fn as_slice(&self) -> &[f64] {
117        // SAFETY: exactly the prefix `0..len` is initialized by `initialize_zeros`;
118        // `MaybeUninit<f64>` has the same layout and alignment as `f64`.
119        unsafe { std::slice::from_raw_parts(self.slots.as_ptr().cast::<f64>(), self.len) }
120    }
121
122    #[inline(always)]
123    fn as_mut_slice(&mut self) -> &mut [f64] {
124        // SAFETY: the logical prefix is initialized and uniquely borrowed.
125        unsafe { std::slice::from_raw_parts_mut(self.slots.as_mut_ptr().cast::<f64>(), self.len) }
126    }
127}
128
129#[derive(Debug)]
130struct GraphTape {
131    dimension: usize,
132    node_len: usize,
133    edge_len: usize,
134    event_len: usize,
135    diagonal_len: usize,
136    projected_curvature_len: usize,
137    nodes: [GraphNode; MAX_GRAPH_NODES],
138    gradients: [f64; MAX_GRAPH_NODES * MAX_PRIMARY_DIMENSION],
139    adjoints: [f64; MAX_GRAPH_NODES],
140    edge_parents: [u8; MAX_GRAPH_EDGES],
141    edge_firsts: [f64; MAX_GRAPH_EDGES],
142    events: [CurvatureEvent; MAX_GRAPH_NODES],
143    diagonal_inputs: [u8; MAX_GRAPH_EDGES],
144    diagonal_seconds: [f64; MAX_GRAPH_EDGES],
145    projected_curvatures: [f64; MAX_PROJECTED_CURVATURE_VALUES],
146}
147
148impl GraphTape {
149    fn new_boxed() -> Box<Self> {
150        let mut tape = Box::<Self>::new_uninit();
151        let pointer = tape.as_mut_ptr();
152        // SAFETY: each field is initialized in place before `assume_init`.
153        // In-place initialization is important here: the fixed-capacity tape is
154        // deliberately large and must never be materialized as a stack value.
155        unsafe {
156            std::ptr::addr_of_mut!((*pointer).dimension).write(0);
157            std::ptr::addr_of_mut!((*pointer).node_len).write(0);
158            std::ptr::addr_of_mut!((*pointer).edge_len).write(0);
159            std::ptr::addr_of_mut!((*pointer).event_len).write(0);
160            std::ptr::addr_of_mut!((*pointer).diagonal_len).write(0);
161            std::ptr::addr_of_mut!((*pointer).projected_curvature_len).write(0);
162
163            for node in &mut *std::ptr::addr_of_mut!((*pointer).nodes) {
164                std::ptr::write(node, EMPTY_NODE);
165            }
166            std::ptr::addr_of_mut!((*pointer).gradients).write_bytes(0, 1);
167            std::ptr::addr_of_mut!((*pointer).adjoints).write_bytes(0, 1);
168            std::ptr::addr_of_mut!((*pointer).edge_parents).write_bytes(0, 1);
169            std::ptr::addr_of_mut!((*pointer).edge_firsts).write_bytes(0, 1);
170            for event in &mut *std::ptr::addr_of_mut!((*pointer).events) {
171                std::ptr::write(event, EMPTY_CURVATURE_EVENT);
172            }
173            std::ptr::addr_of_mut!((*pointer).diagonal_inputs).write_bytes(0, 1);
174            std::ptr::addr_of_mut!((*pointer).diagonal_seconds).write_bytes(0, 1);
175            std::ptr::addr_of_mut!((*pointer).projected_curvatures).write_bytes(0, 1);
176            tape.assume_init()
177        }
178    }
179}
180
181trait HessianSink<const K: usize> {
182    fn reset(&mut self);
183    fn add_upper(&mut self, row: usize, column: usize, value: f64);
184    fn reflect_upper(&mut self);
185}
186
187struct ArrayHessianSink<'a, const K: usize>(&'a mut [[f64; K]; K]);
188
189impl<const K: usize> HessianSink<K> for ArrayHessianSink<'_, K> {
190    #[inline(always)]
191    fn reset(&mut self) {
192        // `into_order2` is the sole constructor and supplies `Tower2::zero()`.
193    }
194
195    #[inline(always)]
196    fn add_upper(&mut self, row: usize, column: usize, value: f64) {
197        self.0[row][column] += value;
198    }
199
200    #[inline(always)]
201    fn reflect_upper(&mut self) {
202        for row in 0..K {
203            for column in row + 1..K {
204                self.0[column][row] = self.0[row][column];
205            }
206        }
207    }
208}
209
210struct RowMajorHessianSink<'a, const K: usize>(&'a mut [f64]);
211
212impl<const K: usize> HessianSink<K> for RowMajorHessianSink<'_, K> {
213    #[inline(always)]
214    fn reset(&mut self) {
215        self.0.fill(0.0);
216    }
217
218    #[inline(always)]
219    fn add_upper(&mut self, row: usize, column: usize, value: f64) {
220        self.0[row * K + column] += value;
221    }
222
223    #[inline(always)]
224    fn reflect_upper(&mut self) {
225        for row in 0..K {
226            for column in row + 1..K {
227                self.0[column * K + row] = self.0[row * K + column];
228            }
229        }
230    }
231}
232
233/// Reusable storage for a compiled scalar DAG.
234///
235/// Reset between rows. The boxed tape has checked fixed capacities, so every
236/// row after worker construction performs no tape or reverse-sweep allocation
237/// and no growth branches.
238#[derive(Debug)]
239pub struct Order2GraphWorkspace {
240    tape: UnsafeCell<Box<GraphTape>>,
241}
242
243impl Default for Order2GraphWorkspace {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl Order2GraphWorkspace {
250    /// Empty reusable graph storage.
251    #[must_use]
252    pub fn new() -> Self {
253        Self {
254            tape: UnsafeCell::new(GraphTape::new_boxed()),
255        }
256    }
257
258    /// Reclaim the prior row in constant time.
259    pub fn reset(&mut self, dimension: usize) {
260        assert!(
261            dimension <= MAX_PRIMARY_DIMENSION,
262            "compiled graph supports at most {MAX_PRIMARY_DIMENSION} primaries"
263        );
264        let tape = self.tape.get_mut().as_mut();
265        tape.dimension = dimension;
266        tape.node_len = 0;
267        tape.edge_len = 0;
268        tape.event_len = 0;
269        tape.diagonal_len = 0;
270        tape.projected_curvature_len = 0;
271    }
272
273    /// Shared tape access. `UnsafeCell` removes the dynamic borrow state that a
274    /// `RefCell` would place in every scalar operation. The workspace is not
275    /// `Sync`, every mutation is completed before a scalar handle is returned,
276    /// and lowering starts only after expression construction, so no aliases to
277    /// the tape contents escape these two private accessors.
278    #[inline(always)]
279    fn tape(&self) -> &GraphTape {
280        // SAFETY: the workspace is not Sync, scalar construction completes each
281        // mutation before returning, and lowering starts only after construction,
282        // so no mutable reference aliases this shared tape reference.
283        unsafe { (&*self.tape.get()).as_ref() }
284    }
285
286    #[inline(always)]
287    fn tape_mut(&self) -> &mut GraphTape {
288        // SAFETY: the workspace is not Sync and every tape mutation is serialized
289        // by the expression-construction/lowering protocol described on `tape`.
290        unsafe { (&mut *self.tape.get()).as_mut() }
291    }
292
293    #[inline(always)]
294    fn push<const K: usize>(
295        tape: &mut GraphTape,
296        value: f64,
297        support: u16,
298        gradient: [f64; K],
299        edge_start: usize,
300    ) -> usize {
301        assert_eq!(tape.dimension, K, "compiled graph dimension mismatch");
302        assert!(
303            tape.node_len < MAX_GRAPH_NODES,
304            "compiled graph node capacity exceeded"
305        );
306        let node = tape.node_len;
307        let edge_len = tape.edge_len - edge_start;
308        tape.nodes[node] = GraphNode {
309            value,
310            support,
311            edge_start: packed_offset(edge_start),
312            edge_len: packed_offset(edge_len),
313            primary_axis: NO_PRIMARY_AXIS,
314        };
315        tape.gradients[node * K..(node + 1) * K].copy_from_slice(&gradient);
316        tape.node_len += 1;
317        node
318    }
319
320    #[inline(always)]
321    fn push_edge(tape: &mut GraphTape, parent: usize, first: f64) {
322        assert!(
323            tape.edge_len < MAX_GRAPH_EDGES,
324            "compiled graph edge capacity exceeded"
325        );
326        assert!(
327            parent < tape.node_len,
328            "compiled graph edge parent must name an existing node"
329        );
330        tape.edge_parents[tape.edge_len] = packed_index(parent);
331        tape.edge_firsts[tape.edge_len] = first;
332        tape.edge_len += 1;
333    }
334
335    #[inline(always)]
336    fn push_event(tape: &mut GraphTape, event: CurvatureEvent) {
337        assert!(
338            tape.event_len < MAX_GRAPH_NODES,
339            "compiled graph curvature-event capacity exceeded"
340        );
341        tape.events[tape.event_len] = event;
342        tape.event_len += 1;
343    }
344
345    #[inline(always)]
346    fn push_diagonal_term(tape: &mut GraphTape, input: usize, second: f64) {
347        assert!(
348            tape.diagonal_len < MAX_GRAPH_EDGES,
349            "compiled graph diagonal-term capacity exceeded"
350        );
351        assert!(
352            input < tape.node_len,
353            "compiled graph diagonal input must name an existing node"
354        );
355        tape.diagonal_inputs[tape.diagonal_len] = packed_index(input);
356        tape.diagonal_seconds[tape.diagonal_len] = second;
357        tape.diagonal_len += 1;
358    }
359
360    #[inline(always)]
361    fn lower_into<const K: usize, H: HessianSink<K>>(
362        &self,
363        output: usize,
364        gradient: &mut [f64],
365        hessian: &mut H,
366    ) -> f64 {
367        let tape = self.tape_mut();
368        assert_eq!(tape.dimension, K, "compiled graph dimension mismatch");
369        assert!(output < tape.node_len, "compiled graph output is absent");
370        assert_eq!(gradient.len(), K, "compiled graph gradient width mismatch");
371        tape.adjoints[..tape.node_len].fill(0.0);
372        tape.adjoints[output] = 1.0;
373
374        gradient.copy_from_slice(&tape.gradients[output * K..(output + 1) * K]);
375        hessian.reset();
376
377        for node_index in (0..=output).rev() {
378            let adjoint = tape.adjoints[node_index];
379            if adjoint == 0.0 {
380                continue;
381            }
382            let node = tape.nodes[node_index];
383            let edge_start = node.edge_start as usize;
384            for edge in edge_start..edge_start + node.edge_len as usize {
385                let parent = tape.edge_parents[edge] as usize;
386                tape.adjoints[parent] += adjoint * tape.edge_firsts[edge];
387            }
388        }
389
390        for event_index in (0..tape.event_len).rev() {
391            match tape.events[event_index] {
392                CurvatureEvent::RankOne {
393                    owner,
394                    input,
395                    second,
396                } => {
397                    let owner_adjoint = tape.adjoints[owner as usize];
398                    if owner_adjoint == 0.0 {
399                        continue;
400                    }
401                    let input = input as usize;
402                    let curvature_scale = owner_adjoint * second;
403                    for_each_supported_upper(tape.nodes[input].support, |primary, other| {
404                        hessian.add_upper(
405                            primary,
406                            other,
407                            curvature_scale
408                                * tape.gradients[input * K + primary]
409                                * tape.gradients[input * K + other],
410                        );
411                    });
412                }
413                CurvatureEvent::Cross { owner, left, right } => {
414                    let owner_adjoint = tape.adjoints[owner as usize];
415                    if owner_adjoint == 0.0 {
416                        continue;
417                    }
418                    let left = left as usize;
419                    let right = right as usize;
420                    for_each_supported_upper(
421                        tape.nodes[left].support | tape.nodes[right].support,
422                        |primary, other| {
423                            let left_primary = tape.gradients[left * K + primary];
424                            let right_primary = tape.gradients[right * K + primary];
425                            let curvature = left_primary * tape.gradients[right * K + other]
426                                + right_primary * tape.gradients[left * K + other];
427                            hessian.add_upper(primary, other, owner_adjoint * curvature);
428                        },
429                    );
430                }
431                CurvatureEvent::Diagonal {
432                    owner,
433                    term_start,
434                    len,
435                } => {
436                    let owner_adjoint = tape.adjoints[owner as usize];
437                    if owner_adjoint == 0.0 {
438                        continue;
439                    }
440                    let term_start = term_start as usize;
441                    for term in term_start..term_start + len as usize {
442                        let input = tape.diagonal_inputs[term] as usize;
443                        let curvature_scale = owner_adjoint * tape.diagonal_seconds[term];
444                        if curvature_scale == 0.0 {
445                            continue;
446                        }
447                        for_each_supported_upper(tape.nodes[input].support, |primary, other| {
448                            hessian.add_upper(
449                                primary,
450                                other,
451                                curvature_scale
452                                    * tape.gradients[input * K + primary]
453                                    * tape.gradients[input * K + other],
454                            );
455                        });
456                    }
457                }
458                CurvatureEvent::Projected {
459                    owner,
460                    curvature_start,
461                    support,
462                } => {
463                    let owner_adjoint = tape.adjoints[owner as usize];
464                    if owner_adjoint == 0.0 {
465                        continue;
466                    }
467                    let mut curvature_offset = 0;
468                    let curvature_start = curvature_start as usize;
469                    for_each_supported_upper_by_column(support, |primary, other| {
470                        hessian.add_upper(
471                            primary,
472                            other,
473                            owner_adjoint
474                                * tape.projected_curvatures[curvature_start + curvature_offset],
475                        );
476                        curvature_offset += 1;
477                    });
478                }
479            }
480        }
481
482        hessian.reflect_upper();
483        tape.nodes[output].value
484    }
485}
486
487#[inline(always)]
488fn for_each_supported_upper_by_column(support: u16, mut visit: impl FnMut(usize, usize)) {
489    let mut columns = support;
490    while columns != 0 {
491        let other = columns.trailing_zeros() as usize;
492        columns &= columns - 1;
493        let mut rows = support & (u16::MAX >> (MAX_PRIMARY_DIMENSION - other - 1));
494        while rows != 0 {
495            let primary = rows.trailing_zeros() as usize;
496            rows &= rows - 1;
497            visit(primary, other);
498        }
499    }
500}
501
502/// Packed column-major upper-triangle offset for two axes in `support`.
503#[inline(always)]
504fn supported_upper_offset(support: u16, primary: usize, other: usize) -> usize {
505    let primary_rank = (support & ((1_u16 << primary) - 1)).count_ones() as usize;
506    let other_rank = (support & ((1_u16 << other) - 1)).count_ones() as usize;
507    other_rank * (other_rank + 1) / 2 + primary_rank
508}
509
510#[inline(always)]
511fn for_each_supported_upper(mut rows: u16, mut visit: impl FnMut(usize, usize)) {
512    while rows != 0 {
513        let primary = rows.trailing_zeros() as usize;
514        rows &= rows - 1;
515        let mut columns = rows | (1_u16 << primary);
516        while columns != 0 {
517            let other = columns.trailing_zeros() as usize;
518            columns &= columns - 1;
519            visit(primary, other);
520        }
521    }
522}
523
524/// Const-primary scalar handle into an [`Order2GraphWorkspace`].
525///
526/// The handle is two machine words. Clone/copy duplicates only the graph node
527/// index; all derivative storage remains in the reusable workspace.
528#[derive(Clone, Copy, Debug)]
529pub struct Order2Graph<'arena, const K: usize> {
530    workspace: &'arena Order2GraphWorkspace,
531    node: usize,
532}
533
534impl<'arena, const K: usize> Order2Graph<'arena, K> {
535    /// Lower this scalar output to the ordinary packed order-2 channels.
536    #[must_use]
537    pub fn into_order2(self) -> Order2<K> {
538        let mut out = crate::jet_tower::Tower2::zero();
539        let mut hessian = ArrayHessianSink(&mut out.h);
540        out.v = self
541            .workspace
542            .lower_into(self.node, &mut out.g, &mut hessian);
543        Order2(out)
544    }
545
546    /// Lower into caller-owned gradient and row-major Hessian storage.
547    ///
548    /// Both slices are completely overwritten, including structurally-zero
549    /// channels. Reusing them therefore requires no caller-side clearing.
550    #[must_use]
551    pub fn lower_into(self, gradient: &mut [f64], hessian_row_major: &mut [f64]) -> f64 {
552        assert_eq!(gradient.len(), K, "compiled graph gradient width mismatch");
553        assert_eq!(
554            hessian_row_major.len(),
555            K * K,
556            "compiled graph Hessian width mismatch"
557        );
558        let mut hessian = RowMajorHessianSink::<K>(hessian_row_major);
559        self.workspace.lower_into(self.node, gradient, &mut hessian)
560    }
561
562    #[inline(always)]
563    fn assert_compatible(&self, other: &Self) {
564        assert!(
565            std::ptr::eq(self.workspace, other.workspace),
566            "compiled graph scalars belong to different workspaces"
567        );
568    }
569
570    #[inline(always)]
571    fn unary(&self, value: f64, first: f64, second: f64) -> Self {
572        let tape = self.workspace.tape_mut();
573        let mut gradient = [0.0; K];
574        for primary in 0..K {
575            gradient[primary] = first * tape.gradients[self.node * K + primary];
576        }
577        let support = tape.nodes[self.node].support;
578        let owner = tape.node_len;
579        let edge_start = tape.edge_len;
580        Order2GraphWorkspace::push_edge(tape, self.node, first);
581        Order2GraphWorkspace::push_event(
582            tape,
583            CurvatureEvent::RankOne {
584                owner: packed_index(owner),
585                input: packed_index(self.node),
586                second,
587            },
588        );
589        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
590        Self {
591            workspace: self.workspace,
592            node,
593        }
594    }
595}
596
597impl<'arena, const K: usize> RuntimeJetScalar<'arena> for Order2Graph<'arena, K> {
598    type Workspace = Order2GraphWorkspace;
599
600    #[inline(always)]
601    fn constant(c: f64, dimension: usize, workspace: &'arena Self::Workspace) -> Self {
602        assert_eq!(dimension, K, "compiled graph dimension mismatch");
603        let tape = workspace.tape_mut();
604        let edge_start = tape.edge_len;
605        let node = Order2GraphWorkspace::push(tape, c, 0, [0.0; K], edge_start);
606        Self { workspace, node }
607    }
608
609    #[inline(always)]
610    fn variable(x: f64, axis: usize, dimension: usize, workspace: &'arena Self::Workspace) -> Self {
611        assert_eq!(dimension, K, "compiled graph dimension mismatch");
612        assert!(axis < K, "compiled graph variable axis out of bounds");
613        let mut gradient = [0.0; K];
614        gradient[axis] = 1.0;
615        let tape = workspace.tape_mut();
616        let edge_start = tape.edge_len;
617        let node = Order2GraphWorkspace::push(tape, x, 1_u16 << axis, gradient, edge_start);
618        tape.nodes[node].primary_axis = packed_index(axis);
619        Self { workspace, node }
620    }
621
622    #[inline(always)]
623    fn constant_like(&self, c: f64) -> Self {
624        Self::constant(c, K, self.workspace)
625    }
626
627    #[inline(always)]
628    fn with_value(&self, value: f64) -> Self {
629        self.unary(value, 1.0, 0.0)
630    }
631
632    #[inline(always)]
633    fn symmetric_quadratic_form<C: SymmetricQuadraticCoefficients>(
634        inputs: &[Self],
635        coefficients: &C,
636        dimension: usize,
637        workspace: &'arena Self::Workspace,
638    ) -> Self {
639        assert_eq!(dimension, K, "compiled graph dimension mismatch");
640        assert_eq!(inputs.len(), coefficients.dimension());
641        assert!(
642            inputs.len() <= MAX_QUADRATIC_ARITY,
643            "compiled graph quadratic arity exceeds graph capacity"
644        );
645        assert!(
646            inputs
647                .iter()
648                .all(|input| std::ptr::eq(input.workspace, workspace)),
649            "compiled graph quadratic inputs belong to different workspaces"
650        );
651
652        let input_dimension = inputs.len();
653        let mut values_storage = MaybeUninit::uninit();
654        let values = InlineScalars::initialize_zeros(&mut values_storage, input_dimension);
655        let mut support = 0_u16;
656        {
657            let tape = workspace.tape();
658            for (value, input) in values.as_mut_slice().iter_mut().zip(inputs) {
659                *value = tape.nodes[input.node].value;
660                support |= tape.nodes[input.node].support;
661            }
662        }
663        // A primary node carries an explicit basis identity. Recording each
664        // input position's axis lets the common variable-only quadratic scatter
665        // the operator's packed curvature directly into primary coordinates.
666        // Repeated and permuted primaries remain exact; constants and derived
667        // nodes retain the unrestricted Jacobian projection below.
668        let mut input_primary_axes = [NO_PRIMARY_AXIS; MAX_QUADRATIC_ARITY];
669        let all_inputs_primary = {
670            let tape = workspace.tape();
671            inputs.iter().enumerate().all(|(position, input)| {
672                let primary_axis = tape.nodes[input.node].primary_axis;
673                if primary_axis == NO_PRIMARY_AXIS {
674                    false
675                } else {
676                    input_primary_axes[position] = primary_axis;
677                    true
678                }
679            })
680        };
681        let mut projected_values_storage = MaybeUninit::uninit();
682        let projected_values =
683            InlineScalars::initialize_zeros(&mut projected_values_storage, input_dimension);
684        coefficients.multiply(values.as_slice(), projected_values.as_mut_slice());
685        let value = values
686            .as_slice()
687            .iter()
688            .zip(projected_values.as_slice())
689            .map(|(&input, &projected)| input * projected)
690            .sum();
691
692        let supported_dimension = support.count_ones() as usize;
693        let curvature_len = supported_dimension * (supported_dimension + 1) / 2;
694        let curvature_start = {
695            let tape = workspace.tape_mut();
696            assert!(
697                curvature_len <= MAX_PROJECTED_CURVATURE_VALUES - tape.projected_curvature_len,
698                "compiled graph projected-curvature capacity exceeded"
699            );
700            let start = tape.projected_curvature_len;
701            tape.projected_curvature_len += curvature_len;
702            start
703        };
704        let mut curvature_offset = 0;
705        if all_inputs_primary {
706            // For primary inputs J is a (possibly repeated/permuted) selection
707            // matrix. Ask the operator to visit its packed upper triangle:
708            // matrix-free implementations retain their `multiply` contract,
709            // while structured operators can emit native O(K²) curvature.
710            workspace.tape_mut().projected_curvatures
711                [curvature_start..curvature_start + curvature_len]
712                .fill(0.0);
713            let mut direction_storage = MaybeUninit::uninit();
714            let direction =
715                InlineScalars::initialize_zeros(&mut direction_storage, input_dimension);
716            let mut projected_direction_storage = MaybeUninit::uninit();
717            let projected_direction =
718                InlineScalars::initialize_zeros(&mut projected_direction_storage, input_dimension);
719            coefficients.visit_upper_triangle(
720                direction.as_mut_slice(),
721                projected_direction.as_mut_slice(),
722                |row, column, coefficient| {
723                    let row_axis = input_primary_axes[row] as usize;
724                    let column_axis = input_primary_axes[column] as usize;
725                    let primary = row_axis.min(column_axis);
726                    let other = row_axis.max(column_axis);
727                    let offset = supported_upper_offset(support, primary, other);
728                    let repeated_axis_multiplicity = if row != column && row_axis == column_axis {
729                        2.0
730                    } else {
731                        1.0
732                    };
733                    workspace.tape_mut().projected_curvatures[curvature_start + offset] +=
734                        2.0 * repeated_axis_multiplicity * coefficient;
735                },
736            );
737            curvature_offset = curvature_len;
738        } else {
739            // Project each unrestricted primary-space input direction through
740            // the operator while no tape borrow is live. This preserves
741            // structured `multiply` implementations and prevents arbitrary
742            // coefficient callbacks from aliasing the workspace's `UnsafeCell`.
743            let mut direction_storage = MaybeUninit::uninit();
744            let direction =
745                InlineScalars::initialize_zeros(&mut direction_storage, input_dimension);
746            let mut projected_direction_storage = MaybeUninit::uninit();
747            let projected_direction =
748                InlineScalars::initialize_zeros(&mut projected_direction_storage, input_dimension);
749            let mut supported_columns = support;
750            while supported_columns != 0 {
751                let other = supported_columns.trailing_zeros() as usize;
752                supported_columns &= supported_columns - 1;
753                {
754                    let tape = workspace.tape();
755                    for (channel, input) in direction.as_mut_slice().iter_mut().zip(inputs) {
756                        *channel = tape.gradients[input.node * K + other];
757                    }
758                }
759                coefficients.multiply(direction.as_slice(), projected_direction.as_mut_slice());
760                let mut supported_rows =
761                    support & (u16::MAX >> (MAX_PRIMARY_DIMENSION - other - 1));
762                while supported_rows != 0 {
763                    let primary = supported_rows.trailing_zeros() as usize;
764                    supported_rows &= supported_rows - 1;
765                    let curvature = {
766                        let tape = workspace.tape();
767                        inputs
768                            .iter()
769                            .zip(projected_direction.as_slice())
770                            .map(|(input, &projected)| {
771                                tape.gradients[input.node * K + primary] * projected
772                            })
773                            .sum::<f64>()
774                    };
775                    workspace.tape_mut().projected_curvatures[curvature_start + curvature_offset] =
776                        2.0 * curvature;
777                    curvature_offset += 1;
778                }
779            }
780        }
781        assert_eq!(
782            curvature_offset, curvature_len,
783            "compiled graph projected-curvature schedule must fill its exact packed range"
784        );
785
786        let tape = workspace.tape_mut();
787        let owner = tape.node_len;
788        let edge_start = tape.edge_len;
789        let mut gradient = [0.0; K];
790        for axis in 0..input_dimension {
791            let first = 2.0 * projected_values.as_slice()[axis];
792            Order2GraphWorkspace::push_edge(tape, inputs[axis].node, first);
793            if all_inputs_primary {
794                let primary = tape.nodes[inputs[axis].node].primary_axis as usize;
795                gradient[primary] += first;
796            } else {
797                for primary in 0..K {
798                    gradient[primary] += first * tape.gradients[inputs[axis].node * K + primary];
799                }
800            }
801        }
802        Order2GraphWorkspace::push_event(
803            tape,
804            CurvatureEvent::Projected {
805                owner: packed_index(owner),
806                curvature_start: packed_offset(curvature_start),
807                support,
808            },
809        );
810        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
811        Self { workspace, node }
812    }
813
814    #[inline(always)]
815    fn linear_combination(
816        inputs: &[Self],
817        weights: &[f64],
818        dimension: usize,
819        workspace: &'arena Self::Workspace,
820    ) -> Self {
821        assert_eq!(dimension, K, "compiled graph dimension mismatch");
822        assert_eq!(inputs.len(), weights.len());
823        assert!(
824            inputs
825                .iter()
826                .all(|input| std::ptr::eq(input.workspace, workspace)),
827            "compiled graph linear inputs belong to different workspaces"
828        );
829        let tape = workspace.tape_mut();
830        let all_inputs_primary = inputs
831            .iter()
832            .all(|input| tape.nodes[input.node].primary_axis != NO_PRIMARY_AXIS);
833        let edge_start = tape.edge_len;
834        let mut value = 0.0;
835        let mut gradient = [0.0; K];
836        let mut support = 0_u16;
837        for (input, &weight) in inputs.iter().zip(weights) {
838            value += weight * tape.nodes[input.node].value;
839            Order2GraphWorkspace::push_edge(tape, input.node, weight);
840            support |= tape.nodes[input.node].support;
841            if all_inputs_primary {
842                let primary = tape.nodes[input.node].primary_axis as usize;
843                gradient[primary] += weight;
844            } else {
845                for primary in 0..K {
846                    gradient[primary] += weight * tape.gradients[input.node * K + primary];
847                }
848            }
849        }
850        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
851        Self { workspace, node }
852    }
853
854    #[inline(always)]
855    fn multiply_add(&self, right: &Self, addend: &Self) -> Self {
856        self.assert_compatible(right);
857        self.assert_compatible(addend);
858        let tape = self.workspace.tape_mut();
859        let left_value = tape.nodes[self.node].value;
860        let right_value = tape.nodes[right.node].value;
861        let mut gradient = [0.0; K];
862        for primary in 0..K {
863            gradient[primary] = left_value * tape.gradients[right.node * K + primary]
864                + tape.gradients[self.node * K + primary] * right_value
865                + tape.gradients[addend.node * K + primary];
866        }
867        let value = left_value * right_value + tape.nodes[addend.node].value;
868        let support = tape.nodes[self.node].support
869            | tape.nodes[right.node].support
870            | tape.nodes[addend.node].support;
871        let owner = tape.node_len;
872        let edge_start = tape.edge_len;
873        Order2GraphWorkspace::push_edge(tape, self.node, right_value);
874        Order2GraphWorkspace::push_edge(tape, right.node, left_value);
875        Order2GraphWorkspace::push_edge(tape, addend.node, 1.0);
876        Order2GraphWorkspace::push_event(
877            tape,
878            CurvatureEvent::Cross {
879                owner: packed_index(owner),
880                left: packed_index(self.node),
881                right: packed_index(right.node),
882            },
883        );
884        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
885        Self {
886            workspace: self.workspace,
887            node,
888        }
889    }
890
891    #[inline(always)]
892    fn composed_sum(
893        inputs: &[Self],
894        derivative_stacks: &[[f64; 5]],
895        dimension: usize,
896        workspace: &'arena Self::Workspace,
897    ) -> Self {
898        assert_eq!(dimension, K, "compiled graph dimension mismatch");
899        assert_eq!(inputs.len(), derivative_stacks.len());
900        assert!(
901            inputs
902                .iter()
903                .all(|input| std::ptr::eq(input.workspace, workspace)),
904            "compiled graph composed inputs belong to different workspaces"
905        );
906        let tape = workspace.tape_mut();
907        let owner = tape.node_len;
908        let edge_start = tape.edge_len;
909        let term_start = tape.diagonal_len;
910        let mut value = 0.0;
911        let mut gradient = [0.0; K];
912        let mut support = 0_u16;
913        for (input, stack) in inputs.iter().zip(derivative_stacks) {
914            value += stack[0];
915            Order2GraphWorkspace::push_edge(tape, input.node, stack[1]);
916            Order2GraphWorkspace::push_diagonal_term(tape, input.node, stack[2]);
917            support |= tape.nodes[input.node].support;
918            for primary in 0..K {
919                gradient[primary] += stack[1] * tape.gradients[input.node * K + primary];
920            }
921        }
922        Order2GraphWorkspace::push_event(
923            tape,
924            CurvatureEvent::Diagonal {
925                owner: packed_index(owner),
926                term_start: packed_offset(term_start),
927                len: packed_offset(inputs.len()),
928            },
929        );
930        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
931        Self { workspace, node }
932    }
933
934    #[inline(always)]
935    fn product(&self, right: &Self) -> Self {
936        self.mul(right)
937    }
938
939    #[inline(always)]
940    fn affine_compose(
941        &self,
942        input_scale: f64,
943        input_shift: f64,
944        derivative_stack: [f64; 5],
945    ) -> Self {
946        assert!(input_shift.is_finite(), "affine input shift must be finite");
947        self.unary(
948            derivative_stack[0],
949            derivative_stack[1] * input_scale,
950            derivative_stack[2] * input_scale * input_scale,
951        )
952    }
953
954    #[inline(always)]
955    fn affine_composed_sum(
956        inputs: &[Self],
957        input_scales: &[f64],
958        derivative_stacks: &[[f64; 5]],
959        dimension: usize,
960        workspace: &'arena Self::Workspace,
961    ) -> Self {
962        assert_eq!(dimension, K, "compiled graph dimension mismatch");
963        assert_eq!(inputs.len(), input_scales.len());
964        assert_eq!(inputs.len(), derivative_stacks.len());
965        assert!(
966            inputs
967                .iter()
968                .all(|input| std::ptr::eq(input.workspace, workspace)),
969            "compiled graph composed inputs belong to different workspaces"
970        );
971        let tape = workspace.tape_mut();
972        let owner = tape.node_len;
973        let edge_start = tape.edge_len;
974        let term_start = tape.diagonal_len;
975        let mut value = 0.0;
976        let mut gradient = [0.0; K];
977        let mut support = 0_u16;
978        for ((input, &input_scale), stack) in inputs.iter().zip(input_scales).zip(derivative_stacks)
979        {
980            let first = stack[1] * input_scale;
981            let second = stack[2] * input_scale * input_scale;
982            value += stack[0];
983            Order2GraphWorkspace::push_edge(tape, input.node, first);
984            Order2GraphWorkspace::push_diagonal_term(tape, input.node, second);
985            support |= tape.nodes[input.node].support;
986            for primary in 0..K {
987                gradient[primary] += first * tape.gradients[input.node * K + primary];
988            }
989        }
990        Order2GraphWorkspace::push_event(
991            tape,
992            CurvatureEvent::Diagonal {
993                owner: packed_index(owner),
994                term_start: packed_offset(term_start),
995                len: packed_offset(inputs.len()),
996            },
997        );
998        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
999        Self { workspace, node }
1000    }
1001
1002    #[inline(always)]
1003    fn shared_multiply_add_affine_composed_sum<const N: usize>(
1004        lefts: &[&Self; N],
1005        right: &Self,
1006        addend: &Self,
1007        addend_scales: &[f64; N],
1008        input_scales: &[f64; N],
1009        derivative_stacks: &[[f64; 5]; N],
1010        dimension: usize,
1011        workspace: &'arena Self::Workspace,
1012    ) -> Self {
1013        assert_eq!(dimension, K, "compiled graph dimension mismatch");
1014        assert!(
1015            lefts
1016                .iter()
1017                .all(|input| std::ptr::eq(input.workspace, workspace))
1018                && (N == 0 || std::ptr::eq(right.workspace, workspace)),
1019            "compiled fused product-composition inputs belong to different workspaces"
1020        );
1021        let addend_live = addend_scales.iter().any(|&scale| scale != 0.0);
1022        assert!(
1023            !addend_live || std::ptr::eq(addend.workspace, workspace),
1024            "live compiled fused addends belong to different workspaces"
1025        );
1026
1027        let tape = workspace.tape_mut();
1028        let right_node = right.node;
1029        let addend_node = addend.node;
1030        let (representatives, term_sources, source_count) =
1031            canonical_shared_source_schedule::<N>(|term, representative| {
1032                lefts[term].node == lefts[representative].node
1033                    && addend_scales[term] == addend_scales[representative]
1034            });
1035        let (value, source_derivatives) =
1036            aggregate_shared_source_derivatives(&term_sources, input_scales, derivative_stacks);
1037        let mut source_gradients = [[0.0; K]; N];
1038        let mut source_primary_axes = [NO_PRIMARY_AXIS; N];
1039        let mut gradient = [0.0; K];
1040        let mut support = 0_u16;
1041        let mut right_first = 0.0;
1042        let mut addend_first = 0.0;
1043        if N != 0 {
1044            support |= tape.nodes[right_node].support;
1045        }
1046        if addend_live {
1047            support |= tape.nodes[addend_node].support;
1048        }
1049        for source in 0..source_count {
1050            let term = representatives[source];
1051            let left = lefts[term].node;
1052            let left_value = tape.nodes[left].value;
1053            let right_value = tape.nodes[right_node].value;
1054            let first = source_derivatives[source][1];
1055            let left_primary_axis = tape.nodes[left].primary_axis;
1056            source_primary_axes[source] = left_primary_axis;
1057            support |= tape.nodes[left].support;
1058            right_first += first * left_value;
1059            addend_first += first * addend_scales[term];
1060            for primary in 0..K {
1061                let product_gradient = left_value * tape.gradients[right_node * K + primary];
1062                let inner_gradient = if addend_scales[term] == 0.0 {
1063                    product_gradient
1064                } else if addend_scales[term] == 1.0 {
1065                    product_gradient + tape.gradients[addend_node * K + primary]
1066                } else {
1067                    product_gradient
1068                        + addend_scales[term] * tape.gradients[addend_node * K + primary]
1069                };
1070                source_gradients[source][primary] = inner_gradient;
1071            }
1072            if left_primary_axis == NO_PRIMARY_AXIS {
1073                for primary in 0..K {
1074                    let left_contribution = tape.gradients[left * K + primary] * right_value;
1075                    source_gradients[source][primary] += left_contribution;
1076                    gradient[primary] += first * left_contribution;
1077                }
1078            } else {
1079                let primary = left_primary_axis as usize;
1080                source_gradients[source][primary] += right_value;
1081                gradient[primary] += first * right_value;
1082            }
1083        }
1084        if N != 0 {
1085            for primary in 0..K {
1086                gradient[primary] += right_first * tape.gradients[right_node * K + primary];
1087            }
1088        }
1089        if addend_live {
1090            for primary in 0..K {
1091                gradient[primary] += addend_first * tape.gradients[addend_node * K + primary];
1092            }
1093        }
1094
1095        let supported_dimension = support.count_ones() as usize;
1096        let curvature_len = supported_dimension * (supported_dimension + 1) / 2;
1097        assert!(
1098            curvature_len <= MAX_PROJECTED_CURVATURE_VALUES - tape.projected_curvature_len,
1099            "compiled graph projected-curvature capacity exceeded"
1100        );
1101        let curvature_start = tape.projected_curvature_len;
1102        tape.projected_curvature_len += curvature_len;
1103        let mut curvature_offset = 0;
1104        for_each_supported_upper_by_column(support, |primary, other| {
1105            let mut channel = 0.0;
1106            for source in 0..source_count {
1107                channel += source_derivatives[source][2]
1108                    * source_gradients[source][primary]
1109                    * source_gradients[source][other];
1110                if source_primary_axes[source] == NO_PRIMARY_AXIS {
1111                    let term = representatives[source];
1112                    let left = lefts[term].node;
1113                    let cross = tape.gradients[left * K + primary]
1114                        * tape.gradients[right_node * K + other]
1115                        + tape.gradients[left * K + other]
1116                            * tape.gradients[right_node * K + primary];
1117                    channel += source_derivatives[source][1] * cross;
1118                }
1119            }
1120            tape.projected_curvatures[curvature_start + curvature_offset] = channel;
1121            curvature_offset += 1;
1122        });
1123        for source in 0..source_count {
1124            let primary_axis = source_primary_axes[source];
1125            if primary_axis == NO_PRIMARY_AXIS {
1126                continue;
1127            }
1128            let primary_axis = primary_axis as usize;
1129            let first = source_derivatives[source][1];
1130            let mut right_support = tape.nodes[right_node].support;
1131            while right_support != 0 {
1132                let right_axis = right_support.trailing_zeros() as usize;
1133                right_support &= right_support - 1;
1134                let primary = primary_axis.min(right_axis);
1135                let other = primary_axis.max(right_axis);
1136                let offset = supported_upper_offset(support, primary, other);
1137                let diagonal_scale = if primary_axis == right_axis { 2.0 } else { 1.0 };
1138                let cross = diagonal_scale * tape.gradients[right_node * K + right_axis];
1139                tape.projected_curvatures[curvature_start + offset] += first * cross;
1140            }
1141        }
1142        assert_eq!(
1143            curvature_offset, curvature_len,
1144            "compiled fused product-composition must fill its packed curvature"
1145        );
1146
1147        let owner = tape.node_len;
1148        let edge_start = tape.edge_len;
1149        for source in 0..source_count {
1150            let term = representatives[source];
1151            let left = lefts[term].node;
1152            let left_first = source_derivatives[source][1] * tape.nodes[right_node].value;
1153            Order2GraphWorkspace::push_edge(tape, left, left_first);
1154        }
1155        if N != 0 {
1156            Order2GraphWorkspace::push_edge(tape, right_node, right_first);
1157        }
1158        if addend_live {
1159            Order2GraphWorkspace::push_edge(tape, addend_node, addend_first);
1160        }
1161        Order2GraphWorkspace::push_event(
1162            tape,
1163            CurvatureEvent::Projected {
1164                owner: packed_index(owner),
1165                curvature_start: packed_offset(curvature_start),
1166                support,
1167            },
1168        );
1169        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
1170        Self { workspace, node }
1171    }
1172
1173    #[inline(always)]
1174    fn dimension(&self) -> usize {
1175        K
1176    }
1177
1178    #[inline(always)]
1179    fn value(&self) -> f64 {
1180        self.workspace.tape().nodes[self.node].value
1181    }
1182
1183    #[inline(always)]
1184    fn add(&self, other: &Self) -> Self {
1185        self.assert_compatible(other);
1186        let tape = self.workspace.tape_mut();
1187        let mut gradient = [0.0; K];
1188        for primary in 0..K {
1189            gradient[primary] =
1190                tape.gradients[self.node * K + primary] + tape.gradients[other.node * K + primary];
1191        }
1192        let value = tape.nodes[self.node].value + tape.nodes[other.node].value;
1193        let support = tape.nodes[self.node].support | tape.nodes[other.node].support;
1194        let edge_start = tape.edge_len;
1195        Order2GraphWorkspace::push_edge(tape, self.node, 1.0);
1196        Order2GraphWorkspace::push_edge(tape, other.node, 1.0);
1197        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
1198        Self {
1199            workspace: self.workspace,
1200            node,
1201        }
1202    }
1203
1204    #[inline(always)]
1205    fn sub(&self, other: &Self) -> Self {
1206        self.assert_compatible(other);
1207        let tape = self.workspace.tape_mut();
1208        let mut gradient = [0.0; K];
1209        for primary in 0..K {
1210            gradient[primary] =
1211                tape.gradients[self.node * K + primary] - tape.gradients[other.node * K + primary];
1212        }
1213        let value = tape.nodes[self.node].value - tape.nodes[other.node].value;
1214        let support = tape.nodes[self.node].support | tape.nodes[other.node].support;
1215        let edge_start = tape.edge_len;
1216        Order2GraphWorkspace::push_edge(tape, self.node, 1.0);
1217        Order2GraphWorkspace::push_edge(tape, other.node, -1.0);
1218        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
1219        Self {
1220            workspace: self.workspace,
1221            node,
1222        }
1223    }
1224
1225    #[inline(always)]
1226    fn mul(&self, other: &Self) -> Self {
1227        self.assert_compatible(other);
1228        let tape = self.workspace.tape_mut();
1229        let left_value = tape.nodes[self.node].value;
1230        let right_value = tape.nodes[other.node].value;
1231        let mut gradient = [0.0; K];
1232        for primary in 0..K {
1233            gradient[primary] = left_value * tape.gradients[other.node * K + primary]
1234                + tape.gradients[self.node * K + primary] * right_value;
1235        }
1236        let support = tape.nodes[self.node].support | tape.nodes[other.node].support;
1237        let owner = tape.node_len;
1238        let edge_start = tape.edge_len;
1239        Order2GraphWorkspace::push_edge(tape, self.node, right_value);
1240        Order2GraphWorkspace::push_edge(tape, other.node, left_value);
1241        Order2GraphWorkspace::push_event(
1242            tape,
1243            CurvatureEvent::Cross {
1244                owner: packed_index(owner),
1245                left: packed_index(self.node),
1246                right: packed_index(other.node),
1247            },
1248        );
1249        let node = Order2GraphWorkspace::push(
1250            tape,
1251            left_value * right_value,
1252            support,
1253            gradient,
1254            edge_start,
1255        );
1256        Self {
1257            workspace: self.workspace,
1258            node,
1259        }
1260    }
1261
1262    #[inline(always)]
1263    fn neg(&self) -> Self {
1264        self.unary(-self.value(), -1.0, 0.0)
1265    }
1266
1267    #[inline(always)]
1268    fn scale(&self, scale: f64) -> Self {
1269        let tape = self.workspace.tape_mut();
1270        let mut gradient = [0.0; K];
1271        for primary in 0..K {
1272            gradient[primary] = scale * tape.gradients[self.node * K + primary];
1273        }
1274        let value = scale * tape.nodes[self.node].value;
1275        let support = tape.nodes[self.node].support;
1276        let edge_start = tape.edge_len;
1277        Order2GraphWorkspace::push_edge(tape, self.node, scale);
1278        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
1279        Self {
1280            workspace: self.workspace,
1281            node,
1282        }
1283    }
1284
1285    #[inline(always)]
1286    fn compose_unary(&self, derivatives: [f64; 5]) -> Self {
1287        self.unary(derivatives[0], derivatives[1], derivatives[2])
1288    }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293    use super::*;
1294    use crate::jet_scalar::{FixedRuntimeJet, JetScalar};
1295    use crate::nested_dual::JetField;
1296    use std::cell::Cell;
1297
1298    #[test]
1299    fn lower_into_overwrites_every_channel_across_workspace_reset() {
1300        let mut workspace = Order2GraphWorkspace::new();
1301        workspace.reset(3);
1302        let x = Order2Graph::<3>::variable(0.5, 0, 3, &workspace);
1303        let y = Order2Graph::<3>::variable(-0.25, 1, 3, &workspace);
1304        let output = x.product(&y);
1305        let mut gradient = [f64::NAN; 3];
1306        let mut hessian = [17.0; 9];
1307
1308        assert_eq!(output.lower_into(&mut gradient, &mut hessian), -0.125);
1309        assert_eq!(gradient, [-0.25, 0.5, 0.0]);
1310        assert_eq!(hessian, [0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
1311
1312        workspace.reset(3);
1313        let x = Order2Graph::<3>::variable(0.25, 0, 3, &workspace);
1314        let z = Order2Graph::<3>::variable(0.5, 2, 3, &workspace);
1315        let output = Order2Graph::linear_combination(&[x, z], &[2.0, -4.0], 3, &workspace);
1316        gradient.fill(f64::NAN);
1317        hessian.fill(-9.0);
1318
1319        assert_eq!(output.lower_into(&mut gradient, &mut hessian), -1.5);
1320        assert_eq!(gradient, [2.0, 0.0, -4.0]);
1321        assert_eq!(hessian, [0.0; 9]);
1322    }
1323
1324    struct DenseSymmetric3([[f64; 3]; 3]);
1325
1326    impl SymmetricQuadraticCoefficients for DenseSymmetric3 {
1327        fn dimension(&self) -> usize {
1328            3
1329        }
1330
1331        fn multiply(&self, input: &[f64], output: &mut [f64]) {
1332            for row in 0..3 {
1333                output[row] = (0..3)
1334                    .map(|column| self.0[row][column] * input[column])
1335                    .sum();
1336            }
1337        }
1338
1339        fn coefficient(&self, row: usize, column: usize) -> f64 {
1340            self.0[row][column]
1341        }
1342    }
1343
1344    struct MatrixFreeIdentity32<'arena> {
1345        workspace: &'arena Order2GraphWorkspace,
1346    }
1347
1348    impl SymmetricQuadraticCoefficients for MatrixFreeIdentity32<'_> {
1349        fn dimension(&self) -> usize {
1350            MAX_QUADRATIC_ARITY
1351        }
1352
1353        fn multiply(&self, input: &[f64], output: &mut [f64]) {
1354            assert!(self.workspace.tape().node_len != 0);
1355            output.copy_from_slice(input);
1356        }
1357
1358        fn coefficient(&self, row: usize, column: usize) -> f64 {
1359            panic!(
1360                "compiled graph quadratic lowering must preserve matrix-free multiply, \
1361                 but entry ({row}, {column}) was read densely"
1362            )
1363        }
1364    }
1365
1366    struct CountingIdentity3<'arena> {
1367        workspace: &'arena Order2GraphWorkspace,
1368        multiply_calls: Cell<usize>,
1369    }
1370
1371    impl SymmetricQuadraticCoefficients for CountingIdentity3<'_> {
1372        fn dimension(&self) -> usize {
1373            3
1374        }
1375
1376        fn multiply(&self, input: &[f64], output: &mut [f64]) {
1377            assert!(self.workspace.tape().node_len != 0);
1378            self.multiply_calls.set(self.multiply_calls.get() + 1);
1379            output.copy_from_slice(input);
1380        }
1381
1382        fn coefficient(&self, row: usize, column: usize) -> f64 {
1383            panic!(
1384                "compiled graph quadratic lowering must preserve matrix-free multiply, \
1385                 but entry ({row}, {column}) was read densely"
1386            )
1387        }
1388    }
1389
1390    #[test]
1391    fn compiled_graph_accepts_maximum_quadratic_arity_plus_output_node() {
1392        let mut workspace = Order2GraphWorkspace::new();
1393        workspace.reset(MAX_PRIMARY_DIMENSION);
1394        let values: [f64; MAX_QUADRATIC_ARITY] =
1395            std::array::from_fn(|axis| 0.01 * (axis + 1) as f64);
1396        let vars: [Order2Graph<'_, MAX_PRIMARY_DIMENSION>; MAX_QUADRATIC_ARITY] =
1397            std::array::from_fn(|axis| {
1398                Order2Graph::variable(
1399                    values[axis],
1400                    axis % MAX_PRIMARY_DIMENSION,
1401                    MAX_PRIMARY_DIMENSION,
1402                    &workspace,
1403                )
1404            });
1405        let coefficients = MatrixFreeIdentity32 {
1406            workspace: &workspace,
1407        };
1408        let graph = Order2Graph::symmetric_quadratic_form(
1409            &vars,
1410            &coefficients,
1411            MAX_PRIMARY_DIMENSION,
1412            &workspace,
1413        )
1414        .into_order2();
1415
1416        let expected_value = values.iter().map(|value| value * value).sum::<f64>();
1417        let tolerance = 2.0e-13;
1418        assert!((graph.value() - expected_value).abs() <= tolerance);
1419        for primary in 0..MAX_PRIMARY_DIMENSION {
1420            let expected_gradient = 2.0 * (values[primary] + values[primary + 16]);
1421            assert!((graph.g()[primary] - expected_gradient).abs() <= tolerance);
1422            for other in 0..MAX_PRIMARY_DIMENSION {
1423                let expected_hessian = if primary == other { 4.0 } else { 0.0 };
1424                assert!((graph.h()[primary][other] - expected_hessian).abs() <= tolerance);
1425            }
1426        }
1427    }
1428
1429    #[test]
1430    fn compiled_graph_projects_only_sparse_supported_primary_directions() {
1431        let mut workspace = Order2GraphWorkspace::new();
1432        workspace.reset(MAX_PRIMARY_DIMENSION);
1433        let x = Order2Graph::<MAX_PRIMARY_DIMENSION>::variable(
1434            0.4,
1435            0,
1436            MAX_PRIMARY_DIMENSION,
1437            &workspace,
1438        );
1439        let y = Order2Graph::<MAX_PRIMARY_DIMENSION>::variable(
1440            -0.7,
1441            7,
1442            MAX_PRIMARY_DIMENSION,
1443            &workspace,
1444        );
1445        let z = Order2Graph::<MAX_PRIMARY_DIMENSION>::variable(
1446            1.1,
1447            15,
1448            MAX_PRIMARY_DIMENSION,
1449            &workspace,
1450        );
1451        let coefficients = CountingIdentity3 {
1452            workspace: &workspace,
1453            multiply_calls: Cell::new(0),
1454        };
1455        let graph = Order2Graph::symmetric_quadratic_form(
1456            &[x, y, z],
1457            &coefficients,
1458            MAX_PRIMARY_DIMENSION,
1459            &workspace,
1460        )
1461        .into_order2();
1462
1463        assert_eq!(coefficients.multiply_calls.get(), 4);
1464        for primary in 0..MAX_PRIMARY_DIMENSION {
1465            let active = matches!(primary, 0 | 7 | 15);
1466            let expected_gradient = match primary {
1467                0 => 0.8,
1468                7 => -1.4,
1469                15 => 2.2,
1470                _ => 0.0,
1471            };
1472            assert_eq!(graph.g()[primary], expected_gradient);
1473            for other in 0..MAX_PRIMARY_DIMENSION {
1474                assert_eq!(
1475                    graph.h()[primary][other],
1476                    if active && primary == other { 2.0 } else { 0.0 }
1477                );
1478            }
1479        }
1480    }
1481
1482    #[test]
1483    fn compiled_fused_addend_support_obeys_structural_coefficient() {
1484        let mut workspace = Order2GraphWorkspace::new();
1485        {
1486            workspace.reset(3);
1487            let left = Order2Graph::<3>::variable(0.4, 0, 3, &workspace);
1488            let right = Order2Graph::<3>::variable(-0.7, 1, 3, &workspace);
1489            let omitted = Order2Graph::<3>::variable(1.1, 2, 3, &workspace);
1490            let output = Order2Graph::shared_multiply_add_affine_composed_sum(
1491                &[&left],
1492                &right,
1493                &omitted,
1494                &[-0.0],
1495                &[1.0],
1496                &[[0.2, 0.0, 1.0, 0.0, 0.0]],
1497                3,
1498                &workspace,
1499            );
1500            assert_eq!(workspace.tape().nodes[output.node].support, 0b011);
1501            let channels = output.into_order2();
1502            assert!(channels.g()[2] == 0.0);
1503            assert!((0..3).all(|axis| channels.h()[axis][2] == 0.0));
1504        }
1505
1506        workspace.reset(3);
1507        let left = Order2Graph::<3>::variable(0.4, 0, 3, &workspace);
1508        let right = Order2Graph::<3>::variable(-0.7, 1, 3, &workspace);
1509        let live = Order2Graph::<3>::variable(1.1, 2, 3, &workspace);
1510        let output = Order2Graph::shared_multiply_add_affine_composed_sum(
1511            &[&left],
1512            &right,
1513            &live,
1514            &[1.0],
1515            &[1.0],
1516            &[[0.2, 0.0, 1.0, 0.0, 0.0]],
1517            3,
1518            &workspace,
1519        );
1520        assert_eq!(workspace.tape().nodes[output.node].support, 0b111);
1521        let channels = output.into_order2();
1522        assert_eq!(channels.g()[2], 0.0);
1523        assert_eq!(channels.h()[2][2], 1.0);
1524    }
1525
1526    fn mixed_primary_derived_fused_expression<'arena, S: RuntimeJetScalar<'arena>>(
1527        vars: &[S; 5],
1528        workspace: &'arena S::Workspace,
1529    ) -> S {
1530        let right = vars[4].affine_compose(1.2, -0.1, [0.7, -1.2, 0.45, 0.0, 0.0]);
1531        let derived_left = vars[2].multiply_add(&vars[3], &vars[0]);
1532        let addend = S::linear_combination(vars, &[0.3, -0.8, 0.5, 1.1, -0.4], 5, workspace);
1533        S::shared_multiply_add_affine_composed_sum(
1534            &[&vars[1], &vars[0], &vars[1], &derived_left],
1535            &right,
1536            &addend,
1537            &[1.0, 1.0, 1.0, -0.4],
1538            &[-1.0, -1.0, 1.0, 0.75],
1539            &[
1540                [0.4, -0.8, 0.3, 0.0, 0.0],
1541                [-0.2, 0.5, -0.7, 0.0, 0.0],
1542                [0.9, 1.1, 0.2, 0.0, 0.0],
1543                [-0.6, 0.4, 0.8, 0.0, 0.0],
1544            ],
1545            5,
1546            workspace,
1547        )
1548    }
1549
1550    #[test]
1551    fn compiled_fused_primary_scatter_matches_eager_with_mixed_lefts() {
1552        let values = [0.4, -0.7, 1.1, -0.3, 0.8];
1553        let eager_vars: [FixedRuntimeJet<Order2<5>, 5>; 5] = std::array::from_fn(|axis| {
1554            FixedRuntimeJet::from_inner(Order2::variable(values[axis], axis))
1555        });
1556        let eager = mixed_primary_derived_fused_expression(&eager_vars, &()).into_inner();
1557
1558        let mut workspace = Order2GraphWorkspace::new();
1559        workspace.reset(5);
1560        let graph_vars: [Order2Graph<'_, 5>; 5] =
1561            std::array::from_fn(|axis| Order2Graph::variable(values[axis], axis, 5, &workspace));
1562        let graph_output = mixed_primary_derived_fused_expression(&graph_vars, &workspace);
1563        let output_node = workspace.tape().nodes[graph_output.node];
1564        assert_eq!(output_node.edge_len, 5);
1565        let graph = graph_output.into_order2();
1566
1567        let close = |actual: f64, expected: f64| {
1568            let tolerance = 2.0e-12 * actual.abs().max(expected.abs()).max(1.0);
1569            assert!((actual - expected).abs() <= tolerance);
1570        };
1571        close(graph.value(), eager.value());
1572        for primary in 0..5 {
1573            close(graph.g()[primary], eager.g()[primary]);
1574            for other in 0..5 {
1575                close(graph.h()[primary][other], eager.h()[primary][other]);
1576            }
1577        }
1578    }
1579
1580    fn expression<'arena, S: RuntimeJetScalar<'arena>>(
1581        vars: &[S; 6],
1582        coefficients: &DenseSymmetric3,
1583        scales: &[f64; 4],
1584        stacks: &[[f64; 5]; 4],
1585        workspace: &'arena S::Workspace,
1586    ) -> S {
1587        let nonlinear = [
1588            vars[0].product(&vars[1]),
1589            vars[2].affine_compose(scales[0], scales[1], stacks[0]),
1590            vars[3].multiply_add(&vars[4], &vars[5]),
1591        ];
1592        let quadratic = S::symmetric_quadratic_form(&nonlinear, coefficients, 6, workspace);
1593        let linear = S::linear_combination(vars, &[0.2, -0.7, 1.1, 0.4, -0.3, 0.8], 6, workspace);
1594        let product = quadratic.product(&linear);
1595        S::affine_composed_sum(
1596            &[quadratic, linear, product, nonlinear[1].clone()],
1597            &[scales[0], scales[1], scales[2], scales[3]],
1598            stacks,
1599            6,
1600            workspace,
1601        )
1602    }
1603
1604    fn fused_expression<'arena, S: RuntimeJetScalar<'arena>>(
1605        vars: &[S; 6],
1606        scales: &[f64; 4],
1607        stacks: &[[f64; 5]; 4],
1608        workspace: &'arena S::Workspace,
1609    ) -> S {
1610        const N: usize = 10;
1611        let upstream = [
1612            vars[0].product(&vars[1]),
1613            vars[2].affine_compose(scales[0], scales[1], stacks[0]),
1614            vars[3].multiply_add(&vars[4], &vars[5]),
1615        ];
1616        let repeated_left = upstream[1].clone();
1617        let mut lefts: [&S; N] = std::array::from_fn(|term| &upstream[term % upstream.len()]);
1618        lefts[9] = &repeated_left;
1619        let right = &upstream[1];
1620        let addend = &vars[2];
1621        let addend_scales: [f64; N] = std::array::from_fn(|term| match term % 4 {
1622            0 => 0.0,
1623            1 => 1.0,
1624            2 => -0.75,
1625            _ => 0.35,
1626        });
1627        let mut input_scales: [f64; N] = std::array::from_fn(|term| match term {
1628            0 => 0.0,
1629            1 => -1.25,
1630            _ => scales[term % scales.len()],
1631        });
1632        input_scales[9] = 1.25;
1633        let derivative_stacks: [[f64; 5]; N] =
1634            std::array::from_fn(|term| stacks[term % stacks.len()]);
1635        S::shared_multiply_add_affine_composed_sum(
1636            &lefts,
1637            right,
1638            addend,
1639            &addend_scales,
1640            &input_scales,
1641            &derivative_stacks,
1642            6,
1643            workspace,
1644        )
1645    }
1646
1647    #[test]
1648    fn compiled_graph_matches_eager_order2_randomized_full_vgh() {
1649        fn sample(state: &mut u64) -> f64 {
1650            *state ^= *state << 13;
1651            *state ^= *state >> 7;
1652            *state ^= *state << 17;
1653            let unit = (*state >> 11) as f64 * (1.0 / ((1_u64 << 53) as f64));
1654            2.0 * unit - 1.0
1655        }
1656
1657        fn close(actual: f64, expected: f64, case: usize, label: &str) {
1658            let tolerance = 5.0e-12 * actual.abs().max(expected.abs()).max(1.0);
1659            assert!(
1660                (actual - expected).abs() <= tolerance,
1661                "case {case} {label}: graph={actual:+.16e}, eager={expected:+.16e}, tolerance={tolerance:.3e}"
1662            );
1663        }
1664
1665        let mut state = 0x932d_a660_5eed_f00d_u64;
1666        let mut workspace = Order2GraphWorkspace::new();
1667        let mut fused_workspace = Order2GraphWorkspace::new();
1668
1669        fused_workspace.reset(6);
1670        let empty_terms: [&Order2Graph<'_, 6>; 0] = [];
1671        let empty_shared = Order2Graph::constant(1.0, 6, &fused_workspace);
1672        let empty_scales: [f64; 0] = [];
1673        let empty_stacks: [[f64; 5]; 0] = [];
1674        let empty = Order2Graph::shared_multiply_add_affine_composed_sum(
1675            &empty_terms,
1676            &empty_shared,
1677            &empty_shared,
1678            &empty_scales,
1679            &empty_scales,
1680            &empty_stacks,
1681            6,
1682            &fused_workspace,
1683        )
1684        .into_order2();
1685        assert_eq!(empty.value().to_bits(), 0.0_f64.to_bits());
1686        assert!(empty.g().iter().all(|&channel| channel == 0.0));
1687        assert!(empty.h().iter().flatten().all(|&channel| channel == 0.0));
1688
1689        for case in 0..256 {
1690            let values: [f64; 6] = std::array::from_fn(|_| sample(&mut state));
1691            let scales: [f64; 4] = std::array::from_fn(|_| sample(&mut state));
1692            let stacks: [[f64; 5]; 4] =
1693                std::array::from_fn(|_| std::array::from_fn(|_| sample(&mut state)));
1694            let raw: [[f64; 3]; 3] =
1695                std::array::from_fn(|_| std::array::from_fn(|_| sample(&mut state)));
1696            let coefficients = DenseSymmetric3([
1697                [raw[0][0], raw[0][1], raw[0][2]],
1698                [raw[0][1], raw[1][1], raw[1][2]],
1699                [raw[0][2], raw[1][2], raw[2][2]],
1700            ]);
1701
1702            let eager_vars: [FixedRuntimeJet<Order2<6>, 6>; 6] = std::array::from_fn(|axis| {
1703                FixedRuntimeJet::from_inner(Order2::variable(values[axis], axis))
1704            });
1705            let eager = expression(&eager_vars, &coefficients, &scales, &stacks, &()).into_inner();
1706            let eager_fused = fused_expression(&eager_vars, &scales, &stacks, &()).into_inner();
1707
1708            workspace.reset(6);
1709            let graph_vars: [Order2Graph<'_, 6>; 6] = std::array::from_fn(|axis| {
1710                Order2Graph::variable(values[axis], axis, 6, &workspace)
1711            });
1712            let graph =
1713                expression(&graph_vars, &coefficients, &scales, &stacks, &workspace).into_order2();
1714            fused_workspace.reset(6);
1715            let fused_graph_vars: [Order2Graph<'_, 6>; 6] = std::array::from_fn(|axis| {
1716                Order2Graph::variable(values[axis], axis, 6, &fused_workspace)
1717            });
1718            let graph_fused =
1719                fused_expression(&fused_graph_vars, &scales, &stacks, &fused_workspace)
1720                    .into_order2();
1721
1722            close(graph.value(), eager.value(), case, "value");
1723            close(
1724                graph_fused.value(),
1725                eager_fused.value(),
1726                case,
1727                "fused value",
1728            );
1729            for primary in 0..6 {
1730                close(graph.g()[primary], eager.g()[primary], case, "gradient");
1731                close(
1732                    graph_fused.g()[primary],
1733                    eager_fused.g()[primary],
1734                    case,
1735                    "fused gradient",
1736                );
1737                for other in 0..6 {
1738                    close(
1739                        graph.h()[primary][other],
1740                        eager.h()[primary][other],
1741                        case,
1742                        "Hessian",
1743                    );
1744                    close(
1745                        graph_fused.h()[primary][other],
1746                        eager_fused.h()[primary][other],
1747                        case,
1748                        "fused Hessian",
1749                    );
1750                }
1751            }
1752        }
1753    }
1754}