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 symmetric_quadratic_form<C: SymmetricQuadraticCoefficients>(
603        inputs: &[Self],
604        coefficients: &C,
605        dimension: usize,
606        workspace: &'arena Self::Workspace,
607    ) -> Self {
608        assert_eq!(dimension, K, "compiled graph dimension mismatch");
609        assert_eq!(inputs.len(), coefficients.dimension());
610        assert!(
611            inputs.len() <= MAX_QUADRATIC_ARITY,
612            "compiled graph quadratic arity exceeds graph capacity"
613        );
614        assert!(
615            inputs
616                .iter()
617                .all(|input| std::ptr::eq(input.workspace, workspace)),
618            "compiled graph quadratic inputs belong to different workspaces"
619        );
620
621        let input_dimension = inputs.len();
622        let mut values_storage = MaybeUninit::uninit();
623        let values = InlineScalars::initialize_zeros(&mut values_storage, input_dimension);
624        let mut support = 0_u16;
625        {
626            let tape = workspace.tape();
627            for (value, input) in values.as_mut_slice().iter_mut().zip(inputs) {
628                *value = tape.nodes[input.node].value;
629                support |= tape.nodes[input.node].support;
630            }
631        }
632        // A primary node carries an explicit basis identity. Recording each
633        // input position's axis lets the common variable-only quadratic scatter
634        // the operator's packed curvature directly into primary coordinates.
635        // Repeated and permuted primaries remain exact; constants and derived
636        // nodes retain the unrestricted Jacobian projection below.
637        let mut input_primary_axes = [NO_PRIMARY_AXIS; MAX_QUADRATIC_ARITY];
638        let all_inputs_primary = {
639            let tape = workspace.tape();
640            inputs.iter().enumerate().all(|(position, input)| {
641                let primary_axis = tape.nodes[input.node].primary_axis;
642                if primary_axis == NO_PRIMARY_AXIS {
643                    false
644                } else {
645                    input_primary_axes[position] = primary_axis;
646                    true
647                }
648            })
649        };
650        let mut projected_values_storage = MaybeUninit::uninit();
651        let projected_values =
652            InlineScalars::initialize_zeros(&mut projected_values_storage, input_dimension);
653        coefficients.multiply(values.as_slice(), projected_values.as_mut_slice());
654        let value = values
655            .as_slice()
656            .iter()
657            .zip(projected_values.as_slice())
658            .map(|(&input, &projected)| input * projected)
659            .sum();
660
661        let supported_dimension = support.count_ones() as usize;
662        let curvature_len = supported_dimension * (supported_dimension + 1) / 2;
663        let curvature_start = {
664            let tape = workspace.tape_mut();
665            assert!(
666                curvature_len <= MAX_PROJECTED_CURVATURE_VALUES - tape.projected_curvature_len,
667                "compiled graph projected-curvature capacity exceeded"
668            );
669            let start = tape.projected_curvature_len;
670            tape.projected_curvature_len += curvature_len;
671            start
672        };
673        let mut curvature_offset = 0;
674        if all_inputs_primary {
675            // For primary inputs J is a (possibly repeated/permuted) selection
676            // matrix. Ask the operator to visit its packed upper triangle:
677            // matrix-free implementations retain their `multiply` contract,
678            // while structured operators can emit native O(K²) curvature.
679            workspace.tape_mut().projected_curvatures
680                [curvature_start..curvature_start + curvature_len]
681                .fill(0.0);
682            let mut direction_storage = MaybeUninit::uninit();
683            let direction =
684                InlineScalars::initialize_zeros(&mut direction_storage, input_dimension);
685            let mut projected_direction_storage = MaybeUninit::uninit();
686            let projected_direction =
687                InlineScalars::initialize_zeros(&mut projected_direction_storage, input_dimension);
688            coefficients.visit_upper_triangle(
689                direction.as_mut_slice(),
690                projected_direction.as_mut_slice(),
691                |row, column, coefficient| {
692                    let row_axis = input_primary_axes[row] as usize;
693                    let column_axis = input_primary_axes[column] as usize;
694                    let primary = row_axis.min(column_axis);
695                    let other = row_axis.max(column_axis);
696                    let offset = supported_upper_offset(support, primary, other);
697                    let repeated_axis_multiplicity = if row != column && row_axis == column_axis {
698                        2.0
699                    } else {
700                        1.0
701                    };
702                    workspace.tape_mut().projected_curvatures[curvature_start + offset] +=
703                        2.0 * repeated_axis_multiplicity * coefficient;
704                },
705            );
706            curvature_offset = curvature_len;
707        } else {
708            // Project each unrestricted primary-space input direction through
709            // the operator while no tape borrow is live. This preserves
710            // structured `multiply` implementations and prevents arbitrary
711            // coefficient callbacks from aliasing the workspace's `UnsafeCell`.
712            let mut direction_storage = MaybeUninit::uninit();
713            let direction =
714                InlineScalars::initialize_zeros(&mut direction_storage, input_dimension);
715            let mut projected_direction_storage = MaybeUninit::uninit();
716            let projected_direction =
717                InlineScalars::initialize_zeros(&mut projected_direction_storage, input_dimension);
718            let mut supported_columns = support;
719            while supported_columns != 0 {
720                let other = supported_columns.trailing_zeros() as usize;
721                supported_columns &= supported_columns - 1;
722                {
723                    let tape = workspace.tape();
724                    for (channel, input) in direction.as_mut_slice().iter_mut().zip(inputs) {
725                        *channel = tape.gradients[input.node * K + other];
726                    }
727                }
728                coefficients.multiply(direction.as_slice(), projected_direction.as_mut_slice());
729                let mut supported_rows =
730                    support & (u16::MAX >> (MAX_PRIMARY_DIMENSION - other - 1));
731                while supported_rows != 0 {
732                    let primary = supported_rows.trailing_zeros() as usize;
733                    supported_rows &= supported_rows - 1;
734                    let curvature = {
735                        let tape = workspace.tape();
736                        inputs
737                            .iter()
738                            .zip(projected_direction.as_slice())
739                            .map(|(input, &projected)| {
740                                tape.gradients[input.node * K + primary] * projected
741                            })
742                            .sum::<f64>()
743                    };
744                    workspace.tape_mut().projected_curvatures[curvature_start + curvature_offset] =
745                        2.0 * curvature;
746                    curvature_offset += 1;
747                }
748            }
749        }
750        assert_eq!(
751            curvature_offset, curvature_len,
752            "compiled graph projected-curvature schedule must fill its exact packed range"
753        );
754
755        let tape = workspace.tape_mut();
756        let owner = tape.node_len;
757        let edge_start = tape.edge_len;
758        let mut gradient = [0.0; K];
759        for axis in 0..input_dimension {
760            let first = 2.0 * projected_values.as_slice()[axis];
761            Order2GraphWorkspace::push_edge(tape, inputs[axis].node, first);
762            if all_inputs_primary {
763                let primary = tape.nodes[inputs[axis].node].primary_axis as usize;
764                gradient[primary] += first;
765            } else {
766                for primary in 0..K {
767                    gradient[primary] += first * tape.gradients[inputs[axis].node * K + primary];
768                }
769            }
770        }
771        Order2GraphWorkspace::push_event(
772            tape,
773            CurvatureEvent::Projected {
774                owner: owner as u8,
775                curvature_start: curvature_start as u16,
776                support,
777            },
778        );
779        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
780        Self { workspace, node }
781    }
782
783    #[inline(always)]
784    fn linear_combination(
785        inputs: &[Self],
786        weights: &[f64],
787        dimension: usize,
788        workspace: &'arena Self::Workspace,
789    ) -> Self {
790        assert_eq!(dimension, K, "compiled graph dimension mismatch");
791        assert_eq!(inputs.len(), weights.len());
792        assert!(
793            inputs
794                .iter()
795                .all(|input| std::ptr::eq(input.workspace, workspace)),
796            "compiled graph linear inputs belong to different workspaces"
797        );
798        let tape = workspace.tape_mut();
799        let all_inputs_primary = inputs
800            .iter()
801            .all(|input| tape.nodes[input.node].primary_axis != NO_PRIMARY_AXIS);
802        let edge_start = tape.edge_len;
803        let mut value = 0.0;
804        let mut gradient = [0.0; K];
805        let mut support = 0_u16;
806        for (input, &weight) in inputs.iter().zip(weights) {
807            value += weight * tape.nodes[input.node].value;
808            Order2GraphWorkspace::push_edge(tape, input.node, weight);
809            support |= tape.nodes[input.node].support;
810            if all_inputs_primary {
811                let primary = tape.nodes[input.node].primary_axis as usize;
812                gradient[primary] += weight;
813            } else {
814                for primary in 0..K {
815                    gradient[primary] += weight * tape.gradients[input.node * K + primary];
816                }
817            }
818        }
819        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
820        Self { workspace, node }
821    }
822
823    #[inline(always)]
824    fn add_constant(&self, constant: f64, workspace: &'arena Self::Workspace) -> Self {
825        assert!(std::ptr::eq(self.workspace, workspace));
826        self.unary(self.value() + constant, 1.0, 0.0)
827    }
828
829    #[inline(always)]
830    fn multiply_add(&self, right: &Self, addend: &Self) -> Self {
831        self.assert_compatible(right);
832        self.assert_compatible(addend);
833        let tape = self.workspace.tape_mut();
834        let left_value = tape.nodes[self.node].value;
835        let right_value = tape.nodes[right.node].value;
836        let mut gradient = [0.0; K];
837        for primary in 0..K {
838            gradient[primary] = left_value * tape.gradients[right.node * K + primary]
839                + tape.gradients[self.node * K + primary] * right_value
840                + tape.gradients[addend.node * K + primary];
841        }
842        let value = left_value * right_value + tape.nodes[addend.node].value;
843        let support = tape.nodes[self.node].support
844            | tape.nodes[right.node].support
845            | tape.nodes[addend.node].support;
846        let owner = tape.node_len;
847        let edge_start = tape.edge_len;
848        Order2GraphWorkspace::push_edge(tape, self.node, right_value);
849        Order2GraphWorkspace::push_edge(tape, right.node, left_value);
850        Order2GraphWorkspace::push_edge(tape, addend.node, 1.0);
851        Order2GraphWorkspace::push_event(
852            tape,
853            CurvatureEvent::Cross {
854                owner: owner as u8,
855                left: self.node as u8,
856                right: right.node as u8,
857            },
858        );
859        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
860        Self {
861            workspace: self.workspace,
862            node,
863        }
864    }
865
866    #[inline(always)]
867    fn composed_sum(
868        inputs: &[Self],
869        derivative_stacks: &[[f64; 5]],
870        dimension: usize,
871        workspace: &'arena Self::Workspace,
872    ) -> Self {
873        assert_eq!(dimension, K, "compiled graph dimension mismatch");
874        assert_eq!(inputs.len(), derivative_stacks.len());
875        assert!(
876            inputs
877                .iter()
878                .all(|input| std::ptr::eq(input.workspace, workspace)),
879            "compiled graph composed inputs belong to different workspaces"
880        );
881        let tape = workspace.tape_mut();
882        let owner = tape.node_len;
883        let edge_start = tape.edge_len;
884        let term_start = tape.diagonal_len;
885        let mut value = 0.0;
886        let mut gradient = [0.0; K];
887        let mut support = 0_u16;
888        for (input, stack) in inputs.iter().zip(derivative_stacks) {
889            value += stack[0];
890            Order2GraphWorkspace::push_edge(tape, input.node, stack[1]);
891            Order2GraphWorkspace::push_diagonal_term(tape, input.node, stack[2]);
892            support |= tape.nodes[input.node].support;
893            for primary in 0..K {
894                gradient[primary] += stack[1] * tape.gradients[input.node * K + primary];
895            }
896        }
897        Order2GraphWorkspace::push_event(
898            tape,
899            CurvatureEvent::Diagonal {
900                owner: owner as u8,
901                term_start: term_start as u16,
902                len: inputs.len() as u16,
903            },
904        );
905        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
906        Self { workspace, node }
907    }
908
909    #[inline(always)]
910    fn product(&self, right: &Self) -> Self {
911        self.mul(right)
912    }
913
914    #[inline(always)]
915    fn affine_compose(
916        &self,
917        input_scale: f64,
918        input_shift: f64,
919        derivative_stack: [f64; 5],
920        workspace: &'arena Self::Workspace,
921    ) -> Self {
922        assert!(std::ptr::eq(self.workspace, workspace));
923        assert!(input_shift.is_finite(), "affine input shift must be finite");
924        self.unary(
925            derivative_stack[0],
926            derivative_stack[1] * input_scale,
927            derivative_stack[2] * input_scale * input_scale,
928        )
929    }
930
931    #[inline(always)]
932    fn affine_composed_sum(
933        inputs: &[Self],
934        input_scales: &[f64],
935        derivative_stacks: &[[f64; 5]],
936        dimension: usize,
937        workspace: &'arena Self::Workspace,
938    ) -> Self {
939        assert_eq!(dimension, K, "compiled graph dimension mismatch");
940        assert_eq!(inputs.len(), input_scales.len());
941        assert_eq!(inputs.len(), derivative_stacks.len());
942        assert!(
943            inputs
944                .iter()
945                .all(|input| std::ptr::eq(input.workspace, workspace)),
946            "compiled graph composed inputs belong to different workspaces"
947        );
948        let tape = workspace.tape_mut();
949        let owner = tape.node_len;
950        let edge_start = tape.edge_len;
951        let term_start = tape.diagonal_len;
952        let mut value = 0.0;
953        let mut gradient = [0.0; K];
954        let mut support = 0_u16;
955        for ((input, &input_scale), stack) in inputs.iter().zip(input_scales).zip(derivative_stacks)
956        {
957            let first = stack[1] * input_scale;
958            let second = stack[2] * input_scale * input_scale;
959            value += stack[0];
960            Order2GraphWorkspace::push_edge(tape, input.node, first);
961            Order2GraphWorkspace::push_diagonal_term(tape, input.node, second);
962            support |= tape.nodes[input.node].support;
963            for primary in 0..K {
964                gradient[primary] += first * tape.gradients[input.node * K + primary];
965            }
966        }
967        Order2GraphWorkspace::push_event(
968            tape,
969            CurvatureEvent::Diagonal {
970                owner: owner as u8,
971                term_start: term_start as u16,
972                len: inputs.len() as u16,
973            },
974        );
975        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
976        Self { workspace, node }
977    }
978
979    #[inline(always)]
980    fn shared_multiply_add_affine_composed_sum<const N: usize>(
981        lefts: &[&Self; N],
982        right: &Self,
983        addend: &Self,
984        addend_scales: &[f64; N],
985        input_scales: &[f64; N],
986        derivative_stacks: &[[f64; 5]; N],
987        dimension: usize,
988        workspace: &'arena Self::Workspace,
989    ) -> Self {
990        assert_eq!(dimension, K, "compiled graph dimension mismatch");
991        assert!(
992            lefts
993                .iter()
994                .all(|input| std::ptr::eq(input.workspace, workspace))
995                && (N == 0 || std::ptr::eq(right.workspace, workspace)),
996            "compiled fused product-composition inputs belong to different workspaces"
997        );
998        let addend_live = addend_scales.iter().any(|&scale| scale != 0.0);
999        assert!(
1000            !addend_live || std::ptr::eq(addend.workspace, workspace),
1001            "live compiled fused addends belong to different workspaces"
1002        );
1003
1004        let tape = workspace.tape_mut();
1005        let right_node = right.node;
1006        let addend_node = addend.node;
1007        let (representatives, term_sources, source_count) =
1008            canonical_shared_source_schedule::<N>(|term, representative| {
1009                lefts[term].node == lefts[representative].node
1010                    && addend_scales[term] == addend_scales[representative]
1011            });
1012        let (value, source_derivatives) =
1013            aggregate_shared_source_derivatives(&term_sources, input_scales, derivative_stacks);
1014        let mut source_gradients = [[0.0; K]; N];
1015        let mut source_primary_axes = [NO_PRIMARY_AXIS; N];
1016        let mut gradient = [0.0; K];
1017        let mut support = 0_u16;
1018        let mut right_first = 0.0;
1019        let mut addend_first = 0.0;
1020        if N != 0 {
1021            support |= tape.nodes[right_node].support;
1022        }
1023        if addend_live {
1024            support |= tape.nodes[addend_node].support;
1025        }
1026        for source in 0..source_count {
1027            let term = representatives[source];
1028            let left = lefts[term].node;
1029            let left_value = tape.nodes[left].value;
1030            let right_value = tape.nodes[right_node].value;
1031            let first = source_derivatives[source][1];
1032            let left_primary_axis = tape.nodes[left].primary_axis;
1033            source_primary_axes[source] = left_primary_axis;
1034            support |= tape.nodes[left].support;
1035            right_first += first * left_value;
1036            addend_first += first * addend_scales[term];
1037            for primary in 0..K {
1038                let product_gradient = left_value * tape.gradients[right_node * K + primary];
1039                let inner_gradient = if addend_scales[term] == 0.0 {
1040                    product_gradient
1041                } else if addend_scales[term] == 1.0 {
1042                    product_gradient + tape.gradients[addend_node * K + primary]
1043                } else {
1044                    product_gradient
1045                        + addend_scales[term] * tape.gradients[addend_node * K + primary]
1046                };
1047                source_gradients[source][primary] = inner_gradient;
1048            }
1049            if left_primary_axis == NO_PRIMARY_AXIS {
1050                for primary in 0..K {
1051                    let left_contribution = tape.gradients[left * K + primary] * right_value;
1052                    source_gradients[source][primary] += left_contribution;
1053                    gradient[primary] += first * left_contribution;
1054                }
1055            } else {
1056                let primary = left_primary_axis as usize;
1057                source_gradients[source][primary] += right_value;
1058                gradient[primary] += first * right_value;
1059            }
1060        }
1061        if N != 0 {
1062            for primary in 0..K {
1063                gradient[primary] += right_first * tape.gradients[right_node * K + primary];
1064            }
1065        }
1066        if addend_live {
1067            for primary in 0..K {
1068                gradient[primary] += addend_first * tape.gradients[addend_node * K + primary];
1069            }
1070        }
1071
1072        let supported_dimension = support.count_ones() as usize;
1073        let curvature_len = supported_dimension * (supported_dimension + 1) / 2;
1074        assert!(
1075            curvature_len <= MAX_PROJECTED_CURVATURE_VALUES - tape.projected_curvature_len,
1076            "compiled graph projected-curvature capacity exceeded"
1077        );
1078        let curvature_start = tape.projected_curvature_len;
1079        tape.projected_curvature_len += curvature_len;
1080        let mut curvature_offset = 0;
1081        for_each_supported_upper_by_column(support, |primary, other| {
1082            let mut channel = 0.0;
1083            for source in 0..source_count {
1084                channel += source_derivatives[source][2]
1085                    * source_gradients[source][primary]
1086                    * source_gradients[source][other];
1087                if source_primary_axes[source] == NO_PRIMARY_AXIS {
1088                    let term = representatives[source];
1089                    let left = lefts[term].node;
1090                    let cross = tape.gradients[left * K + primary]
1091                        * tape.gradients[right_node * K + other]
1092                        + tape.gradients[left * K + other]
1093                            * tape.gradients[right_node * K + primary];
1094                    channel += source_derivatives[source][1] * cross;
1095                }
1096            }
1097            tape.projected_curvatures[curvature_start + curvature_offset] = channel;
1098            curvature_offset += 1;
1099        });
1100        for source in 0..source_count {
1101            let primary_axis = source_primary_axes[source];
1102            if primary_axis == NO_PRIMARY_AXIS {
1103                continue;
1104            }
1105            let primary_axis = primary_axis as usize;
1106            let first = source_derivatives[source][1];
1107            let mut right_support = tape.nodes[right_node].support;
1108            while right_support != 0 {
1109                let right_axis = right_support.trailing_zeros() as usize;
1110                right_support &= right_support - 1;
1111                let primary = primary_axis.min(right_axis);
1112                let other = primary_axis.max(right_axis);
1113                let offset = supported_upper_offset(support, primary, other);
1114                let diagonal_scale = if primary_axis == right_axis { 2.0 } else { 1.0 };
1115                let cross = diagonal_scale * tape.gradients[right_node * K + right_axis];
1116                tape.projected_curvatures[curvature_start + offset] += first * cross;
1117            }
1118        }
1119        assert_eq!(
1120            curvature_offset, curvature_len,
1121            "compiled fused product-composition must fill its packed curvature"
1122        );
1123
1124        let owner = tape.node_len;
1125        let edge_start = tape.edge_len;
1126        for source in 0..source_count {
1127            let term = representatives[source];
1128            let left = lefts[term].node;
1129            let left_first = source_derivatives[source][1] * tape.nodes[right_node].value;
1130            Order2GraphWorkspace::push_edge(tape, left, left_first);
1131        }
1132        if N != 0 {
1133            Order2GraphWorkspace::push_edge(tape, right_node, right_first);
1134        }
1135        if addend_live {
1136            Order2GraphWorkspace::push_edge(tape, addend_node, addend_first);
1137        }
1138        Order2GraphWorkspace::push_event(
1139            tape,
1140            CurvatureEvent::Projected {
1141                owner: owner as u8,
1142                curvature_start: curvature_start as u16,
1143                support,
1144            },
1145        );
1146        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
1147        Self { workspace, node }
1148    }
1149
1150    #[inline(always)]
1151    fn dimension(&self) -> usize {
1152        K
1153    }
1154
1155    #[inline(always)]
1156    fn value(&self) -> f64 {
1157        self.workspace.tape().nodes[self.node].value
1158    }
1159
1160    #[inline(always)]
1161    fn add(&self, other: &Self) -> Self {
1162        self.assert_compatible(other);
1163        let tape = self.workspace.tape_mut();
1164        let mut gradient = [0.0; K];
1165        for primary in 0..K {
1166            gradient[primary] =
1167                tape.gradients[self.node * K + primary] + tape.gradients[other.node * K + primary];
1168        }
1169        let value = tape.nodes[self.node].value + tape.nodes[other.node].value;
1170        let support = tape.nodes[self.node].support | tape.nodes[other.node].support;
1171        let edge_start = tape.edge_len;
1172        Order2GraphWorkspace::push_edge(tape, self.node, 1.0);
1173        Order2GraphWorkspace::push_edge(tape, other.node, 1.0);
1174        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
1175        Self {
1176            workspace: self.workspace,
1177            node,
1178        }
1179    }
1180
1181    #[inline(always)]
1182    fn sub(&self, other: &Self) -> Self {
1183        self.assert_compatible(other);
1184        let tape = self.workspace.tape_mut();
1185        let mut gradient = [0.0; K];
1186        for primary in 0..K {
1187            gradient[primary] =
1188                tape.gradients[self.node * K + primary] - tape.gradients[other.node * K + primary];
1189        }
1190        let value = tape.nodes[self.node].value - tape.nodes[other.node].value;
1191        let support = tape.nodes[self.node].support | tape.nodes[other.node].support;
1192        let edge_start = tape.edge_len;
1193        Order2GraphWorkspace::push_edge(tape, self.node, 1.0);
1194        Order2GraphWorkspace::push_edge(tape, other.node, -1.0);
1195        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
1196        Self {
1197            workspace: self.workspace,
1198            node,
1199        }
1200    }
1201
1202    #[inline(always)]
1203    fn mul(&self, other: &Self) -> Self {
1204        self.assert_compatible(other);
1205        let tape = self.workspace.tape_mut();
1206        let left_value = tape.nodes[self.node].value;
1207        let right_value = tape.nodes[other.node].value;
1208        let mut gradient = [0.0; K];
1209        for primary in 0..K {
1210            gradient[primary] = left_value * tape.gradients[other.node * K + primary]
1211                + tape.gradients[self.node * K + primary] * right_value;
1212        }
1213        let support = tape.nodes[self.node].support | tape.nodes[other.node].support;
1214        let owner = tape.node_len;
1215        let edge_start = tape.edge_len;
1216        Order2GraphWorkspace::push_edge(tape, self.node, right_value);
1217        Order2GraphWorkspace::push_edge(tape, other.node, left_value);
1218        Order2GraphWorkspace::push_event(
1219            tape,
1220            CurvatureEvent::Cross {
1221                owner: owner as u8,
1222                left: self.node as u8,
1223                right: other.node as u8,
1224            },
1225        );
1226        let node = Order2GraphWorkspace::push(
1227            tape,
1228            left_value * right_value,
1229            support,
1230            gradient,
1231            edge_start,
1232        );
1233        Self {
1234            workspace: self.workspace,
1235            node,
1236        }
1237    }
1238
1239    #[inline(always)]
1240    fn neg(&self) -> Self {
1241        self.unary(-self.value(), -1.0, 0.0)
1242    }
1243
1244    #[inline(always)]
1245    fn scale(&self, scale: f64) -> Self {
1246        let tape = self.workspace.tape_mut();
1247        let mut gradient = [0.0; K];
1248        for primary in 0..K {
1249            gradient[primary] = scale * tape.gradients[self.node * K + primary];
1250        }
1251        let value = scale * tape.nodes[self.node].value;
1252        let support = tape.nodes[self.node].support;
1253        let edge_start = tape.edge_len;
1254        Order2GraphWorkspace::push_edge(tape, self.node, scale);
1255        let node = Order2GraphWorkspace::push(tape, value, support, gradient, edge_start);
1256        Self {
1257            workspace: self.workspace,
1258            node,
1259        }
1260    }
1261
1262    #[inline(always)]
1263    fn compose_unary(&self, derivatives: [f64; 5]) -> Self {
1264        self.unary(derivatives[0], derivatives[1], derivatives[2])
1265    }
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270    use super::*;
1271    use crate::jet_scalar::{DynamicJetArena, DynamicOrder2, FixedRuntimeJet, JetScalar};
1272    use crate::nested_dual::JetField;
1273    use std::cell::Cell;
1274
1275    #[test]
1276    fn lower_into_overwrites_every_channel_across_workspace_reset() {
1277        let mut workspace = Order2GraphWorkspace::new();
1278        workspace.reset(3);
1279        let x = Order2Graph::<3>::variable(0.5, 0, 3, &workspace);
1280        let y = Order2Graph::<3>::variable(-0.25, 1, 3, &workspace);
1281        let output = x.product(&y);
1282        let mut gradient = [f64::NAN; 3];
1283        let mut hessian = [17.0; 9];
1284
1285        assert_eq!(output.lower_into(&mut gradient, &mut hessian), -0.125);
1286        assert_eq!(gradient, [-0.25, 0.5, 0.0]);
1287        assert_eq!(hessian, [0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
1288
1289        workspace.reset(3);
1290        let x = Order2Graph::<3>::variable(0.25, 0, 3, &workspace);
1291        let z = Order2Graph::<3>::variable(0.5, 2, 3, &workspace);
1292        let output = Order2Graph::linear_combination(&[x, z], &[2.0, -4.0], 3, &workspace);
1293        gradient.fill(f64::NAN);
1294        hessian.fill(-9.0);
1295
1296        assert_eq!(output.lower_into(&mut gradient, &mut hessian), -1.5);
1297        assert_eq!(gradient, [2.0, 0.0, -4.0]);
1298        assert_eq!(hessian, [0.0; 9]);
1299    }
1300
1301    struct DenseSymmetric3([[f64; 3]; 3]);
1302
1303    impl SymmetricQuadraticCoefficients for DenseSymmetric3 {
1304        fn dimension(&self) -> usize {
1305            3
1306        }
1307
1308        fn multiply(&self, input: &[f64], output: &mut [f64]) {
1309            for row in 0..3 {
1310                output[row] = (0..3)
1311                    .map(|column| self.0[row][column] * input[column])
1312                    .sum();
1313            }
1314        }
1315
1316        fn coefficient(&self, row: usize, column: usize) -> f64 {
1317            self.0[row][column]
1318        }
1319    }
1320
1321    struct MatrixFreeDense3<'arena> {
1322        matrix: [[f64; 3]; 3],
1323        workspace: &'arena Order2GraphWorkspace,
1324    }
1325
1326    impl SymmetricQuadraticCoefficients for MatrixFreeDense3<'_> {
1327        fn dimension(&self) -> usize {
1328            3
1329        }
1330
1331        fn multiply(&self, input: &[f64], output: &mut [f64]) {
1332            assert!(self.workspace.tape().node_len != 0);
1333            for row in 0..3 {
1334                output[row] = (0..3)
1335                    .map(|column| self.matrix[row][column] * input[column])
1336                    .sum();
1337            }
1338        }
1339
1340        fn coefficient(&self, _: usize, _: usize) -> f64 {
1341            panic!("compiled graph quadratic lowering must preserve matrix-free multiply")
1342        }
1343    }
1344
1345    struct MatrixFreeIdentity32<'arena> {
1346        workspace: &'arena Order2GraphWorkspace,
1347    }
1348
1349    impl SymmetricQuadraticCoefficients for MatrixFreeIdentity32<'_> {
1350        fn dimension(&self) -> usize {
1351            MAX_QUADRATIC_ARITY
1352        }
1353
1354        fn multiply(&self, input: &[f64], output: &mut [f64]) {
1355            assert!(self.workspace.tape().node_len != 0);
1356            output.copy_from_slice(input);
1357        }
1358
1359        fn coefficient(&self, _: usize, _: usize) -> f64 {
1360            panic!("compiled graph quadratic lowering must preserve matrix-free multiply")
1361        }
1362    }
1363
1364    struct CountingIdentity3<'arena> {
1365        workspace: &'arena Order2GraphWorkspace,
1366        multiply_calls: Cell<usize>,
1367    }
1368
1369    impl SymmetricQuadraticCoefficients for CountingIdentity3<'_> {
1370        fn dimension(&self) -> usize {
1371            3
1372        }
1373
1374        fn multiply(&self, input: &[f64], output: &mut [f64]) {
1375            assert!(self.workspace.tape().node_len != 0);
1376            self.multiply_calls.set(self.multiply_calls.get() + 1);
1377            output.copy_from_slice(input);
1378        }
1379
1380        fn coefficient(&self, _: usize, _: usize) -> f64 {
1381            panic!("compiled graph quadratic lowering must preserve matrix-free multiply")
1382        }
1383    }
1384
1385    #[test]
1386    fn compiled_graph_quadratic_arity_is_independent_and_matrix_free() {
1387        let mut workspace = Order2GraphWorkspace::new();
1388        workspace.reset(2);
1389        let x = Order2Graph::<2>::variable(0.4, 0, 2, &workspace);
1390        let y = Order2Graph::<2>::variable(-0.7, 1, 2, &workspace);
1391        let xy = x.product(&y);
1392        assert_eq!(
1393            workspace.tape().nodes[xy.node].primary_axis,
1394            NO_PRIMARY_AXIS,
1395            "derived quadratic inputs must select the unrestricted Jacobian projection",
1396        );
1397        let graph_linear = Order2Graph::linear_combination(&[x, xy], &[0.3, -0.8], 2, &workspace);
1398        let coefficients = MatrixFreeDense3 {
1399            matrix: [[1.2, -0.3, 0.25], [-0.3, 0.8, 0.17], [0.25, 0.17, 1.4]],
1400            workspace: &workspace,
1401        };
1402        let graph =
1403            Order2Graph::<2>::symmetric_quadratic_form(&[x, y, xy], &coefficients, 2, &workspace)
1404                .into_order2();
1405
1406        let arena = DynamicJetArena::new();
1407        let eager_x = DynamicOrder2::variable(0.4, 0, 2, &arena);
1408        let eager_y = DynamicOrder2::variable(-0.7, 1, 2, &arena);
1409        let eager_xy = eager_x.product(&eager_y);
1410        let eager_linear =
1411            DynamicOrder2::linear_combination(&[eager_x, eager_xy], &[0.3, -0.8], 2, &arena);
1412        let eager = DynamicOrder2::symmetric_quadratic_form(
1413            &[eager_x, eager_y, eager_xy],
1414            &coefficients,
1415            2,
1416            &arena,
1417        );
1418
1419        let close = |actual: f64, expected: f64| {
1420            let tolerance = 2.0e-13 * actual.abs().max(expected.abs()).max(1.0);
1421            assert!((actual - expected).abs() <= tolerance);
1422        };
1423        let graph_linear = graph_linear.into_order2();
1424        close(graph_linear.value(), eager_linear.v);
1425        close(graph.value(), eager.v);
1426        for primary in 0..2 {
1427            close(graph_linear.g()[primary], eager_linear.g()[primary]);
1428            close(graph.g()[primary], eager.g()[primary]);
1429        }
1430        for row in 0..2 {
1431            for column in 0..2 {
1432                close(
1433                    graph_linear.h()[row][column],
1434                    eager_linear.h_at(row, column),
1435                );
1436                close(graph.h()[row][column], eager.h_at(row, column));
1437            }
1438        }
1439    }
1440
1441    #[test]
1442    fn compiled_graph_primary_quadratic_groups_repeated_permuted_sparse_axes() {
1443        let mut workspace = Order2GraphWorkspace::new();
1444        workspace.reset(4);
1445        let x = Order2Graph::<4>::variable(0.4, 0, 4, &workspace);
1446        let z = Order2Graph::<4>::variable(1.1, 2, 4, &workspace);
1447        let linear = Order2Graph::linear_combination(&[z, x, z], &[0.3, -0.7, 1.1], 4, &workspace)
1448            .into_order2();
1449        let close = |actual: f64, expected: f64| {
1450            let tolerance = 2.0e-13 * actual.abs().max(expected.abs()).max(1.0);
1451            assert!((actual - expected).abs() <= tolerance);
1452        };
1453        assert!((linear.value() - 1.26).abs() <= 2.0e-13);
1454        for (actual, expected) in linear.g().iter().zip([-0.7, 0.0, 1.4, 0.0]) {
1455            close(*actual, expected);
1456        }
1457        assert!(linear.h().iter().flatten().all(|&channel| channel == 0.0));
1458        let coefficients = MatrixFreeDense3 {
1459            matrix: [[1.2, -0.3, 0.25], [-0.3, 0.8, 0.17], [0.25, 0.17, 1.4]],
1460            workspace: &workspace,
1461        };
1462        let graph =
1463            Order2Graph::<4>::symmetric_quadratic_form(&[z, x, z], &coefficients, 4, &workspace)
1464                .into_order2();
1465
1466        let arena = DynamicJetArena::new();
1467        let eager_x = DynamicOrder2::variable(0.4, 0, 4, &arena);
1468        let eager_z = DynamicOrder2::variable(1.1, 2, 4, &arena);
1469        let eager = DynamicOrder2::symmetric_quadratic_form(
1470            &[eager_z, eager_x, eager_z],
1471            &coefficients,
1472            4,
1473            &arena,
1474        );
1475
1476        close(graph.value(), eager.v);
1477        for primary in 0..4 {
1478            close(graph.g()[primary], eager.g()[primary]);
1479            for other in 0..4 {
1480                close(graph.h()[primary][other], eager.h_at(primary, other));
1481            }
1482        }
1483    }
1484
1485    #[test]
1486    fn compiled_graph_accepts_maximum_quadratic_arity_plus_output_node() {
1487        let mut workspace = Order2GraphWorkspace::new();
1488        workspace.reset(MAX_PRIMARY_DIMENSION);
1489        let values: [f64; MAX_QUADRATIC_ARITY] =
1490            std::array::from_fn(|axis| 0.01 * (axis + 1) as f64);
1491        let vars: [Order2Graph<'_, MAX_PRIMARY_DIMENSION>; MAX_QUADRATIC_ARITY] =
1492            std::array::from_fn(|axis| {
1493                Order2Graph::variable(
1494                    values[axis],
1495                    axis % MAX_PRIMARY_DIMENSION,
1496                    MAX_PRIMARY_DIMENSION,
1497                    &workspace,
1498                )
1499            });
1500        let coefficients = MatrixFreeIdentity32 {
1501            workspace: &workspace,
1502        };
1503        let graph = Order2Graph::symmetric_quadratic_form(
1504            &vars,
1505            &coefficients,
1506            MAX_PRIMARY_DIMENSION,
1507            &workspace,
1508        )
1509        .into_order2();
1510
1511        let expected_value = values.iter().map(|value| value * value).sum::<f64>();
1512        let tolerance = 2.0e-13;
1513        assert!((graph.value() - expected_value).abs() <= tolerance);
1514        for primary in 0..MAX_PRIMARY_DIMENSION {
1515            let expected_gradient = 2.0 * (values[primary] + values[primary + 16]);
1516            assert!((graph.g()[primary] - expected_gradient).abs() <= tolerance);
1517            for other in 0..MAX_PRIMARY_DIMENSION {
1518                let expected_hessian = if primary == other { 4.0 } else { 0.0 };
1519                assert!((graph.h()[primary][other] - expected_hessian).abs() <= tolerance);
1520            }
1521        }
1522    }
1523
1524    #[test]
1525    fn compiled_graph_projects_only_sparse_supported_primary_directions() {
1526        let mut workspace = Order2GraphWorkspace::new();
1527        workspace.reset(MAX_PRIMARY_DIMENSION);
1528        let x = Order2Graph::<MAX_PRIMARY_DIMENSION>::variable(
1529            0.4,
1530            0,
1531            MAX_PRIMARY_DIMENSION,
1532            &workspace,
1533        );
1534        let y = Order2Graph::<MAX_PRIMARY_DIMENSION>::variable(
1535            -0.7,
1536            7,
1537            MAX_PRIMARY_DIMENSION,
1538            &workspace,
1539        );
1540        let z = Order2Graph::<MAX_PRIMARY_DIMENSION>::variable(
1541            1.1,
1542            15,
1543            MAX_PRIMARY_DIMENSION,
1544            &workspace,
1545        );
1546        let coefficients = CountingIdentity3 {
1547            workspace: &workspace,
1548            multiply_calls: Cell::new(0),
1549        };
1550        let graph = Order2Graph::symmetric_quadratic_form(
1551            &[x, y, z],
1552            &coefficients,
1553            MAX_PRIMARY_DIMENSION,
1554            &workspace,
1555        )
1556        .into_order2();
1557
1558        assert_eq!(coefficients.multiply_calls.get(), 4);
1559        for primary in 0..MAX_PRIMARY_DIMENSION {
1560            let active = matches!(primary, 0 | 7 | 15);
1561            let expected_gradient = match primary {
1562                0 => 0.8,
1563                7 => -1.4,
1564                15 => 2.2,
1565                _ => 0.0,
1566            };
1567            assert_eq!(graph.g()[primary], expected_gradient);
1568            for other in 0..MAX_PRIMARY_DIMENSION {
1569                assert_eq!(
1570                    graph.h()[primary][other],
1571                    if active && primary == other { 2.0 } else { 0.0 }
1572                );
1573            }
1574        }
1575    }
1576
1577    #[test]
1578    fn compiled_fused_addend_support_obeys_structural_coefficient() {
1579        let mut workspace = Order2GraphWorkspace::new();
1580        {
1581            workspace.reset(3);
1582            let left = Order2Graph::<3>::variable(0.4, 0, 3, &workspace);
1583            let right = Order2Graph::<3>::variable(-0.7, 1, 3, &workspace);
1584            let omitted = Order2Graph::<3>::variable(1.1, 2, 3, &workspace);
1585            let output = Order2Graph::shared_multiply_add_affine_composed_sum(
1586                &[&left],
1587                &right,
1588                &omitted,
1589                &[-0.0],
1590                &[1.0],
1591                &[[0.2, 0.0, 1.0, 0.0, 0.0]],
1592                3,
1593                &workspace,
1594            );
1595            assert_eq!(workspace.tape().nodes[output.node].support, 0b011);
1596            let channels = output.into_order2();
1597            assert!(channels.g()[2] == 0.0);
1598            assert!((0..3).all(|axis| channels.h()[axis][2] == 0.0));
1599        }
1600
1601        workspace.reset(3);
1602        let left = Order2Graph::<3>::variable(0.4, 0, 3, &workspace);
1603        let right = Order2Graph::<3>::variable(-0.7, 1, 3, &workspace);
1604        let live = Order2Graph::<3>::variable(1.1, 2, 3, &workspace);
1605        let output = Order2Graph::shared_multiply_add_affine_composed_sum(
1606            &[&left],
1607            &right,
1608            &live,
1609            &[1.0],
1610            &[1.0],
1611            &[[0.2, 0.0, 1.0, 0.0, 0.0]],
1612            3,
1613            &workspace,
1614        );
1615        assert_eq!(workspace.tape().nodes[output.node].support, 0b111);
1616        let channels = output.into_order2();
1617        assert_eq!(channels.g()[2], 0.0);
1618        assert_eq!(channels.h()[2][2], 1.0);
1619    }
1620
1621    fn mixed_primary_derived_fused_expression<'arena, S: RuntimeJetScalar<'arena>>(
1622        vars: &[S; 5],
1623        workspace: &'arena S::Workspace,
1624    ) -> S {
1625        let right = vars[4].affine_compose(1.2, -0.1, [0.7, -1.2, 0.45, 0.0, 0.0], workspace);
1626        let derived_left = vars[2].multiply_add(&vars[3], &vars[0]);
1627        let addend = S::linear_combination(vars, &[0.3, -0.8, 0.5, 1.1, -0.4], 5, workspace);
1628        S::shared_multiply_add_affine_composed_sum(
1629            &[&vars[1], &vars[0], &vars[1], &derived_left],
1630            &right,
1631            &addend,
1632            &[1.0, 1.0, 1.0, -0.4],
1633            &[-1.0, -1.0, 1.0, 0.75],
1634            &[
1635                [0.4, -0.8, 0.3, 0.0, 0.0],
1636                [-0.2, 0.5, -0.7, 0.0, 0.0],
1637                [0.9, 1.1, 0.2, 0.0, 0.0],
1638                [-0.6, 0.4, 0.8, 0.0, 0.0],
1639            ],
1640            5,
1641            workspace,
1642        )
1643    }
1644
1645    #[test]
1646    fn compiled_fused_primary_scatter_matches_eager_with_mixed_lefts() {
1647        let values = [0.4, -0.7, 1.1, -0.3, 0.8];
1648        let eager_vars: [FixedRuntimeJet<Order2<5>, 5>; 5] = std::array::from_fn(|axis| {
1649            FixedRuntimeJet::from_inner(Order2::variable(values[axis], axis))
1650        });
1651        let eager = mixed_primary_derived_fused_expression(&eager_vars, &()).into_inner();
1652
1653        let mut workspace = Order2GraphWorkspace::new();
1654        workspace.reset(5);
1655        let graph_vars: [Order2Graph<'_, 5>; 5] =
1656            std::array::from_fn(|axis| Order2Graph::variable(values[axis], axis, 5, &workspace));
1657        let graph_output = mixed_primary_derived_fused_expression(&graph_vars, &workspace);
1658        let output_node = workspace.tape().nodes[graph_output.node];
1659        assert_eq!(output_node.edge_len, 5);
1660        let graph = graph_output.into_order2();
1661
1662        let close = |actual: f64, expected: f64| {
1663            let tolerance = 2.0e-12 * actual.abs().max(expected.abs()).max(1.0);
1664            assert!((actual - expected).abs() <= tolerance);
1665        };
1666        close(graph.value(), eager.value());
1667        for primary in 0..5 {
1668            close(graph.g()[primary], eager.g()[primary]);
1669            for other in 0..5 {
1670                close(graph.h()[primary][other], eager.h()[primary][other]);
1671            }
1672        }
1673    }
1674
1675    fn expression<'arena, S: RuntimeJetScalar<'arena>>(
1676        vars: &[S; 6],
1677        coefficients: &DenseSymmetric3,
1678        scales: &[f64; 4],
1679        stacks: &[[f64; 5]; 4],
1680        workspace: &'arena S::Workspace,
1681    ) -> S {
1682        let nonlinear = [
1683            vars[0].product(&vars[1]),
1684            vars[2].affine_compose(scales[0], scales[1], stacks[0], workspace),
1685            vars[3].multiply_add(&vars[4], &vars[5]),
1686        ];
1687        let quadratic = S::symmetric_quadratic_form(&nonlinear, coefficients, 6, workspace);
1688        let linear = S::linear_combination(vars, &[0.2, -0.7, 1.1, 0.4, -0.3, 0.8], 6, workspace);
1689        let product = quadratic.product(&linear);
1690        S::affine_composed_sum(
1691            &[quadratic, linear, product, nonlinear[1].clone()],
1692            &[scales[0], scales[1], scales[2], scales[3]],
1693            stacks,
1694            6,
1695            workspace,
1696        )
1697    }
1698
1699    fn fused_expression<'arena, S: RuntimeJetScalar<'arena>>(
1700        vars: &[S; 6],
1701        scales: &[f64; 4],
1702        stacks: &[[f64; 5]; 4],
1703        workspace: &'arena S::Workspace,
1704    ) -> S {
1705        const N: usize = 10;
1706        let upstream = [
1707            vars[0].product(&vars[1]),
1708            vars[2].affine_compose(scales[0], scales[1], stacks[0], workspace),
1709            vars[3].multiply_add(&vars[4], &vars[5]),
1710        ];
1711        let repeated_left = upstream[1].clone();
1712        let mut lefts: [&S; N] = std::array::from_fn(|term| &upstream[term % upstream.len()]);
1713        lefts[9] = &repeated_left;
1714        let right = &upstream[1];
1715        let addend = &vars[2];
1716        let addend_scales: [f64; N] = std::array::from_fn(|term| match term % 4 {
1717            0 => 0.0,
1718            1 => 1.0,
1719            2 => -0.75,
1720            _ => 0.35,
1721        });
1722        let mut input_scales: [f64; N] = std::array::from_fn(|term| match term {
1723            0 => 0.0,
1724            1 => -1.25,
1725            _ => scales[term % scales.len()],
1726        });
1727        input_scales[9] = 1.25;
1728        let derivative_stacks: [[f64; 5]; N] =
1729            std::array::from_fn(|term| stacks[term % stacks.len()]);
1730        S::shared_multiply_add_affine_composed_sum(
1731            &lefts,
1732            right,
1733            addend,
1734            &addend_scales,
1735            &input_scales,
1736            &derivative_stacks,
1737            6,
1738            workspace,
1739        )
1740    }
1741
1742    #[test]
1743    fn compiled_graph_matches_eager_order2_randomized_full_vgh() {
1744        fn sample(state: &mut u64) -> f64 {
1745            *state ^= *state << 13;
1746            *state ^= *state >> 7;
1747            *state ^= *state << 17;
1748            let unit = (*state >> 11) as f64 * (1.0 / ((1_u64 << 53) as f64));
1749            2.0 * unit - 1.0
1750        }
1751
1752        fn close(actual: f64, expected: f64, case: usize, label: &str) {
1753            let tolerance = 5.0e-12 * actual.abs().max(expected.abs()).max(1.0);
1754            assert!(
1755                (actual - expected).abs() <= tolerance,
1756                "case {case} {label}: graph={actual:+.16e}, eager={expected:+.16e}, tolerance={tolerance:.3e}"
1757            );
1758        }
1759
1760        let mut state = 0x932d_a660_5eed_f00d_u64;
1761        let mut workspace = Order2GraphWorkspace::new();
1762        let mut fused_workspace = Order2GraphWorkspace::new();
1763
1764        fused_workspace.reset(6);
1765        let empty_terms: [&Order2Graph<'_, 6>; 0] = [];
1766        let empty_shared = Order2Graph::constant(1.0, 6, &fused_workspace);
1767        let empty_scales: [f64; 0] = [];
1768        let empty_stacks: [[f64; 5]; 0] = [];
1769        let empty = Order2Graph::shared_multiply_add_affine_composed_sum(
1770            &empty_terms,
1771            &empty_shared,
1772            &empty_shared,
1773            &empty_scales,
1774            &empty_scales,
1775            &empty_stacks,
1776            6,
1777            &fused_workspace,
1778        )
1779        .into_order2();
1780        assert_eq!(empty.value().to_bits(), 0.0_f64.to_bits());
1781        assert!(empty.g().iter().all(|&channel| channel == 0.0));
1782        assert!(empty.h().iter().flatten().all(|&channel| channel == 0.0));
1783
1784        for case in 0..256 {
1785            let values: [f64; 6] = std::array::from_fn(|_| sample(&mut state));
1786            let scales: [f64; 4] = std::array::from_fn(|_| sample(&mut state));
1787            let stacks: [[f64; 5]; 4] =
1788                std::array::from_fn(|_| std::array::from_fn(|_| sample(&mut state)));
1789            let raw: [[f64; 3]; 3] =
1790                std::array::from_fn(|_| std::array::from_fn(|_| sample(&mut state)));
1791            let coefficients = DenseSymmetric3([
1792                [raw[0][0], raw[0][1], raw[0][2]],
1793                [raw[0][1], raw[1][1], raw[1][2]],
1794                [raw[0][2], raw[1][2], raw[2][2]],
1795            ]);
1796
1797            let eager_vars: [FixedRuntimeJet<Order2<6>, 6>; 6] = std::array::from_fn(|axis| {
1798                FixedRuntimeJet::from_inner(Order2::variable(values[axis], axis))
1799            });
1800            let eager = expression(&eager_vars, &coefficients, &scales, &stacks, &()).into_inner();
1801            let eager_fused = fused_expression(&eager_vars, &scales, &stacks, &()).into_inner();
1802
1803            workspace.reset(6);
1804            let graph_vars: [Order2Graph<'_, 6>; 6] = std::array::from_fn(|axis| {
1805                Order2Graph::variable(values[axis], axis, 6, &workspace)
1806            });
1807            let graph =
1808                expression(&graph_vars, &coefficients, &scales, &stacks, &workspace).into_order2();
1809            fused_workspace.reset(6);
1810            let fused_graph_vars: [Order2Graph<'_, 6>; 6] = std::array::from_fn(|axis| {
1811                Order2Graph::variable(values[axis], axis, 6, &fused_workspace)
1812            });
1813            let graph_fused =
1814                fused_expression(&fused_graph_vars, &scales, &stacks, &fused_workspace)
1815                    .into_order2();
1816
1817            close(graph.value(), eager.value(), case, "value");
1818            close(
1819                graph_fused.value(),
1820                eager_fused.value(),
1821                case,
1822                "fused value",
1823            );
1824            for primary in 0..6 {
1825                close(graph.g()[primary], eager.g()[primary], case, "gradient");
1826                close(
1827                    graph_fused.g()[primary],
1828                    eager_fused.g()[primary],
1829                    case,
1830                    "fused gradient",
1831                );
1832                for other in 0..6 {
1833                    close(
1834                        graph.h()[primary][other],
1835                        eager.h()[primary][other],
1836                        case,
1837                        "Hessian",
1838                    );
1839                    close(
1840                        graph_fused.h()[primary][other],
1841                        eager_fused.h()[primary][other],
1842                        case,
1843                        "fused Hessian",
1844                    );
1845                }
1846            }
1847        }
1848    }
1849}