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