Skip to main content

pounce_nl/
nl_tape.rs

1//! Flat-tape reverse-mode AD for `.nl` expression trees.
2//!
3//! Replaces the FD-based Hessian path with a port of the tape AD used
4//! in `ripopt::nl::autodiff`. The tape is a `Vec<TapeOp>` where each op
5//! refers to its operands by tape-slot index; forward evaluation runs
6//! through the slice once filling a parallel `Vec<f64>` of values, and
7//! reverse-mode adjoints walk the same buffer backwards.
8//!
9//! Sparse Hessians are computed by forward-over-reverse: for each
10//! variable `j` that the tape depends on, run a forward tangent sweep
11//! seeded with `e_j`, then a second-order reverse sweep that produces
12//! column `j` of the Hessian. The caller supplies a `(row, col) -> nnz
13//! position` map (lower triangle, row >= col), and contributions are
14//! accumulated in place — the outer loop in `eval_h` calls the same
15//! map for the objective and every active constraint, so every
16//! Lagrangian term lands in the right slot.
17//!
18//! Common subexpressions are tape-emitted **once**: when the recursive
19//! builder hits `Expr::Cse(rc)` it keys on the `Arc` pointer identity,
20//! emitting the body the first time and returning the cached
21//! result-slot index on subsequent references. The forward pass then
22//! computes each CSE once and the reverse pass folds adjoints from
23//! every reference into a single slot — exact chain-rule behaviour.
24
25use std::collections::{BTreeSet, HashMap, HashSet};
26use std::sync::Arc;
27
28use super::nl_external::{EvalResult, ExternalArg, ExternalLibrary, ExternalResolver};
29use super::nl_reader::{BinOp, CmpOp, Expr, FuncallArg, UnaryOp};
30
31/// One operation in the flattened tape. Operand fields are tape-slot
32/// indices into the same tape; `Var(i)` references problem variable
33/// index `i` (read from the input `x` slice during forward).
34#[derive(Debug, Clone)]
35pub enum TapeOp {
36    Const(f64),
37    Var(usize),
38    Add(usize, usize),
39    Sub(usize, usize),
40    Mul(usize, usize),
41    Div(usize, usize),
42    Pow(usize, usize),
43    Neg(usize),
44    Abs(usize),
45    Sqrt(usize),
46    Exp(usize),
47    Log(usize),
48    Log10(usize),
49    Sin(usize),
50    Cos(usize),
51    Tan(usize),
52    Atan(usize),
53    Acos(usize),
54    Sinh(usize),
55    Cosh(usize),
56    Tanh(usize),
57    Asin(usize),
58    Acosh(usize),
59    Asinh(usize),
60    Atanh(usize),
61    /// Gauss error function `erf(vals[a])` (issue #469). Unlike the other
62    /// transcendentals here it has no AMPL `.nl` opcode — AMPL has no `erf`
63    /// — so it is reachable only through the in-memory `Expr` path
64    /// (`UnaryOp::Erf`), which is exactly the case it was added for:
65    /// modeling frontends that build a tape directly instead of round-
66    /// tripping through `.nl`. Smooth everywhere, with closed-form
67    /// derivatives `erf'(u) = 2/√π·exp(-u²)` and `erf''(u) = -2u·erf'(u)`.
68    Erf(usize),
69    /// `a·ln(a)` with the `a = 0` limit `0` (GAMS `entropy` up to sign).
70    ///
71    /// Like [`TapeOp::Erf`] it has no AMPL `.nl` opcode and is reachable only
72    /// through the in-memory `Expr` path (`UnaryOp::XLogX`). Unlike `Erf` it is
73    /// fused for **correctness**, not convenience: the second derivative is
74    /// `1/a`, which is finite for every positive `a` down to `1e-308`, but
75    /// *every* chain-rule decomposition routes through `ln''(a) = -1/a²`. At
76    /// `a = 1e-299` that intermediate is `-1e598` — past `f64::MAX` — so the
77    /// composed Hessian is `inf`/`nan` while the answer it is computing,
78    /// `1e299`, is an ordinary number. No amount of care in the `Mul`/`Log`
79    /// rules reaches it; only an op that never forms `1/a²` does.
80    XLogX(usize),
81    /// `a·ln(a/b)` with the `a = 0` limit `0` — GAMS `centropy`. Operands
82    /// `(a, b)`; `b > 0` is the caller's responsibility, as for `Log`.
83    ///
84    /// Fused for [`TapeOp::XLogX`]'s reason (`∂²/∂a² = 1/a` is unreachable
85    /// through `ln''`) plus one of its own: `∂²/∂b² = a/b²` squares the
86    /// denominator, which overflows for `|b| > 1.3e154` even when `a/b²` is
87    /// itself in range. The fused rule computes `q = a/b` once and expresses
88    /// every second-order term as a division by `b`, never by `b²`.
89    CEntropy(usize, usize),
90    /// Two-argument arctangent `atan2(vals[a], vals[b])` (operands are
91    /// `(y, x)`, matching AMPL's `atan2(y, x)` / `.nl` opcode o48).
92    Atan2(usize, usize),
93    /// Pairwise minimum `min(vals[a], vals[b])`. Piecewise linear: the
94    /// value/tangent/adjoint route through whichever operand is smaller
95    /// (ties pick the first), and the second derivative is identically
96    /// zero. n-ary AMPL `min` (opcode o11) folds to a chain of these.
97    Min(usize, usize),
98    /// Pairwise maximum `max(vals[a], vals[b])` — the `Min` mirror;
99    /// n-ary AMPL `max` (opcode o12) folds to a chain of these.
100    Max(usize, usize),
101    /// Relational comparison `vals[a] OP vals[b]` → `1.0`/`0.0`.
102    /// Piecewise constant, so its derivative is identically zero — the
103    /// AD passes treat it as a constant w.r.t. its operands.
104    Cmp(CmpOp, usize, usize),
105    /// Logical AND: `1.0` iff both operands are nonzero. Zero derivative.
106    And(usize, usize),
107    /// Logical OR: `1.0` iff either operand is nonzero. Zero derivative.
108    Or(usize, usize),
109    /// Logical NOT: `1.0` iff the operand is zero. Zero derivative.
110    Not(usize),
111    /// `if-then-else`: operands `(cond, then, else)`. The value is
112    /// `vals[then]` when `vals[cond] != 0` else `vals[else]`, and the
113    /// value/tangent/adjoint all route through the active branch only.
114    /// The condition contributes no derivative (the branch switch is a
115    /// non-smooth event the AD ignores).
116    Select(usize, usize, usize),
117    /// AMPL imported (external) function call. The payload (library
118    /// handle, name, and argument list) is boxed so this rare variant
119    /// does not inflate `size_of::<TapeOp>()`: without the box the
120    /// `Arc`+`String`+`Vec` make every op ~64 bytes, which on a
121    /// summand-split objective with millions of tiny tapes (e.g.
122    /// `sensors`) costs gigabytes. Boxing drops the common arithmetic
123    /// ops back to the size of the next-largest variant.
124    Funcall(Box<FuncallData>),
125}
126
127/// Boxed payload of [`TapeOp::Funcall`]. The library is kept alive by
128/// the `Arc`; `name` is the registered function name; `args` carries
129/// positional arguments where real-valued args reference earlier tape
130/// slots and string args are inline literals.
131#[derive(Debug, Clone)]
132pub struct FuncallData {
133    pub lib: Arc<ExternalLibrary>,
134    pub name: String,
135    pub args: Vec<TapeFuncallArg>,
136}
137
138/// One argument of a `TapeOp::Funcall`. Real arguments are tape-slot indices
139/// (their values come from the running `vals[]` during forward); string
140/// arguments are owned literals (AMPL `h<len>:<chars>` tokens).
141#[derive(Debug, Clone)]
142pub enum TapeFuncallArg {
143    Tape(usize),
144    Str(String),
145}
146
147/// `ln(a/b)`, computed without ever materializing an out-of-range `a/b`.
148///
149/// Shared by every arm of [`TapeOp::CEntropy`] so the value and the derivatives
150/// cannot drift apart. Three regimes:
151///
152///   * `a ≈ b` — the ratio is near 1, where `ln` loses digits to cancellation.
153///     `(a - b)` is exact by Sterbenz whenever `b/2 ≤ a ≤ 2b`, so `ln_1p` of
154///     `(a - b)/b` keeps full precision where `q.ln()` would not.
155///   * `a/b` finite and positive — plain `q.ln()`.
156///   * `a/b` overflowed, underflowed to zero, or went non-finite — fall back to
157///     `ln(a) - ln(b)`. At `a = 1e300, b = 1e-300` the ratio is `1e600` (inf)
158///     but `ln` of it is a perfectly ordinary `1381.55`; the difference form
159///     reaches it. This is the only regime where cancellation is a risk, and
160///     it cannot bite here because the ratio being out of range means the two
161///     logs are far apart.
162#[inline]
163pub(crate) fn ln_ratio(a: f64, b: f64) -> f64 {
164    let q = a / b;
165    if q.is_finite() && q > 0.0 {
166        let t = (a - b) / b;
167        if t.abs() < 0.5 { t.ln_1p() } else { q.ln() }
168    } else {
169        a.ln() - b.ln()
170    }
171}
172
173/// `a·ln(a)`, the value arm of [`TapeOp::XLogX`] — GAMS `entropy`.
174///
175/// Exists as a FUSED op because the chain rule provably cannot evaluate its
176/// second derivative. `(a·ln a)'' = 1/a`, which at `a = 1e-299` is `1e299` and
177/// perfectly representable — but any decomposition routes through
178/// `ln''(a) = -1/a² = -1e598`, which exceeds `f64::MAX`. The composite is in
179/// range while the factor it is built from is not, so no rule for `Mul` and
180/// `Log` can recover it, however carefully written. Only a fused op that never
181/// forms `1/a²` gets there.
182///
183/// `0·ln 0` is `0` by the limit, not `NaN`.
184#[inline]
185pub(crate) fn xlogx(a: f64) -> f64 {
186    if a == 0.0 { 0.0 } else { a * a.ln() }
187}
188
189/// `a·ln(a/b)`, the value arm of [`TapeOp::CEntropy`] — GAMS `centropy`.
190///
191/// Fused for the same reason as [`xlogx`], plus one of its own: `∂²/∂b²` is
192/// `a/b²`, and `b²` overflows for `|b| > 1.3e154` while `a/b²` itself stays in
193/// range. The fused rule computes it as `q/b` and never squares anything.
194///
195/// `0·ln(0/b)` is `0` by the limit.
196#[inline]
197pub(crate) fn centropy(a: f64, b: f64) -> f64 {
198    if a == 0.0 { 0.0 } else { a * ln_ratio(a, b) }
199}
200
201/// `(a·ln a)' = ln(a) + 1`. `-inf` at `a = 0`, which is the true one-sided limit.
202#[inline]
203pub(crate) fn xlogx_d1(a: f64) -> f64 {
204    a.ln() + 1.0
205}
206
207/// `(a·ln a)'' = 1/a`.
208///
209/// The entire reason [`TapeOp::XLogX`] exists: this is finite for every positive
210/// `a`, while the `ln''` any decomposition would go through is not.
211#[inline]
212pub(crate) fn xlogx_d2(a: f64) -> f64 {
213    1.0 / a
214}
215
216/// `∂/∂a [a·ln(a/b)] = ln(a/b) + 1`.
217#[inline]
218pub(crate) fn centropy_da(a: f64, b: f64) -> f64 {
219    ln_ratio(a, b) + 1.0
220}
221
222/// `∂/∂b [a·ln(a/b)] = -a/b`.
223#[inline]
224pub(crate) fn centropy_db(a: f64, b: f64) -> f64 {
225    -(a / b)
226}
227
228/// `∂²/∂a² [a·ln(a/b)] = 1/a` — independent of `b`; see [`xlogx_d2`].
229#[inline]
230pub(crate) fn centropy_daa(a: f64) -> f64 {
231    1.0 / a
232}
233
234/// `∂²/∂a∂b [a·ln(a/b)] = -1/b` — independent of `a`.
235#[inline]
236pub(crate) fn centropy_dab(b: f64) -> f64 {
237    -1.0 / b
238}
239
240/// `∂²/∂b² [a·ln(a/b)] = a/b²`, evaluated as `(a/b)/b`.
241///
242/// Never as `a/(b*b)`: at `a = 1e300, b = 1e200` the true value is `1e-100`, but
243/// `b*b` is `inf` and the squared form returns `0.0`. Dividing twice keeps it.
244#[inline]
245pub(crate) fn centropy_dbb(a: f64, b: f64) -> f64 {
246    (a / b) / b
247}
248
249/// Gauss error function, the value arm of [`TapeOp::Erf`].
250///
251/// Delegates to `libm` (the rust-lang port of musl's libm) rather than a
252/// series approximation: `erf` shows up inside residuals, so a 1e-7-accurate
253/// Abramowitz–Stegun fit would cap the achievable KKT error well above what
254/// the IPM asks for.
255#[inline]
256pub(crate) fn erf(u: f64) -> f64 {
257    libm::erf(u)
258}
259
260/// `erf'(u) = 2/√π · exp(-u²)`.
261///
262/// The AD sweeps all call this (rather than each inlining the constant) so
263/// the tangent and adjoint arms of [`TapeOp::Erf`] cannot drift apart.
264#[inline]
265pub(crate) fn erf_d1(u: f64) -> f64 {
266    std::f64::consts::FRAC_2_SQRT_PI * (-u * u).exp()
267}
268
269/// `erf''(u) = -2u · erf'(u)`.
270///
271/// Parenthesized as `-2·(u·erf'(u))`, not `(-2·u)·erf'(u)`. The two differ
272/// at the top of the range: `erf_d1` underflows to `0.0` around `|u| > 27`,
273/// while `-2.0 * u` overflows to `±inf` for `|u| > f64::MAX/2`, and
274/// `inf * 0.0` is `NaN` where the true limit is `0`. Multiplying `u` into
275/// the already-underflowed derivative first keeps every finite magnitude
276/// finite. (`u = ±inf` is `NaN` either way, correctly — the model has
277/// already left the reals by then.) Only the Hessian arms read this; the
278/// value and gradient arms are unaffected.
279#[inline]
280pub(crate) fn erf_d2(u: f64) -> f64 {
281    -2.0 * (u * erf_d1(u))
282}
283
284/// Evaluate a relational opcode on two scalar values, returning the
285/// boolean truth (callers map it to `1.0`/`0.0`).
286#[inline]
287fn cmp_holds(op: CmpOp, a: f64, b: f64) -> bool {
288    match op {
289        CmpOp::Lt => a < b,
290        CmpOp::Le => a <= b,
291        CmpOp::Eq => a == b,
292        CmpOp::Ge => a >= b,
293        CmpOp::Gt => a > b,
294        CmpOp::Ne => a != b,
295    }
296}
297
298fn funcall_to_ext_args<'a>(args: &'a [TapeFuncallArg], vals: &[f64]) -> Vec<ExternalArg<'a>> {
299    args.iter()
300        .map(|a| match a {
301            TapeFuncallArg::Tape(idx) => ExternalArg::Real(vals[*idx]),
302            TapeFuncallArg::Str(s) => ExternalArg::Str(s.as_str()),
303        })
304        .collect()
305}
306
307/// Evaluate an external (AMPL imported) function, poisoning the result with
308/// `NaN` instead of panicking when the library reports an error.
309///
310/// An external eval fails on user-controllable conditions — most commonly an
311/// out-of-domain property evaluation (e.g. an IDAES Helmholtz thermo call
312/// outside its valid pressure/temperature range). We mirror the tape's own
313/// arithmetic domain-error semantics (`log(-1) → NaN`): hand back NaN so the
314/// IPM sees a failed evaluation and the line search backs off, rather than
315/// raising an uncatchable panic across the pyo3 boundary on the `read_nl`
316/// surface. The NaN derivative/Hessian vectors are sized by the full argument
317/// count — an upper bound on the real-arg count a successful eval returns — so
318/// every downstream index into them stays in range.
319fn ext_eval_or_nan(
320    lib: &ExternalLibrary,
321    name: &str,
322    call_args: &[ExternalArg<'_>],
323    n_args: usize,
324    want_derivs: bool,
325    want_hes: bool,
326) -> EvalResult {
327    lib.eval(name, call_args, want_derivs, want_hes)
328        .unwrap_or_else(|_| EvalResult {
329            value: f64::NAN,
330            derivs: want_derivs.then(|| vec![f64::NAN; n_args]),
331            hessian: want_hes.then(|| vec![f64::NAN; n_args * (n_args + 1) / 2]),
332        })
333}
334
335/// A flattened expression tape. The result of evaluation is the value
336/// at slot `ops.len() - 1` (i.e. the last op).
337#[derive(Debug, Clone)]
338pub struct Tape {
339    pub ops: Vec<TapeOp>,
340}
341
342impl Tape {
343    /// Build a tape from an `Expr` tree (no AMPL external functions). CSE
344    /// bodies (`Expr::Cse(rc)`) are cached by `Arc` pointer identity so each
345    /// body is emitted once even when referenced many times.
346    pub fn build(expr: &Expr) -> Self {
347        Self::build_with_externals(expr, &ExternalResolver::default())
348    }
349
350    /// Build a tape from an `Expr` tree, resolving any `Expr::Funcall`
351    /// nodes through `resolver`. Panics if the expression references a
352    /// funcall id that is not in the resolver — `NlProblem::resolve_externals`
353    /// must populate the resolver before tape construction.
354    pub fn build_with_externals(expr: &Expr, resolver: &ExternalResolver) -> Self {
355        let mut ops = Vec::new();
356        let mut cache: HashMap<*const Expr, usize> = HashMap::new();
357        build_recursive(expr, &mut ops, &mut cache, resolver);
358        Tape { ops }
359    }
360
361    /// Forward sweep: returns `vals[i] = value of tape slot i`. The
362    /// scalar tape result is `vals[ops.len() - 1]`.
363    pub fn forward(&self, x: &[f64]) -> Vec<f64> {
364        let mut vals: Vec<f64> = Vec::with_capacity(self.ops.len());
365        for op in &self.ops {
366            let v = match op {
367                TapeOp::Const(c) => *c,
368                TapeOp::Var(i) => x[*i],
369                TapeOp::Add(a, b) => vals[*a] + vals[*b],
370                TapeOp::Sub(a, b) => vals[*a] - vals[*b],
371                TapeOp::Mul(a, b) => vals[*a] * vals[*b],
372                TapeOp::Div(a, b) => vals[*a] / vals[*b],
373                TapeOp::Pow(a, b) => vals[*a].powf(vals[*b]),
374                TapeOp::Neg(a) => -vals[*a],
375                TapeOp::Abs(a) => vals[*a].abs(),
376                TapeOp::Sqrt(a) => vals[*a].sqrt(),
377                TapeOp::Exp(a) => vals[*a].exp(),
378                TapeOp::Log(a) => vals[*a].ln(),
379                TapeOp::Log10(a) => vals[*a].log10(),
380                TapeOp::Sin(a) => vals[*a].sin(),
381                TapeOp::Cos(a) => vals[*a].cos(),
382                TapeOp::Tan(a) => vals[*a].tan(),
383                TapeOp::Atan(a) => vals[*a].atan(),
384                TapeOp::Acos(a) => vals[*a].acos(),
385                TapeOp::Sinh(a) => vals[*a].sinh(),
386                TapeOp::Cosh(a) => vals[*a].cosh(),
387                TapeOp::Tanh(a) => vals[*a].tanh(),
388                TapeOp::Asin(a) => vals[*a].asin(),
389                TapeOp::Acosh(a) => vals[*a].acosh(),
390                TapeOp::Asinh(a) => vals[*a].asinh(),
391                TapeOp::Atanh(a) => vals[*a].atanh(),
392                TapeOp::Erf(a) => erf(vals[*a]),
393                TapeOp::XLogX(a) => xlogx(vals[*a]),
394                TapeOp::CEntropy(a, b) => centropy(vals[*a], vals[*b]),
395                TapeOp::Atan2(a, b) => vals[*a].atan2(vals[*b]),
396                TapeOp::Min(a, b) => vals[*a].min(vals[*b]),
397                TapeOp::Max(a, b) => vals[*a].max(vals[*b]),
398                TapeOp::Cmp(op, a, b) => f64::from(cmp_holds(*op, vals[*a], vals[*b])),
399                TapeOp::And(a, b) => f64::from(vals[*a] != 0.0 && vals[*b] != 0.0),
400                TapeOp::Or(a, b) => f64::from(vals[*a] != 0.0 || vals[*b] != 0.0),
401                TapeOp::Not(a) => f64::from(vals[*a] == 0.0),
402                TapeOp::Select(c, t, e) => {
403                    if vals[*c] != 0.0 {
404                        vals[*t]
405                    } else {
406                        vals[*e]
407                    }
408                }
409                TapeOp::Funcall(fc) => {
410                    let FuncallData { lib, name, args } = fc.as_ref();
411                    let call_args = funcall_to_ext_args(args, &vals);
412                    let res = ext_eval_or_nan(lib, name, &call_args, args.len(), false, false);
413                    res.value
414                }
415            };
416            vals.push(v);
417        }
418        vals
419    }
420
421    pub fn eval(&self, x: &[f64]) -> f64 {
422        let vals = self.forward(x);
423        *vals.last().unwrap_or(&0.0)
424    }
425
426    /// Reverse-mode AD: accumulate `seed * df/dx_i` into `grad[i]` for
427    /// every problem variable `i` referenced by the tape. `grad` is
428    /// **not** zeroed by this routine — the caller can chain multiple
429    /// gradient accumulations into the same buffer.
430    pub fn gradient_seed(&self, x: &[f64], seed: f64, grad: &mut [f64]) {
431        if seed == 0.0 || self.ops.is_empty() {
432            return;
433        }
434        let vals = self.forward(x);
435        self.reverse(&vals, seed, grad);
436    }
437
438    /// Reverse-mode AD reusing two caller-supplied scratch buffers
439    /// (`vals` from [`forward_into`], and an `adj` arena ≥
440    /// `self.ops.len()`) instead of allocating a forward-value vector and
441    /// an adjoint vector per call like [`gradient_seed`]. The `.nl` design
442    /// emits one tiny tape per summand — ~10⁶ on large models — so a single
443    /// `eval_jac_g` / `eval_grad_f` drives this millions of times and the
444    /// per-call allocation dominated. `grad` is accumulated into (not
445    /// zeroed); `adj` may be passed dirty (it is zeroed at the touched
446    /// slots internally).
447    ///
448    /// [`forward_into`]: Tape::forward_into
449    pub fn gradient_seed_into(
450        &self,
451        x: &[f64],
452        seed: f64,
453        grad: &mut [f64],
454        vals: &mut [f64],
455        adj: &mut [f64],
456    ) {
457        if seed == 0.0 || self.ops.is_empty() {
458            return;
459        }
460        debug_assert!(vals.len() >= self.ops.len());
461        self.forward_into(x, vals);
462        self.reverse_into(vals, seed, grad, adj);
463    }
464
465    fn reverse(&self, vals: &[f64], seed: f64, grad: &mut [f64]) {
466        let n = self.ops.len();
467        let mut adj = vec![0.0f64; n];
468        self.reverse_into(vals, seed, grad, &mut adj);
469    }
470
471    /// Reverse adjoint sweep into a caller-supplied `adj` scratch buffer
472    /// (length ≥ `self.ops.len()`), the allocation-free core of [`reverse`].
473    /// `adj` is zeroed over `[0, n)` internally, so a dirty arena is fine;
474    /// `grad` is accumulated into (not zeroed).
475    fn reverse_into(&self, vals: &[f64], seed: f64, grad: &mut [f64], adj: &mut [f64]) {
476        let n = self.ops.len();
477        debug_assert!(adj.len() >= n);
478        adj[..n].fill(0.0);
479        adj[n - 1] = seed;
480
481        for i in (0..n).rev() {
482            let a = adj[i];
483            if a == 0.0 {
484                continue;
485            }
486            match &self.ops[i] {
487                TapeOp::Const(_) => {}
488                TapeOp::Var(j) => {
489                    grad[*j] += a;
490                }
491                TapeOp::Add(l, r) => {
492                    adj[*l] += a;
493                    adj[*r] += a;
494                }
495                TapeOp::Sub(l, r) => {
496                    adj[*l] += a;
497                    adj[*r] -= a;
498                }
499                TapeOp::Mul(l, r) => {
500                    adj[*l] += a * vals[*r];
501                    adj[*r] += a * vals[*l];
502                }
503                TapeOp::Div(l, r) => {
504                    // ∂(l/r)/∂r = -q/r, NOT -l/r²: r*r overflows for |r| > 1.3e154
505                    // and underflows for |r| < 1.5e-154, zeroing an adjoint that is
506                    // itself representable. `vals[i]` is q = l/r, already computed.
507                    let rv = vals[*r];
508                    adj[*l] += a / rv;
509                    adj[*r] -= a * vals[i] / rv;
510                }
511                TapeOp::Pow(l, r) => {
512                    let lv = vals[*l];
513                    let rv = vals[*r];
514                    if rv != 0.0 {
515                        adj[*l] += a * rv * lv.powf(rv - 1.0);
516                    }
517                    if lv > 0.0 {
518                        adj[*r] += a * vals[i] * lv.ln();
519                    }
520                }
521                TapeOp::Neg(j) => {
522                    adj[*j] -= a;
523                }
524                TapeOp::Abs(j) => {
525                    if vals[*j] >= 0.0 {
526                        adj[*j] += a;
527                    } else {
528                        adj[*j] -= a;
529                    }
530                }
531                TapeOp::Sqrt(j) => {
532                    let sv = vals[i];
533                    if sv > 0.0 {
534                        adj[*j] += a * 0.5 / sv;
535                    }
536                }
537                TapeOp::Exp(j) => {
538                    adj[*j] += a * vals[i];
539                }
540                TapeOp::Log(j) => {
541                    adj[*j] += a / vals[*j];
542                }
543                TapeOp::Log10(j) => {
544                    adj[*j] += a / (vals[*j] * std::f64::consts::LN_10);
545                }
546                TapeOp::Sin(j) => {
547                    adj[*j] += a * vals[*j].cos();
548                }
549                TapeOp::Cos(j) => {
550                    adj[*j] -= a * vals[*j].sin();
551                }
552                TapeOp::Tan(j) => {
553                    let t = vals[i];
554                    adj[*j] += a * (1.0 + t * t);
555                }
556                TapeOp::Atan(j) => {
557                    let u = vals[*j];
558                    adj[*j] += a / (1.0 + u * u);
559                }
560                TapeOp::Acos(j) => {
561                    let u = vals[*j];
562                    adj[*j] -= a / (1.0 - u * u).sqrt();
563                }
564                TapeOp::Sinh(j) => {
565                    adj[*j] += a * vals[*j].cosh();
566                }
567                TapeOp::Cosh(j) => {
568                    adj[*j] += a * vals[*j].sinh();
569                }
570                TapeOp::Tanh(j) => {
571                    let t = vals[i];
572                    adj[*j] += a * (1.0 - t * t);
573                }
574                TapeOp::Asin(j) => {
575                    let u = vals[*j];
576                    adj[*j] += a / (1.0 - u * u).sqrt();
577                }
578                TapeOp::Acosh(j) => {
579                    let u = vals[*j];
580                    adj[*j] += a / (u * u - 1.0).sqrt();
581                }
582                TapeOp::Asinh(j) => {
583                    let u = vals[*j];
584                    adj[*j] += a / (u * u + 1.0).sqrt();
585                }
586                TapeOp::Atanh(j) => {
587                    let u = vals[*j];
588                    adj[*j] += a / (1.0 - u * u);
589                }
590                TapeOp::Erf(j) => {
591                    adj[*j] += a * erf_d1(vals[*j]);
592                }
593                TapeOp::XLogX(j) => {
594                    adj[*j] += a * xlogx_d1(vals[*j]);
595                }
596                TapeOp::CEntropy(l, r) => {
597                    adj[*l] += a * centropy_da(vals[*l], vals[*r]);
598                    adj[*r] += a * centropy_db(vals[*l], vals[*r]);
599                }
600                TapeOp::Atan2(l, r) => {
601                    let y = vals[*l];
602                    let x = vals[*r];
603                    let d = y * y + x * x;
604                    adj[*l] += a * (x / d);
605                    adj[*r] += a * (-y / d);
606                }
607                // min/max are piecewise linear: the adjoint flows to the
608                // selected operand only (ties pick the first, a valid
609                // subgradient choice).
610                TapeOp::Min(l, r) => {
611                    if vals[*l] <= vals[*r] {
612                        adj[*l] += a;
613                    } else {
614                        adj[*r] += a;
615                    }
616                }
617                TapeOp::Max(l, r) => {
618                    if vals[*l] >= vals[*r] {
619                        adj[*l] += a;
620                    } else {
621                        adj[*r] += a;
622                    }
623                }
624                // Comparisons and logical connectives are piecewise
625                // constant: zero derivative, so no adjoint propagates.
626                TapeOp::Cmp(_, _, _) | TapeOp::And(_, _) | TapeOp::Or(_, _) | TapeOp::Not(_) => {}
627                // if-then-else: the adjoint flows entirely into the
628                // active branch; the condition gets none.
629                TapeOp::Select(c, t, e) => {
630                    if vals[*c] != 0.0 {
631                        adj[*t] += a;
632                    } else {
633                        adj[*e] += a;
634                    }
635                }
636                TapeOp::Funcall(fc) => {
637                    let FuncallData { lib, name, args } = fc.as_ref();
638                    let call_args = funcall_to_ext_args(args, vals);
639                    let res = ext_eval_or_nan(lib, name, &call_args, args.len(), true, false);
640                    let derivs = res.derivs.expect("want_derivs=true returns derivs");
641                    let mut k = 0usize;
642                    for arg in args {
643                        if let TapeFuncallArg::Tape(idx) = arg {
644                            adj[*idx] += a * derivs[k];
645                            k += 1;
646                        }
647                    }
648                }
649            }
650        }
651    }
652
653    /// Sorted distinct problem-variable indices that the tape depends on.
654    pub fn variables(&self) -> Vec<usize> {
655        let mut s: BTreeSet<usize> = BTreeSet::new();
656        for op in &self.ops {
657            if let TapeOp::Var(j) = op {
658                s.insert(*j);
659            }
660        }
661        s.into_iter().collect()
662    }
663
664    /// Forward tangent sweep: `dot[i] = d(slot_i) / dx_{seed_var}`.
665    /// Caller-supplied `dot` buffer is overwritten in full; no zeroing
666    /// needed beforehand because every slot is written before it is
667    /// read (the loop walks forward and only reads earlier slots).
668    fn forward_tangent(&self, vals: &[f64], seed_var: usize, dot: &mut [f64]) {
669        let n = self.ops.len();
670        debug_assert_eq!(dot.len(), n);
671        for i in 0..n {
672            dot[i] = match &self.ops[i] {
673                TapeOp::Const(_) => 0.0,
674                TapeOp::Var(k) => {
675                    if *k == seed_var {
676                        1.0
677                    } else {
678                        0.0
679                    }
680                }
681                TapeOp::Add(a, b) => dot[*a] + dot[*b],
682                TapeOp::Sub(a, b) => dot[*a] - dot[*b],
683                TapeOp::Mul(a, b) => dot[*a] * vals[*b] + vals[*a] * dot[*b],
684                TapeOp::Div(a, b) => {
685                    // (ȧ - q·ḃ)/b, NOT (ȧ·b - a·ḃ)/b². Squaring the denominator
686                    // overflows for |b| > 1.3e154 and underflows for |b| < 1.5e-154,
687                    // destroying a tangent that is itself perfectly representable:
688                    // at a = b = 1e300 with ḃ = 1 the true -a/b² is -1e-300, but
689                    // b*b is inf so the squared form returns -0.0. Dividing by b
690                    // twice instead of by b² once is the standard Kahan form, and
691                    // costs nothing -- q is this node's own value, already computed.
692                    (dot[*a] - vals[i] * dot[*b]) / vals[*b]
693                }
694                TapeOp::Pow(a, b) => {
695                    let u = vals[*a];
696                    let r = vals[*b];
697                    let du = dot[*a];
698                    let dr = dot[*b];
699                    let mut result = 0.0;
700                    // Match the reverse-mode gradient's guard (`rv != 0.0` only): at base
701                    // u == 0 the slope is still well defined for r >= 1 (and a
702                    // genuine ±inf for r < 1), so it must not be silently dropped,
703                    // or the forward tangent disagrees with the reverse gradient.
704                    if r != 0.0 {
705                        result += r * u.powf(r - 1.0) * du;
706                    }
707                    if u > 0.0 {
708                        result += vals[i] * u.ln() * dr;
709                    }
710                    result
711                }
712                TapeOp::Neg(a) => -dot[*a],
713                TapeOp::Abs(a) => {
714                    if vals[*a] >= 0.0 {
715                        dot[*a]
716                    } else {
717                        -dot[*a]
718                    }
719                }
720                TapeOp::Sqrt(a) => {
721                    let sv = vals[i];
722                    if sv > 0.0 { dot[*a] * 0.5 / sv } else { 0.0 }
723                }
724                TapeOp::Exp(a) => dot[*a] * vals[i],
725                TapeOp::Log(a) => dot[*a] / vals[*a],
726                TapeOp::Log10(a) => dot[*a] / (vals[*a] * std::f64::consts::LN_10),
727                TapeOp::Sin(a) => dot[*a] * vals[*a].cos(),
728                TapeOp::Cos(a) => -dot[*a] * vals[*a].sin(),
729                TapeOp::Tan(a) => {
730                    let t = vals[i];
731                    dot[*a] * (1.0 + t * t)
732                }
733                TapeOp::Atan(a) => {
734                    let u = vals[*a];
735                    dot[*a] / (1.0 + u * u)
736                }
737                TapeOp::Acos(a) => {
738                    let u = vals[*a];
739                    -dot[*a] / (1.0 - u * u).sqrt()
740                }
741                TapeOp::Sinh(a) => dot[*a] * vals[*a].cosh(),
742                TapeOp::Cosh(a) => dot[*a] * vals[*a].sinh(),
743                TapeOp::Tanh(a) => {
744                    let t = vals[i];
745                    dot[*a] * (1.0 - t * t)
746                }
747                TapeOp::Asin(a) => {
748                    let u = vals[*a];
749                    dot[*a] / (1.0 - u * u).sqrt()
750                }
751                TapeOp::Acosh(a) => {
752                    let u = vals[*a];
753                    dot[*a] / (u * u - 1.0).sqrt()
754                }
755                TapeOp::Asinh(a) => {
756                    let u = vals[*a];
757                    dot[*a] / (u * u + 1.0).sqrt()
758                }
759                TapeOp::Atanh(a) => {
760                    let u = vals[*a];
761                    dot[*a] / (1.0 - u * u)
762                }
763                TapeOp::Erf(a) => erf_d1(vals[*a]) * dot[*a],
764                TapeOp::XLogX(a) => xlogx_d1(vals[*a]) * dot[*a],
765                TapeOp::CEntropy(a, b) => {
766                    centropy_da(vals[*a], vals[*b]) * dot[*a]
767                        + centropy_db(vals[*a], vals[*b]) * dot[*b]
768                }
769                TapeOp::Atan2(a, b) => {
770                    let y = vals[*a];
771                    let x = vals[*b];
772                    let d = y * y + x * x;
773                    (x * dot[*a] - y * dot[*b]) / d
774                }
775                // min/max: the tangent follows the selected operand.
776                TapeOp::Min(a, b) => {
777                    if vals[*a] <= vals[*b] {
778                        dot[*a]
779                    } else {
780                        dot[*b]
781                    }
782                }
783                TapeOp::Max(a, b) => {
784                    if vals[*a] >= vals[*b] {
785                        dot[*a]
786                    } else {
787                        dot[*b]
788                    }
789                }
790                TapeOp::Cmp(_, _, _) | TapeOp::And(_, _) | TapeOp::Or(_, _) | TapeOp::Not(_) => 0.0,
791                TapeOp::Select(c, t, e) => {
792                    if vals[*c] != 0.0 {
793                        dot[*t]
794                    } else {
795                        dot[*e]
796                    }
797                }
798                TapeOp::Funcall(fc) => {
799                    let FuncallData { lib, name, args } = fc.as_ref();
800                    let call_args = funcall_to_ext_args(args, vals);
801                    let res = ext_eval_or_nan(lib, name, &call_args, args.len(), true, false);
802                    let derivs = res.derivs.expect("want_derivs=true returns derivs");
803                    let mut acc = 0.0;
804                    let mut k = 0usize;
805                    for arg in args {
806                        if let TapeFuncallArg::Tape(idx) = arg {
807                            acc += derivs[k] * dot[*idx];
808                            k += 1;
809                        }
810                    }
811                    acc
812                }
813            };
814        }
815    }
816
817    /// Forward sweep into a caller-supplied buffer. Avoids the
818    /// per-call allocation of `forward()` so hot paths can reuse
819    /// one scratch arena across many tapes.
820    pub fn forward_into(&self, x: &[f64], vals: &mut [f64]) {
821        let n = self.ops.len();
822        debug_assert!(vals.len() >= n);
823        for i in 0..n {
824            vals[i] = match &self.ops[i] {
825                TapeOp::Const(c) => *c,
826                TapeOp::Var(j) => x[*j],
827                TapeOp::Add(a, b) => vals[*a] + vals[*b],
828                TapeOp::Sub(a, b) => vals[*a] - vals[*b],
829                TapeOp::Mul(a, b) => vals[*a] * vals[*b],
830                TapeOp::Div(a, b) => vals[*a] / vals[*b],
831                TapeOp::Pow(a, b) => vals[*a].powf(vals[*b]),
832                TapeOp::Neg(a) => -vals[*a],
833                TapeOp::Abs(a) => vals[*a].abs(),
834                TapeOp::Sqrt(a) => vals[*a].sqrt(),
835                TapeOp::Exp(a) => vals[*a].exp(),
836                TapeOp::Log(a) => vals[*a].ln(),
837                TapeOp::Log10(a) => vals[*a].log10(),
838                TapeOp::Sin(a) => vals[*a].sin(),
839                TapeOp::Cos(a) => vals[*a].cos(),
840                TapeOp::Tan(a) => vals[*a].tan(),
841                TapeOp::Atan(a) => vals[*a].atan(),
842                TapeOp::Acos(a) => vals[*a].acos(),
843                TapeOp::Sinh(a) => vals[*a].sinh(),
844                TapeOp::Cosh(a) => vals[*a].cosh(),
845                TapeOp::Tanh(a) => vals[*a].tanh(),
846                TapeOp::Asin(a) => vals[*a].asin(),
847                TapeOp::Acosh(a) => vals[*a].acosh(),
848                TapeOp::Asinh(a) => vals[*a].asinh(),
849                TapeOp::Atanh(a) => vals[*a].atanh(),
850                TapeOp::Erf(a) => erf(vals[*a]),
851                TapeOp::XLogX(a) => xlogx(vals[*a]),
852                TapeOp::CEntropy(a, b) => centropy(vals[*a], vals[*b]),
853                TapeOp::Atan2(a, b) => vals[*a].atan2(vals[*b]),
854                TapeOp::Min(a, b) => vals[*a].min(vals[*b]),
855                TapeOp::Max(a, b) => vals[*a].max(vals[*b]),
856                TapeOp::Cmp(op, a, b) => f64::from(cmp_holds(*op, vals[*a], vals[*b])),
857                TapeOp::And(a, b) => f64::from(vals[*a] != 0.0 && vals[*b] != 0.0),
858                TapeOp::Or(a, b) => f64::from(vals[*a] != 0.0 || vals[*b] != 0.0),
859                TapeOp::Not(a) => f64::from(vals[*a] == 0.0),
860                TapeOp::Select(c, t, e) => {
861                    if vals[*c] != 0.0 {
862                        vals[*t]
863                    } else {
864                        vals[*e]
865                    }
866                }
867                TapeOp::Funcall(fc) => {
868                    let FuncallData { lib, name, args } = fc.as_ref();
869                    let call_args = funcall_to_ext_args(args, &*vals);
870                    let res = ext_eval_or_nan(lib, name, &call_args, args.len(), false, false);
871                    res.value
872                }
873            };
874        }
875    }
876
877    /// Scalar tape value, reusing a caller-supplied scratch buffer
878    /// (`vals.len() >= self.ops.len()`) instead of allocating a fresh
879    /// forward-value `Vec` per call like [`eval`]. The `.nl` design emits
880    /// one tiny tape per summand — 10⁵–10⁶ on large models — so a single
881    /// `eval_f` / `eval_g` drives this once per summand and the per-call
882    /// allocation dominated the sweep itself (same motivation as
883    /// [`gradient_seed_into`], M18).
884    ///
885    /// [`eval`]: Tape::eval
886    /// [`gradient_seed_into`]: Tape::gradient_seed_into
887    pub fn eval_into(&self, x: &[f64], vals: &mut [f64]) -> f64 {
888        let n = self.ops.len();
889        if n == 0 {
890            return 0.0;
891        }
892        self.forward_into(x, vals);
893        vals[n - 1]
894    }
895
896    /// Directional Hessian-vector product: emits
897    /// `weight * (∇²f · seed)[k]` into `out[k]` for every problem
898    /// variable `k` the tape references. Caller supplies the
899    /// forward-pass result `vals` (use [`forward_into`]) plus three
900    /// scratch buffers (`dot`, `adj`, `adj_dot`), each at least
901    /// `self.ops.len()` long. `out` must be at least one past the
902    /// largest variable index in the tape; the routine reads
903    /// `seed[k]` for each `Var(k)` and writes `out[k] += weight *
904    /// (Hess · seed)[k]`.
905    ///
906    /// This is one forward-over-reverse AD pass — O(n_ops) work —
907    /// regardless of how many variables the tape depends on, which
908    /// is what makes Hessian coloring efficient: a single
909    /// directional pass recovers a whole color group of columns.
910    ///
911    /// [`forward_into`]: Tape::forward_into
912    pub fn hessian_directional(
913        &self,
914        vals: &[f64],
915        seed: &[f64],
916        weight: f64,
917        out: &mut [f64],
918        dot: &mut [f64],
919        adj: &mut [f64],
920        adj_dot: &mut [f64],
921    ) {
922        let n = self.ops.len();
923        if n == 0 || weight == 0.0 {
924            return;
925        }
926        debug_assert!(vals.len() >= n);
927        debug_assert!(dot.len() >= n);
928        debug_assert!(adj.len() >= n);
929        debug_assert!(adj_dot.len() >= n);
930
931        // Forward tangent: dot[i] = (∂vals[i] / ∂x · seed). At
932        // Var(k) the seed entry feeds in; the rest of the chain
933        // rule matches `forward_tangent` exactly.
934        for i in 0..n {
935            dot[i] = match &self.ops[i] {
936                TapeOp::Const(_) => 0.0,
937                TapeOp::Var(k) => seed[*k],
938                TapeOp::Add(a, b) => dot[*a] + dot[*b],
939                TapeOp::Sub(a, b) => dot[*a] - dot[*b],
940                TapeOp::Mul(a, b) => dot[*a] * vals[*b] + vals[*a] * dot[*b],
941                TapeOp::Div(a, b) => {
942                    // (ȧ - q·ḃ)/b, NOT (ȧ·b - a·ḃ)/b². Squaring the denominator
943                    // overflows for |b| > 1.3e154 and underflows for |b| < 1.5e-154,
944                    // destroying a tangent that is itself perfectly representable:
945                    // at a = b = 1e300 with ḃ = 1 the true -a/b² is -1e-300, but
946                    // b*b is inf so the squared form returns -0.0. Dividing by b
947                    // twice instead of by b² once is the standard Kahan form, and
948                    // costs nothing -- q is this node's own value, already computed.
949                    (dot[*a] - vals[i] * dot[*b]) / vals[*b]
950                }
951                TapeOp::Pow(a, b) => {
952                    let u = vals[*a];
953                    let r = vals[*b];
954                    let du = dot[*a];
955                    let dr = dot[*b];
956                    let mut result = 0.0;
957                    // Match the reverse-mode gradient's guard (`rv != 0.0` only): at base
958                    // u == 0 the slope is still well defined for r >= 1 (and a
959                    // genuine ±inf for r < 1), so it must not be silently dropped,
960                    // or the forward tangent disagrees with the reverse gradient.
961                    if r != 0.0 {
962                        result += r * u.powf(r - 1.0) * du;
963                    }
964                    if u > 0.0 {
965                        result += vals[i] * u.ln() * dr;
966                    }
967                    result
968                }
969                TapeOp::Neg(a) => -dot[*a],
970                TapeOp::Abs(a) => {
971                    if vals[*a] >= 0.0 {
972                        dot[*a]
973                    } else {
974                        -dot[*a]
975                    }
976                }
977                TapeOp::Sqrt(a) => {
978                    let sv = vals[i];
979                    if sv > 0.0 { dot[*a] * 0.5 / sv } else { 0.0 }
980                }
981                TapeOp::Exp(a) => vals[i] * dot[*a],
982                TapeOp::Log(a) => dot[*a] / vals[*a],
983                TapeOp::Log10(a) => dot[*a] / (vals[*a] * std::f64::consts::LN_10),
984                TapeOp::Sin(a) => vals[*a].cos() * dot[*a],
985                TapeOp::Cos(a) => -vals[*a].sin() * dot[*a],
986                TapeOp::Tan(a) => {
987                    let t = vals[i];
988                    (1.0 + t * t) * dot[*a]
989                }
990                TapeOp::Atan(a) => {
991                    let u = vals[*a];
992                    dot[*a] / (1.0 + u * u)
993                }
994                TapeOp::Acos(a) => {
995                    let u = vals[*a];
996                    -dot[*a] / (1.0 - u * u).sqrt()
997                }
998                TapeOp::Sinh(a) => dot[*a] * vals[*a].cosh(),
999                TapeOp::Cosh(a) => dot[*a] * vals[*a].sinh(),
1000                TapeOp::Tanh(a) => {
1001                    let t = vals[i];
1002                    (1.0 - t * t) * dot[*a]
1003                }
1004                TapeOp::Asin(a) => {
1005                    let u = vals[*a];
1006                    dot[*a] / (1.0 - u * u).sqrt()
1007                }
1008                TapeOp::Acosh(a) => {
1009                    let u = vals[*a];
1010                    dot[*a] / (u * u - 1.0).sqrt()
1011                }
1012                TapeOp::Asinh(a) => {
1013                    let u = vals[*a];
1014                    dot[*a] / (u * u + 1.0).sqrt()
1015                }
1016                TapeOp::Atanh(a) => {
1017                    let u = vals[*a];
1018                    dot[*a] / (1.0 - u * u)
1019                }
1020                TapeOp::Erf(a) => erf_d1(vals[*a]) * dot[*a],
1021                TapeOp::XLogX(a) => xlogx_d1(vals[*a]) * dot[*a],
1022                TapeOp::CEntropy(a, b) => {
1023                    centropy_da(vals[*a], vals[*b]) * dot[*a]
1024                        + centropy_db(vals[*a], vals[*b]) * dot[*b]
1025                }
1026                TapeOp::Atan2(a, b) => {
1027                    let y = vals[*a];
1028                    let x = vals[*b];
1029                    let d = y * y + x * x;
1030                    (x * dot[*a] - y * dot[*b]) / d
1031                }
1032                // min/max: the tangent follows the selected operand.
1033                TapeOp::Min(a, b) => {
1034                    if vals[*a] <= vals[*b] {
1035                        dot[*a]
1036                    } else {
1037                        dot[*b]
1038                    }
1039                }
1040                TapeOp::Max(a, b) => {
1041                    if vals[*a] >= vals[*b] {
1042                        dot[*a]
1043                    } else {
1044                        dot[*b]
1045                    }
1046                }
1047                TapeOp::Cmp(_, _, _) | TapeOp::And(_, _) | TapeOp::Or(_, _) | TapeOp::Not(_) => 0.0,
1048                TapeOp::Select(c, t, e) => {
1049                    if vals[*c] != 0.0 {
1050                        dot[*t]
1051                    } else {
1052                        dot[*e]
1053                    }
1054                }
1055                TapeOp::Funcall(fc) => {
1056                    let FuncallData { lib, name, args } = fc.as_ref();
1057                    let call_args = funcall_to_ext_args(args, vals);
1058                    let res = ext_eval_or_nan(lib, name, &call_args, args.len(), true, false);
1059                    let derivs = res.derivs.expect("want_derivs=true returns derivs");
1060                    let mut acc = 0.0;
1061                    let mut k = 0usize;
1062                    for arg in args {
1063                        if let TapeFuncallArg::Tape(idx) = arg {
1064                            acc += derivs[k] * dot[*idx];
1065                            k += 1;
1066                        }
1067                    }
1068                    acc
1069                }
1070            };
1071        }
1072
1073        // Reverse over tangent. adj[i] = ∂f/∂vals[i],
1074        // adj_dot[i] = derivative of adj[i] along `seed`
1075        // direction = (Hess · seed) projected onto slot i.
1076        for slot in adj.iter_mut().take(n) {
1077            *slot = 0.0;
1078        }
1079        for slot in adj_dot.iter_mut().take(n) {
1080            *slot = 0.0;
1081        }
1082        adj[n - 1] = 1.0;
1083
1084        for i in (0..n).rev() {
1085            let w = adj[i];
1086            let wd = adj_dot[i];
1087            if w == 0.0 && wd == 0.0 {
1088                continue;
1089            }
1090            match &self.ops[i] {
1091                TapeOp::Const(_) => {}
1092                TapeOp::Var(k) => {
1093                    if wd != 0.0 {
1094                        out[*k] += weight * wd;
1095                    }
1096                }
1097                TapeOp::Add(a, b) => {
1098                    adj[*a] += w;
1099                    adj[*b] += w;
1100                    adj_dot[*a] += wd;
1101                    adj_dot[*b] += wd;
1102                }
1103                TapeOp::Sub(a, b) => {
1104                    adj[*a] += w;
1105                    adj[*b] -= w;
1106                    adj_dot[*a] += wd;
1107                    adj_dot[*b] -= wd;
1108                }
1109                TapeOp::Mul(a, b) => {
1110                    adj[*a] += w * vals[*b];
1111                    adj[*b] += w * vals[*a];
1112                    adj_dot[*a] += wd * vals[*b] + w * dot[*b];
1113                    adj_dot[*b] += wd * vals[*a] + w * dot[*a];
1114                }
1115                TapeOp::Div(a, b) => {
1116                    // Kahan form, second order. `vb2`/`vb3` overflow for
1117                    // |b| > 1.3e154 (and vb2 underflows below 1.5e-154), which
1118                    // silently zeroed second-order terms that are representable.
1119                    // Every 1/b² here is regrouped as (·/b)/b, and the
1120                    // 2a·ḃ/b³ term folds into q̇ = dot[i], already computed:
1121                    //   -ȧ/b² + 2q·ḃ/b² == (-q̇ + q·(ḃ/b)) / b.
1122                    let vb = vals[*b];
1123                    let q = vals[i];
1124                    let qd = dot[i];
1125                    adj[*a] += w / vb;
1126                    adj_dot[*a] += wd / vb - w * (dot[*b] / vb) / vb;
1127                    adj[*b] -= w * q / vb;
1128                    adj_dot[*b] += -(wd * q) / vb + (w / vb) * (-qd + q * (dot[*b] / vb));
1129                }
1130                TapeOp::Pow(a, b) => {
1131                    let u = vals[*a];
1132                    let r = vals[*b];
1133                    let du = dot[*a];
1134                    let dr = dot[*b];
1135                    if r != 0.0 {
1136                        if u != 0.0 {
1137                            let p_a = r * u.powf(r - 1.0);
1138                            adj[*a] += w * p_a;
1139                            let mut dp_a = dr * u.powf(r - 1.0);
1140                            if u > 0.0 {
1141                                dp_a += r * u.powf(r - 1.0) * ((r - 1.0) * du / u + dr * u.ln());
1142                            } else {
1143                                dp_a += r * (r - 1.0) * u.powf(r - 2.0) * du;
1144                            }
1145                            adj_dot[*a] += wd * p_a + w * dp_a;
1146                        } else if r >= 2.0 {
1147                            let p_a = 0.0;
1148                            adj[*a] += w * p_a;
1149                            let dp_a = if r == 2.0 {
1150                                2.0 * du
1151                            } else {
1152                                r * (r - 1.0) * (0.0_f64).powf(r - 2.0) * du
1153                            };
1154                            adj_dot[*a] += wd * p_a + w * dp_a;
1155                        }
1156                    }
1157                    if u > 0.0 {
1158                        let ln_u = u.ln();
1159                        let p_b = vals[i] * ln_u;
1160                        adj[*b] += w * p_b;
1161                        let dur = vals[i] * (r * du / u + dr * ln_u);
1162                        let dp_b = dur * ln_u + vals[i] * du / u;
1163                        adj_dot[*b] += wd * p_b + w * dp_b;
1164                    }
1165                }
1166                TapeOp::Neg(a) => {
1167                    adj[*a] -= w;
1168                    adj_dot[*a] -= wd;
1169                }
1170                TapeOp::Abs(a) => {
1171                    let s = if vals[*a] >= 0.0 { 1.0 } else { -1.0 };
1172                    adj[*a] += w * s;
1173                    adj_dot[*a] += wd * s;
1174                }
1175                TapeOp::Sqrt(a) => {
1176                    let sv = vals[i];
1177                    if sv > 0.0 {
1178                        let fp = 0.5 / sv;
1179                        let fpp = -0.25 / (vals[*a] * sv);
1180                        adj[*a] += w * fp;
1181                        adj_dot[*a] += wd * fp + w * fpp * dot[*a];
1182                    }
1183                }
1184                TapeOp::Exp(a) => {
1185                    let ev = vals[i];
1186                    adj[*a] += w * ev;
1187                    adj_dot[*a] += wd * ev + w * ev * dot[*a];
1188                }
1189                TapeOp::Log(a) => {
1190                    let u = vals[*a];
1191                    adj[*a] += w / u;
1192                    adj_dot[*a] += wd / u + w * (-1.0 / (u * u)) * dot[*a];
1193                }
1194                TapeOp::Log10(a) => {
1195                    let u = vals[*a];
1196                    let c = std::f64::consts::LN_10;
1197                    adj[*a] += w / (u * c);
1198                    adj_dot[*a] += wd / (u * c) + w * (-1.0 / (u * u * c)) * dot[*a];
1199                }
1200                TapeOp::Sin(a) => {
1201                    let u = vals[*a];
1202                    let cu = u.cos();
1203                    adj[*a] += w * cu;
1204                    adj_dot[*a] += wd * cu + w * (-u.sin()) * dot[*a];
1205                }
1206                TapeOp::Cos(a) => {
1207                    let u = vals[*a];
1208                    let su = u.sin();
1209                    adj[*a] -= w * su;
1210                    adj_dot[*a] += wd * (-su) + w * (-u.cos()) * dot[*a];
1211                }
1212                TapeOp::Tan(a) => {
1213                    let t = vals[i];
1214                    let gp = 1.0 + t * t;
1215                    let gpp = 2.0 * t * gp;
1216                    adj[*a] += w * gp;
1217                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1218                }
1219                TapeOp::Atan(a) => {
1220                    let u = vals[*a];
1221                    let d = 1.0 + u * u;
1222                    let gp = 1.0 / d;
1223                    let gpp = -2.0 * u / (d * d);
1224                    adj[*a] += w * gp;
1225                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1226                }
1227                TapeOp::Acos(a) => {
1228                    let u = vals[*a];
1229                    let s = 1.0 - u * u;
1230                    let r = s.sqrt();
1231                    let gp = -1.0 / r;
1232                    let gpp = -u / (s * r);
1233                    adj[*a] += w * gp;
1234                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1235                }
1236                TapeOp::Sinh(a) => {
1237                    let u = vals[*a];
1238                    let gp = u.cosh();
1239                    let gpp = u.sinh();
1240                    adj[*a] += w * gp;
1241                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1242                }
1243                TapeOp::Cosh(a) => {
1244                    let u = vals[*a];
1245                    let gp = u.sinh();
1246                    let gpp = u.cosh();
1247                    adj[*a] += w * gp;
1248                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1249                }
1250                TapeOp::Tanh(a) => {
1251                    let t = vals[i];
1252                    let gp = 1.0 - t * t;
1253                    let gpp = -2.0 * t * gp;
1254                    adj[*a] += w * gp;
1255                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1256                }
1257                TapeOp::Asin(a) => {
1258                    let u = vals[*a];
1259                    let s = 1.0 - u * u;
1260                    let r = s.sqrt();
1261                    let gp = 1.0 / r;
1262                    let gpp = u / (s * r);
1263                    adj[*a] += w * gp;
1264                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1265                }
1266                TapeOp::Acosh(a) => {
1267                    let u = vals[*a];
1268                    let s = u * u - 1.0;
1269                    let r = s.sqrt();
1270                    let gp = 1.0 / r;
1271                    let gpp = -u / (s * r);
1272                    adj[*a] += w * gp;
1273                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1274                }
1275                TapeOp::Asinh(a) => {
1276                    let u = vals[*a];
1277                    let s = u * u + 1.0;
1278                    let r = s.sqrt();
1279                    let gp = 1.0 / r;
1280                    let gpp = -u / (s * r);
1281                    adj[*a] += w * gp;
1282                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1283                }
1284                TapeOp::Atanh(a) => {
1285                    let u = vals[*a];
1286                    let d = 1.0 - u * u;
1287                    let gp = 1.0 / d;
1288                    let gpp = 2.0 * u / (d * d);
1289                    adj[*a] += w * gp;
1290                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1291                }
1292                TapeOp::Erf(a) => {
1293                    let u = vals[*a];
1294                    let gp = erf_d1(u);
1295                    let gpp = erf_d2(u);
1296                    adj[*a] += w * gp;
1297                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1298                }
1299                TapeOp::XLogX(a) => {
1300                    // gpp is 1/u, NOT the -1/u² any decomposition through
1301                    // `ln''` would produce. That is the whole point of the op.
1302                    let u = vals[*a];
1303                    let gp = xlogx_d1(u);
1304                    let gpp = xlogx_d2(u);
1305                    adj[*a] += w * gp;
1306                    adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1307                }
1308                TapeOp::CEntropy(a, b) => {
1309                    let ua = vals[*a];
1310                    let ub = vals[*b];
1311                    let fa = centropy_da(ua, ub);
1312                    let fb = centropy_db(ua, ub);
1313                    let faa = centropy_daa(ua);
1314                    let fab = centropy_dab(ub);
1315                    let fbb = centropy_dbb(ua, ub);
1316                    adj[*a] += w * fa;
1317                    adj[*b] += w * fb;
1318                    adj_dot[*a] += wd * fa + w * (faa * dot[*a] + fab * dot[*b]);
1319                    adj_dot[*b] += wd * fb + w * (fab * dot[*a] + fbb * dot[*b]);
1320                }
1321                TapeOp::Atan2(a, b) => {
1322                    let y = vals[*a];
1323                    let x = vals[*b];
1324                    let d = y * y + x * x;
1325                    let d2 = d * d;
1326                    let fa = x / d;
1327                    let fb = -y / d;
1328                    let faa = -2.0 * y * x / d2;
1329                    let fab = (y * y - x * x) / d2;
1330                    let fbb = 2.0 * y * x / d2;
1331                    adj[*a] += w * fa;
1332                    adj[*b] += w * fb;
1333                    adj_dot[*a] += wd * fa + w * (faa * dot[*a] + fab * dot[*b]);
1334                    adj_dot[*b] += wd * fb + w * (fab * dot[*a] + fbb * dot[*b]);
1335                }
1336                // min/max are piecewise linear (zero second derivative):
1337                // route the adjoint and its tangent into the selected
1338                // operand, exactly like the active branch of a Select.
1339                TapeOp::Min(a, b) => {
1340                    let br = if vals[*a] <= vals[*b] { *a } else { *b };
1341                    adj[br] += w;
1342                    adj_dot[br] += wd;
1343                }
1344                TapeOp::Max(a, b) => {
1345                    let br = if vals[*a] >= vals[*b] { *a } else { *b };
1346                    adj[br] += w;
1347                    adj_dot[br] += wd;
1348                }
1349                // Zero derivative: no first- or second-order adjoint.
1350                TapeOp::Cmp(_, _, _) | TapeOp::And(_, _) | TapeOp::Or(_, _) | TapeOp::Not(_) => {}
1351                // Route both the adjoint and its tangent into the
1352                // active branch; the condition contributes nothing.
1353                TapeOp::Select(c, t, e) => {
1354                    let br = if vals[*c] != 0.0 { *t } else { *e };
1355                    adj[br] += w;
1356                    adj_dot[br] += wd;
1357                }
1358                TapeOp::Funcall(fc) => {
1359                    let FuncallData { lib, name, args } = fc.as_ref();
1360                    let call_args = funcall_to_ext_args(args, vals);
1361                    let res = ext_eval_or_nan(lib, name, &call_args, args.len(), true, true);
1362                    let derivs = res.derivs.expect("want_derivs=true returns derivs");
1363                    let hes = res.hessian.expect("want_hes=true returns hessian");
1364                    let real_tape: Vec<usize> = args
1365                        .iter()
1366                        .filter_map(|a| match a {
1367                            TapeFuncallArg::Tape(t) => Some(*t),
1368                            TapeFuncallArg::Str(_) => None,
1369                        })
1370                        .collect();
1371                    for (k, &tk) in real_tape.iter().enumerate() {
1372                        adj[tk] += w * derivs[k];
1373                        let mut second_term = 0.0;
1374                        for (l, &tl) in real_tape.iter().enumerate() {
1375                            let (lo, hi) = if k <= l { (k, l) } else { (l, k) };
1376                            let h_kl = hes[lo + hi * (hi + 1) / 2];
1377                            second_term += h_kl * dot[tl];
1378                        }
1379                        adj_dot[tk] += wd * derivs[k] + w * second_term;
1380                    }
1381                }
1382            }
1383        }
1384    }
1385
1386    /// Forward-over-reverse Hessian: for each variable `j` the tape
1387    /// depends on, accumulate `weight * (d²f / dx_i dx_j)` into
1388    /// `values[hess_map[(i, j)]]` for every `(i, j)` lower-triangle
1389    /// pair in the map. The same routine is used for the objective
1390    /// (with `weight = obj_factor`) and each active constraint (with
1391    /// `weight = lambda[k]`); contributions sum into the shared map.
1392    pub fn hessian_accumulate(
1393        &self,
1394        x: &[f64],
1395        weight: f64,
1396        hess_map: &HashMap<(usize, usize), usize>,
1397        values: &mut [f64],
1398    ) {
1399        let n = self.ops.len();
1400        if n == 0 || weight == 0.0 {
1401            return;
1402        }
1403        let v = self.forward(x);
1404        let var_indices = self.variables();
1405
1406        // Hoist scratch allocations out of the per-variable loop —
1407        // each was costing O(n) per j on every hessian_accumulate
1408        // call, which dominated runtime on large tapes (the dense-
1409        // Hessian Mittelmann problems). `forward_tangent` fully
1410        // overwrites `dot`, so no reset is needed there. `adj` and
1411        // `adj_dot` are mutated additively, so we zero them per j.
1412        let mut dot = vec![0.0f64; n];
1413        let mut adj = vec![0.0f64; n];
1414        let mut adj_dot = vec![0.0f64; n];
1415        for &j in &var_indices {
1416            self.forward_tangent(&v, j, &mut dot);
1417
1418            // adj[i] = standard adjoint (∂f/∂slot_i)
1419            // adj_dot[i] = derivative of adj[i] w.r.t. x_j = ∂²f/(∂slot_i ∂x_j)
1420            adj.fill(0.0);
1421            adj_dot.fill(0.0);
1422            adj[n - 1] = 1.0;
1423
1424            for i in (0..n).rev() {
1425                let w = adj[i];
1426                let wd = adj_dot[i];
1427                if w == 0.0 && wd == 0.0 {
1428                    continue;
1429                }
1430                match &self.ops[i] {
1431                    TapeOp::Const(_) => {}
1432                    TapeOp::Var(k) => {
1433                        // Lower-triangle: only emit when row k >= col j
1434                        // so an off-diagonal pair appears once.
1435                        if wd != 0.0 && *k >= j {
1436                            if let Some(&pos) = hess_map.get(&(*k, j)) {
1437                                values[pos] += weight * wd;
1438                            }
1439                        }
1440                    }
1441                    TapeOp::Add(a, b) => {
1442                        adj[*a] += w;
1443                        adj[*b] += w;
1444                        adj_dot[*a] += wd;
1445                        adj_dot[*b] += wd;
1446                    }
1447                    TapeOp::Sub(a, b) => {
1448                        adj[*a] += w;
1449                        adj[*b] -= w;
1450                        adj_dot[*a] += wd;
1451                        adj_dot[*b] -= wd;
1452                    }
1453                    TapeOp::Mul(a, b) => {
1454                        adj[*a] += w * v[*b];
1455                        adj[*b] += w * v[*a];
1456                        adj_dot[*a] += wd * v[*b] + w * dot[*b];
1457                        adj_dot[*b] += wd * v[*a] + w * dot[*a];
1458                    }
1459                    TapeOp::Div(a, b) => {
1460                        // Kahan form -- see the identical arm in the dense
1461                        // reverse-over-forward sweep for the derivation.
1462                        let vb = v[*b];
1463                        let q = v[i];
1464                        let qd = dot[i];
1465                        adj[*a] += w / vb;
1466                        adj_dot[*a] += wd / vb - w * (dot[*b] / vb) / vb;
1467                        adj[*b] -= w * q / vb;
1468                        adj_dot[*b] += -(wd * q) / vb + (w / vb) * (-qd + q * (dot[*b] / vb));
1469                    }
1470                    TapeOp::Pow(a, b) => {
1471                        let u = v[*a];
1472                        let r = v[*b];
1473                        let du = dot[*a];
1474                        let dr = dot[*b];
1475                        if r != 0.0 {
1476                            if u != 0.0 {
1477                                let p_a = r * u.powf(r - 1.0);
1478                                adj[*a] += w * p_a;
1479                                let mut dp_a = dr * u.powf(r - 1.0);
1480                                if u > 0.0 {
1481                                    dp_a +=
1482                                        r * u.powf(r - 1.0) * ((r - 1.0) * du / u + dr * u.ln());
1483                                } else {
1484                                    dp_a += r * (r - 1.0) * u.powf(r - 2.0) * du;
1485                                }
1486                                adj_dot[*a] += wd * p_a + w * dp_a;
1487                            } else if r >= 2.0 {
1488                                let p_a = 0.0;
1489                                adj[*a] += w * p_a;
1490                                let dp_a = if r == 2.0 {
1491                                    2.0 * du
1492                                } else {
1493                                    r * (r - 1.0) * (0.0_f64).powf(r - 2.0) * du
1494                                };
1495                                adj_dot[*a] += wd * p_a + w * dp_a;
1496                            }
1497                        }
1498                        if u > 0.0 {
1499                            let ln_u = u.ln();
1500                            let p_b = v[i] * ln_u;
1501                            adj[*b] += w * p_b;
1502                            let dur = v[i] * (r * du / u + dr * ln_u);
1503                            let dp_b = dur * ln_u + v[i] * du / u;
1504                            adj_dot[*b] += wd * p_b + w * dp_b;
1505                        }
1506                    }
1507                    TapeOp::Neg(a) => {
1508                        adj[*a] -= w;
1509                        adj_dot[*a] -= wd;
1510                    }
1511                    TapeOp::Abs(a) => {
1512                        let s = if v[*a] >= 0.0 { 1.0 } else { -1.0 };
1513                        adj[*a] += w * s;
1514                        adj_dot[*a] += wd * s;
1515                    }
1516                    TapeOp::Sqrt(a) => {
1517                        let sv = v[i];
1518                        if sv > 0.0 {
1519                            let fp = 0.5 / sv;
1520                            let fpp = -0.25 / (v[*a] * sv);
1521                            adj[*a] += w * fp;
1522                            adj_dot[*a] += wd * fp + w * fpp * dot[*a];
1523                        }
1524                    }
1525                    TapeOp::Exp(a) => {
1526                        let ev = v[i];
1527                        adj[*a] += w * ev;
1528                        adj_dot[*a] += wd * ev + w * ev * dot[*a];
1529                    }
1530                    TapeOp::Log(a) => {
1531                        let u = v[*a];
1532                        adj[*a] += w / u;
1533                        adj_dot[*a] += wd / u + w * (-1.0 / (u * u)) * dot[*a];
1534                    }
1535                    TapeOp::Log10(a) => {
1536                        let u = v[*a];
1537                        let c = std::f64::consts::LN_10;
1538                        adj[*a] += w / (u * c);
1539                        adj_dot[*a] += wd / (u * c) + w * (-1.0 / (u * u * c)) * dot[*a];
1540                    }
1541                    TapeOp::Sin(a) => {
1542                        let u = v[*a];
1543                        let cu = u.cos();
1544                        adj[*a] += w * cu;
1545                        adj_dot[*a] += wd * cu + w * (-u.sin()) * dot[*a];
1546                    }
1547                    TapeOp::Cos(a) => {
1548                        let u = v[*a];
1549                        let su = u.sin();
1550                        adj[*a] -= w * su;
1551                        adj_dot[*a] += wd * (-su) + w * (-u.cos()) * dot[*a];
1552                    }
1553                    TapeOp::Tan(a) => {
1554                        let t = v[i];
1555                        let gp = 1.0 + t * t;
1556                        let gpp = 2.0 * t * gp;
1557                        adj[*a] += w * gp;
1558                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1559                    }
1560                    TapeOp::Atan(a) => {
1561                        let u = v[*a];
1562                        let d = 1.0 + u * u;
1563                        let gp = 1.0 / d;
1564                        let gpp = -2.0 * u / (d * d);
1565                        adj[*a] += w * gp;
1566                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1567                    }
1568                    TapeOp::Acos(a) => {
1569                        let u = v[*a];
1570                        let s = 1.0 - u * u;
1571                        let r = s.sqrt();
1572                        let gp = -1.0 / r;
1573                        let gpp = -u / (s * r);
1574                        adj[*a] += w * gp;
1575                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1576                    }
1577                    TapeOp::Sinh(a) => {
1578                        let u = v[*a];
1579                        let gp = u.cosh();
1580                        let gpp = u.sinh();
1581                        adj[*a] += w * gp;
1582                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1583                    }
1584                    TapeOp::Cosh(a) => {
1585                        let u = v[*a];
1586                        let gp = u.sinh();
1587                        let gpp = u.cosh();
1588                        adj[*a] += w * gp;
1589                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1590                    }
1591                    TapeOp::Tanh(a) => {
1592                        let t = v[i];
1593                        let gp = 1.0 - t * t;
1594                        let gpp = -2.0 * t * gp;
1595                        adj[*a] += w * gp;
1596                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1597                    }
1598                    TapeOp::Asin(a) => {
1599                        let u = v[*a];
1600                        let s = 1.0 - u * u;
1601                        let r = s.sqrt();
1602                        let gp = 1.0 / r;
1603                        let gpp = u / (s * r);
1604                        adj[*a] += w * gp;
1605                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1606                    }
1607                    TapeOp::Acosh(a) => {
1608                        let u = v[*a];
1609                        let s = u * u - 1.0;
1610                        let r = s.sqrt();
1611                        let gp = 1.0 / r;
1612                        let gpp = -u / (s * r);
1613                        adj[*a] += w * gp;
1614                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1615                    }
1616                    TapeOp::Asinh(a) => {
1617                        let u = v[*a];
1618                        let s = u * u + 1.0;
1619                        let r = s.sqrt();
1620                        let gp = 1.0 / r;
1621                        let gpp = -u / (s * r);
1622                        adj[*a] += w * gp;
1623                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1624                    }
1625                    TapeOp::Atanh(a) => {
1626                        let u = v[*a];
1627                        let d = 1.0 - u * u;
1628                        let gp = 1.0 / d;
1629                        let gpp = 2.0 * u / (d * d);
1630                        adj[*a] += w * gp;
1631                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1632                    }
1633                    TapeOp::Erf(a) => {
1634                        let u = v[*a];
1635                        let gp = erf_d1(u);
1636                        let gpp = erf_d2(u);
1637                        adj[*a] += w * gp;
1638                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1639                    }
1640                    TapeOp::XLogX(a) => {
1641                        // See the identical arm in `hessian_accumulate`: gpp is
1642                        // 1/u, never the unrepresentable -1/u² of `ln''`.
1643                        let u = v[*a];
1644                        let gp = xlogx_d1(u);
1645                        let gpp = xlogx_d2(u);
1646                        adj[*a] += w * gp;
1647                        adj_dot[*a] += wd * gp + w * gpp * dot[*a];
1648                    }
1649                    TapeOp::CEntropy(a, b) => {
1650                        let ua = v[*a];
1651                        let ub = v[*b];
1652                        let fa = centropy_da(ua, ub);
1653                        let fb = centropy_db(ua, ub);
1654                        let faa = centropy_daa(ua);
1655                        let fab = centropy_dab(ub);
1656                        let fbb = centropy_dbb(ua, ub);
1657                        adj[*a] += w * fa;
1658                        adj[*b] += w * fb;
1659                        adj_dot[*a] += wd * fa + w * (faa * dot[*a] + fab * dot[*b]);
1660                        adj_dot[*b] += wd * fb + w * (fab * dot[*a] + fbb * dot[*b]);
1661                    }
1662                    TapeOp::Atan2(a, b) => {
1663                        let y = v[*a];
1664                        let x = v[*b];
1665                        let d = y * y + x * x;
1666                        let d2 = d * d;
1667                        let fa = x / d;
1668                        let fb = -y / d;
1669                        let faa = -2.0 * y * x / d2;
1670                        let fab = (y * y - x * x) / d2;
1671                        let fbb = 2.0 * y * x / d2;
1672                        adj[*a] += w * fa;
1673                        adj[*b] += w * fb;
1674                        adj_dot[*a] += wd * fa + w * (faa * dot[*a] + fab * dot[*b]);
1675                        adj_dot[*b] += wd * fb + w * (fab * dot[*a] + fbb * dot[*b]);
1676                    }
1677                    // min/max are piecewise linear (zero second
1678                    // derivative): route adjoint and its tangent into
1679                    // the selected operand, like an active Select branch.
1680                    TapeOp::Min(a, b) => {
1681                        let br = if v[*a] <= v[*b] { *a } else { *b };
1682                        adj[br] += w;
1683                        adj_dot[br] += wd;
1684                    }
1685                    TapeOp::Max(a, b) => {
1686                        let br = if v[*a] >= v[*b] { *a } else { *b };
1687                        adj[br] += w;
1688                        adj_dot[br] += wd;
1689                    }
1690                    // Zero derivative: no first- or second-order adjoint.
1691                    TapeOp::Cmp(_, _, _)
1692                    | TapeOp::And(_, _)
1693                    | TapeOp::Or(_, _)
1694                    | TapeOp::Not(_) => {}
1695                    // Route adjoint and its tangent into the active
1696                    // branch only; the condition contributes nothing.
1697                    TapeOp::Select(c, t, e) => {
1698                        let br = if v[*c] != 0.0 { *t } else { *e };
1699                        adj[br] += w;
1700                        adj_dot[br] += wd;
1701                    }
1702                    TapeOp::Funcall(fc) => {
1703                        let FuncallData { lib, name, args } = fc.as_ref();
1704                        let call_args = funcall_to_ext_args(args, &v);
1705                        let res = ext_eval_or_nan(lib, name, &call_args, args.len(), true, true);
1706                        let derivs = res.derivs.expect("want_derivs=true returns derivs");
1707                        let hes = res.hessian.expect("want_hes=true returns hessian");
1708                        let real_tape: Vec<usize> = args
1709                            .iter()
1710                            .filter_map(|a| match a {
1711                                TapeFuncallArg::Tape(t) => Some(*t),
1712                                TapeFuncallArg::Str(_) => None,
1713                            })
1714                            .collect();
1715                        for (k, &tk) in real_tape.iter().enumerate() {
1716                            adj[tk] += w * derivs[k];
1717                            let mut second_term = 0.0;
1718                            for (l, &tl) in real_tape.iter().enumerate() {
1719                                let (lo, hi) = if k <= l { (k, l) } else { (l, k) };
1720                                let h_kl = hes[lo + hi * (hi + 1) / 2];
1721                                second_term += h_kl * dot[tl];
1722                            }
1723                            adj_dot[tk] += wd * derivs[k] + w * second_term;
1724                        }
1725                    }
1726                }
1727            }
1728        }
1729    }
1730
1731    /// Structural Hessian sparsity (lower triangle, row >= col).
1732    /// Propagates per-slot variable-dependence sets forward; each
1733    /// nonlinear op emits the cross/self products of its operand sets.
1734    /// Linear ops contribute no second-derivative pairs.
1735    pub fn hessian_sparsity(&self) -> BTreeSet<(usize, usize)> {
1736        let n = self.ops.len();
1737        let mut var_sets: Vec<BTreeSet<usize>> = Vec::with_capacity(n);
1738        let mut pairs: BTreeSet<(usize, usize)> = BTreeSet::new();
1739
1740        // Slot lifetimes: `last_use[i]` is the highest-numbered op that
1741        // reads slot `i` (or `i` itself when nothing does). A slot's
1742        // variable set is dead the moment its last reader has been
1743        // processed, so it can be *moved into* that reader's result
1744        // instead of copied, and dropped otherwise.
1745        //
1746        // Without this the pass is O(n²) in both time and memory on any
1747        // tape carrying a long additive chain — `v0 * (v0 + v1 + … + vk)`,
1748        // the shape a dense Hessian row comes from — because every
1749        // partial sum leaves behind its own full-size `BTreeSet` and each
1750        // union copies the whole running set. `split_top_sums` keeps
1751        // *top-level* sums out of a single tape, so this only bites when
1752        // the sum feeds an enclosing operator, which is exactly the dense
1753        // case. With reuse, a left-leaning chain extends one set in place.
1754        let mut last_use: Vec<usize> = (0..n).collect();
1755        for (i, op) in self.ops.iter().enumerate() {
1756            for_each_input(op, |a| last_use[a] = i);
1757        }
1758        // Union of two operand sets, taking ownership of whichever dies
1759        // at this op rather than copying. `a == b` is handled first:
1760        // taking one would empty the other.
1761        macro_rules! merge {
1762            ($a:expr, $b:expr, $i:expr) => {{
1763                let (a, b, i) = ($a, $b, $i);
1764                if a == b {
1765                    if last_use[a] == i {
1766                        std::mem::take(&mut var_sets[a])
1767                    } else {
1768                        var_sets[a].clone()
1769                    }
1770                } else {
1771                    // Prefer moving the larger of the two dead sets.
1772                    let take_a = last_use[a] == i
1773                        && (last_use[b] != i || var_sets[a].len() >= var_sets[b].len());
1774                    if take_a {
1775                        let mut s = std::mem::take(&mut var_sets[a]);
1776                        s.extend(var_sets[b].iter().copied());
1777                        s
1778                    } else if last_use[b] == i {
1779                        let mut s = std::mem::take(&mut var_sets[b]);
1780                        s.extend(var_sets[a].iter().copied());
1781                        s
1782                    } else {
1783                        var_sets[a].union(&var_sets[b]).copied().collect()
1784                    }
1785                }
1786            }};
1787        }
1788        // Same, for the one-operand ops.
1789        macro_rules! carry {
1790            ($a:expr, $i:expr) => {{
1791                let (a, i) = ($a, $i);
1792                if last_use[a] == i {
1793                    std::mem::take(&mut var_sets[a])
1794                } else {
1795                    var_sets[a].clone()
1796                }
1797            }};
1798        }
1799
1800        let emit_cross =
1801            |s1: &BTreeSet<usize>, s2: &BTreeSet<usize>, pairs: &mut BTreeSet<(usize, usize)>| {
1802                for &v1 in s1 {
1803                    for &v2 in s2 {
1804                        let (r, c) = if v1 >= v2 { (v1, v2) } else { (v2, v1) };
1805                        pairs.insert((r, c));
1806                    }
1807                }
1808            };
1809        let emit_self = |s: &BTreeSet<usize>, pairs: &mut BTreeSet<(usize, usize)>| {
1810            let vars: Vec<usize> = s.iter().copied().collect();
1811            for (ai, &vi) in vars.iter().enumerate() {
1812                for &vj in &vars[..=ai] {
1813                    let (r, c) = if vi >= vj { (vi, vj) } else { (vj, vi) };
1814                    pairs.insert((r, c));
1815                }
1816            }
1817        };
1818
1819        for (i, op) in self.ops.iter().enumerate() {
1820            let vset = match op {
1821                TapeOp::Const(_) => BTreeSet::new(),
1822                TapeOp::Var(j) => {
1823                    let mut s = BTreeSet::new();
1824                    s.insert(*j);
1825                    s
1826                }
1827                TapeOp::Add(a, b) | TapeOp::Sub(a, b) => merge!(*a, *b, i),
1828                TapeOp::Neg(a) | TapeOp::Abs(a) => carry!(*a, i),
1829                TapeOp::Mul(a, b) => {
1830                    emit_cross(&var_sets[*a], &var_sets[*b], &mut pairs);
1831                    merge!(*a, *b, i)
1832                }
1833                TapeOp::Div(a, b) => {
1834                    emit_cross(&var_sets[*a], &var_sets[*b], &mut pairs);
1835                    emit_self(&var_sets[*b], &mut pairs);
1836                    merge!(*a, *b, i)
1837                }
1838                TapeOp::Pow(a, b) => {
1839                    let combined = merge!(*a, *b, i);
1840                    emit_self(&combined, &mut pairs);
1841                    combined
1842                }
1843                TapeOp::Sqrt(a)
1844                | TapeOp::Exp(a)
1845                | TapeOp::Log(a)
1846                | TapeOp::Log10(a)
1847                | TapeOp::Sin(a)
1848                | TapeOp::Cos(a)
1849                | TapeOp::Tan(a)
1850                | TapeOp::Atan(a)
1851                | TapeOp::Acos(a)
1852                | TapeOp::Sinh(a)
1853                | TapeOp::Cosh(a)
1854                | TapeOp::Tanh(a)
1855                | TapeOp::Asin(a)
1856                | TapeOp::Acosh(a)
1857                | TapeOp::Asinh(a)
1858                | TapeOp::Erf(a)
1859                | TapeOp::XLogX(a)
1860                | TapeOp::Atanh(a) => {
1861                    emit_self(&var_sets[*a], &mut pairs);
1862                    carry!(*a, i)
1863                }
1864                // atan2(y, x) and centropy(a, b) are nonlinear in both
1865                // operands with a full 2×2 second-derivative block; the
1866                // structural superset is every self/cross pair within the
1867                // combined operand set.
1868                TapeOp::Atan2(a, b) | TapeOp::CEntropy(a, b) => {
1869                    let combined = merge!(*a, *b, i);
1870                    emit_self(&combined, &mut pairs);
1871                    combined
1872                }
1873                // Comparisons / logical connectives are piecewise
1874                // constant: identically-zero derivative, so they
1875                // introduce no second-derivative pairs and carry no
1876                // variable dependence downstream (their result is a
1877                // constant as far as AD is concerned).
1878                TapeOp::Cmp(_, _, _) | TapeOp::And(_, _) | TapeOp::Or(_, _) | TapeOp::Not(_) => {
1879                    BTreeSet::new()
1880                }
1881                // Select passes through the active branch's value with
1882                // unit derivative, so it emits no pairs of its own; its
1883                // dependence set is the union of *both* branches
1884                // (either may become active as x varies — conservative
1885                // and correct for a structural superset). The condition
1886                // contributes no derivative and is excluded.
1887                TapeOp::Select(_c, t, e) => merge!(*t, *e, i),
1888                // min/max are piecewise linear: the active operand passes
1889                // through with unit derivative, so the second derivative is
1890                // identically zero (no pairs). Their dependence set is the
1891                // union of both operands (either may become active as x
1892                // varies — conservative and correct for a structural
1893                // superset), mirroring Select.
1894                TapeOp::Min(a, b) | TapeOp::Max(a, b) => merge!(*a, *b, i),
1895                TapeOp::Funcall(fc) => {
1896                    let args = &fc.args;
1897                    let mut combined: BTreeSet<usize> = BTreeSet::new();
1898                    for arg in args {
1899                        if let TapeFuncallArg::Tape(t) = arg {
1900                            for &vv in &var_sets[*t] {
1901                                combined.insert(vv);
1902                            }
1903                        }
1904                    }
1905                    emit_self(&combined, &mut pairs);
1906                    combined
1907                }
1908            };
1909            // Release any operand whose last reader was this op and which
1910            // `merge!` / `carry!` did not already move out (already-moved
1911            // slots are empty, so this is a no-op for them). Without it a
1912            // tape's peak memory is the sum of every intermediate set
1913            // rather than the live ones.
1914            for_each_input(op, |a| {
1915                if last_use[a] == i {
1916                    var_sets[a].clear();
1917                }
1918            });
1919            var_sets.push(vset);
1920        }
1921        pairs
1922    }
1923}
1924
1925/// Call `f` on every tape slot `op` reads.
1926///
1927/// Deliberately written as an exhaustive match with **no catch-all arm**:
1928/// a new `TapeOp` variant must be added here or this stops compiling.
1929/// A silent "reads nothing" default would let `hessian_sparsity`'s
1930/// liveness analysis drop an operand's variable set while it is still
1931/// needed, which would quietly under-report Hessian sparsity — the kind
1932/// of wrong answer that surfaces as a bad search direction, not a panic.
1933/// Over-reporting an input is merely a missed optimization, so when in
1934/// doubt list the slot.
1935fn for_each_input(op: &TapeOp, mut f: impl FnMut(usize)) {
1936    match op {
1937        TapeOp::Const(_) | TapeOp::Var(_) => {}
1938        TapeOp::Neg(a)
1939        | TapeOp::Abs(a)
1940        | TapeOp::Sqrt(a)
1941        | TapeOp::Exp(a)
1942        | TapeOp::Log(a)
1943        | TapeOp::Log10(a)
1944        | TapeOp::Sin(a)
1945        | TapeOp::Cos(a)
1946        | TapeOp::Tan(a)
1947        | TapeOp::Atan(a)
1948        | TapeOp::Acos(a)
1949        | TapeOp::Sinh(a)
1950        | TapeOp::Cosh(a)
1951        | TapeOp::Tanh(a)
1952        | TapeOp::Asin(a)
1953        | TapeOp::Acosh(a)
1954        | TapeOp::Asinh(a)
1955        | TapeOp::Atanh(a)
1956        | TapeOp::Erf(a)
1957        | TapeOp::XLogX(a)
1958        | TapeOp::Not(a) => f(*a),
1959        TapeOp::Add(a, b)
1960        | TapeOp::Sub(a, b)
1961        | TapeOp::Mul(a, b)
1962        | TapeOp::Div(a, b)
1963        | TapeOp::Pow(a, b)
1964        | TapeOp::CEntropy(a, b)
1965        | TapeOp::Atan2(a, b)
1966        | TapeOp::Min(a, b)
1967        | TapeOp::Max(a, b)
1968        | TapeOp::And(a, b)
1969        | TapeOp::Or(a, b)
1970        | TapeOp::Cmp(_, a, b) => {
1971            f(*a);
1972            f(*b);
1973        }
1974        TapeOp::Select(c, t, e) => {
1975            f(*c);
1976            f(*t);
1977            f(*e);
1978        }
1979        TapeOp::Funcall(fc) => {
1980            for arg in &fc.args {
1981                if let TapeFuncallArg::Tape(t) = arg {
1982                    f(*t);
1983                }
1984            }
1985        }
1986    }
1987}
1988
1989fn build_recursive(
1990    expr: &Expr,
1991    ops: &mut Vec<TapeOp>,
1992    cache: &mut HashMap<*const Expr, usize>,
1993    resolver: &ExternalResolver,
1994) -> usize {
1995    match expr {
1996        Expr::Const(c) => {
1997            let idx = ops.len();
1998            ops.push(TapeOp::Const(*c));
1999            idx
2000        }
2001        Expr::Var(i) => {
2002            let idx = ops.len();
2003            ops.push(TapeOp::Var(*i));
2004            idx
2005        }
2006        Expr::Binary(op, a, b) => {
2007            // Pow(x, const) is the dominant libm/dispatch cost in
2008            // transcendental-heavy AMPL tapes (henon, lane_emden, …):
2009            // `powf` itself is ~30–50 cycles AND the reverse-mode arm
2010            // for `Pow` carries an extra `ln(x)` branch. Rewriting
2011            // small integer / half-integer exponents into mul/sqrt
2012            // chains drops these calls entirely and reroutes the AD
2013            // through the much cheaper `Mul`/`Sqrt` arms.
2014            if let BinOp::Pow = op {
2015                if let Some(c) = peek_const(b) {
2016                    if let Some(idx) = try_emit_const_pow(a, c, ops, cache, resolver) {
2017                        return idx;
2018                    }
2019                }
2020            }
2021            let l = build_recursive(a, ops, cache, resolver);
2022            let r = build_recursive(b, ops, cache, resolver);
2023            let idx = ops.len();
2024            ops.push(match op {
2025                BinOp::Add => TapeOp::Add(l, r),
2026                BinOp::Sub => TapeOp::Sub(l, r),
2027                BinOp::Mul => TapeOp::Mul(l, r),
2028                BinOp::Div => TapeOp::Div(l, r),
2029                BinOp::Pow => TapeOp::Pow(l, r),
2030                BinOp::Atan2 => TapeOp::Atan2(l, r),
2031                BinOp::CEntropy => TapeOp::CEntropy(l, r),
2032            });
2033            idx
2034        }
2035        Expr::Unary(op, a) => {
2036            let v = build_recursive(a, ops, cache, resolver);
2037            let idx = ops.len();
2038            ops.push(match op {
2039                UnaryOp::Neg => TapeOp::Neg(v),
2040                UnaryOp::Sqrt => TapeOp::Sqrt(v),
2041                UnaryOp::Log => TapeOp::Log(v),
2042                UnaryOp::Log10 => TapeOp::Log10(v),
2043                UnaryOp::Exp => TapeOp::Exp(v),
2044                UnaryOp::Abs => TapeOp::Abs(v),
2045                UnaryOp::Sin => TapeOp::Sin(v),
2046                UnaryOp::Cos => TapeOp::Cos(v),
2047                UnaryOp::Tan => TapeOp::Tan(v),
2048                UnaryOp::Atan => TapeOp::Atan(v),
2049                UnaryOp::Acos => TapeOp::Acos(v),
2050                UnaryOp::Sinh => TapeOp::Sinh(v),
2051                UnaryOp::Cosh => TapeOp::Cosh(v),
2052                UnaryOp::Tanh => TapeOp::Tanh(v),
2053                UnaryOp::Asin => TapeOp::Asin(v),
2054                UnaryOp::Acosh => TapeOp::Acosh(v),
2055                UnaryOp::Asinh => TapeOp::Asinh(v),
2056                UnaryOp::Atanh => TapeOp::Atanh(v),
2057                UnaryOp::Erf => TapeOp::Erf(v),
2058                UnaryOp::XLogX => TapeOp::XLogX(v),
2059            });
2060            idx
2061        }
2062        Expr::Sum(args) => {
2063            if args.is_empty() {
2064                let idx = ops.len();
2065                ops.push(TapeOp::Const(0.0));
2066                return idx;
2067            }
2068            let mut acc = build_recursive(&args[0], ops, cache, resolver);
2069            for a in &args[1..] {
2070                let next = build_recursive(a, ops, cache, resolver);
2071                let idx = ops.len();
2072                ops.push(TapeOp::Add(acc, next));
2073                acc = idx;
2074            }
2075            acc
2076        }
2077        // n-ary min/max fold to a left-associative chain of binary
2078        // Min/Max TapeOps. The chain reproduces the list extremum, and
2079        // the binary Min/Max AD arms route the (sub)gradient to the
2080        // active operand at each step — equivalent to selecting the one
2081        // active operand of the whole list. An empty list cannot arise
2082        // from a well-formed `.nl` MINLIST/MAXLIST (count >= 1); guard
2083        // with a 0 constant for safety rather than panicking.
2084        Expr::MinList(args) | Expr::MaxList(args) => {
2085            let is_min = matches!(expr, Expr::MinList(_));
2086            if args.is_empty() {
2087                let idx = ops.len();
2088                ops.push(TapeOp::Const(0.0));
2089                return idx;
2090            }
2091            let mut acc = build_recursive(&args[0], ops, cache, resolver);
2092            for a in &args[1..] {
2093                let next = build_recursive(a, ops, cache, resolver);
2094                let idx = ops.len();
2095                ops.push(if is_min {
2096                    TapeOp::Min(acc, next)
2097                } else {
2098                    TapeOp::Max(acc, next)
2099                });
2100                acc = idx;
2101            }
2102            acc
2103        }
2104        Expr::Cse(body) => {
2105            // Cache by Arc identity so each shared body is emitted into
2106            // the tape exactly once and every reference resolves to the
2107            // same result-slot index. Forward computes the body once;
2108            // reverse-mode adjoint sums contributions from every ref
2109            // into that shared slot — exact chain rule for shared
2110            // sub-expressions.
2111            let key = Arc::as_ptr(body) as *const Expr;
2112            if let Some(&idx) = cache.get(&key) {
2113                idx
2114            } else {
2115                let idx = build_recursive(body, ops, cache, resolver);
2116                cache.insert(key, idx);
2117                idx
2118            }
2119        }
2120        Expr::Compare(op, a, b) => {
2121            let l = build_recursive(a, ops, cache, resolver);
2122            let r = build_recursive(b, ops, cache, resolver);
2123            let idx = ops.len();
2124            ops.push(TapeOp::Cmp(*op, l, r));
2125            idx
2126        }
2127        Expr::And(a, b) => {
2128            let l = build_recursive(a, ops, cache, resolver);
2129            let r = build_recursive(b, ops, cache, resolver);
2130            let idx = ops.len();
2131            ops.push(TapeOp::And(l, r));
2132            idx
2133        }
2134        Expr::Or(a, b) => {
2135            let l = build_recursive(a, ops, cache, resolver);
2136            let r = build_recursive(b, ops, cache, resolver);
2137            let idx = ops.len();
2138            ops.push(TapeOp::Or(l, r));
2139            idx
2140        }
2141        Expr::Not(a) => {
2142            let v = build_recursive(a, ops, cache, resolver);
2143            let idx = ops.len();
2144            ops.push(TapeOp::Not(v));
2145            idx
2146        }
2147        Expr::Cond { cond, then_, else_ } => {
2148            let c = build_recursive(cond, ops, cache, resolver);
2149            let t = build_recursive(then_, ops, cache, resolver);
2150            let e = build_recursive(else_, ops, cache, resolver);
2151            let idx = ops.len();
2152            ops.push(TapeOp::Select(c, t, e));
2153            idx
2154        }
2155        Expr::Funcall { id, args } => {
2156            let (lib, name) = resolver
2157                .funcs_by_id
2158                .get(id)
2159                .unwrap_or_else(|| panic!("unresolved AMPL funcall id {id}"));
2160            let tape_args: Vec<TapeFuncallArg> = args
2161                .iter()
2162                .map(|a| match a {
2163                    FuncallArg::Real(e) => {
2164                        TapeFuncallArg::Tape(build_recursive(e, ops, cache, resolver))
2165                    }
2166                    FuncallArg::Str(s) => TapeFuncallArg::Str(s.clone()),
2167                })
2168                .collect();
2169            let idx = ops.len();
2170            ops.push(TapeOp::Funcall(Box::new(FuncallData {
2171                lib: Arc::clone(lib),
2172                name: name.clone(),
2173                args: tape_args,
2174            })));
2175            idx
2176        }
2177    }
2178}
2179
2180/// Resolve `e` to a literal constant if it is one (transparently
2181/// peering through `Cse` wrappers, which AMPL emits around shared
2182/// constants in CSE-heavy problems).
2183fn peek_const(e: &Expr) -> Option<f64> {
2184    match e {
2185        Expr::Const(c) => Some(*c),
2186        Expr::Cse(body) => peek_const(body),
2187        _ => None,
2188    }
2189}
2190
2191/// Try to rewrite `base ^ exponent_const` into cheaper ops. Returns
2192/// the result tape-slot on success; `None` means "fall through to
2193/// generic Pow." Handles the cases that account for the bulk of
2194/// AMPL-emitted Pow nodes: integer exponents up to ±8 and the
2195/// `Sqrt`/passthrough/one specials. Half-integer exponents (e.g.
2196/// `^1.5`) and larger integers are left to generic `Pow` since the
2197/// resulting mul chain grows the tape faster than it saves work.
2198fn try_emit_const_pow(
2199    base_expr: &Expr,
2200    c: f64,
2201    ops: &mut Vec<TapeOp>,
2202    cache: &mut HashMap<*const Expr, usize>,
2203    resolver: &ExternalResolver,
2204) -> Option<usize> {
2205    if c == 0.0 {
2206        let idx = ops.len();
2207        ops.push(TapeOp::Const(1.0));
2208        return Some(idx);
2209    }
2210    if c == 1.0 {
2211        return Some(build_recursive(base_expr, ops, cache, resolver));
2212    }
2213    if c == 0.5 {
2214        let b = build_recursive(base_expr, ops, cache, resolver);
2215        let idx = ops.len();
2216        ops.push(TapeOp::Sqrt(b));
2217        return Some(idx);
2218    }
2219    // Integer exponents: bounded so a bad tape can't blow up the
2220    // op count. 8 covers everything AMPL typically emits for
2221    // polynomial models; beyond that the binary-expansion mul
2222    // chain (≥4 ops) starts to lose to a single `powf`.
2223    if c.is_finite() && c.fract() == 0.0 && c.abs() <= 8.0 {
2224        let n = c.abs() as u32;
2225        if n == 0 {
2226            // Already handled above, but guard.
2227            let idx = ops.len();
2228            ops.push(TapeOp::Const(1.0));
2229            return Some(idx);
2230        }
2231        let b = build_recursive(base_expr, ops, cache, resolver);
2232        let pos = emit_int_pow(b, n, ops);
2233        if c < 0.0 {
2234            // x^-n = 1 / x^n. Saves the powf and its ln branch in
2235            // reverse mode; cost is one Div in their place.
2236            let one_idx = ops.len();
2237            ops.push(TapeOp::Const(1.0));
2238            let idx = ops.len();
2239            ops.push(TapeOp::Div(one_idx, pos));
2240            return Some(idx);
2241        }
2242        return Some(pos);
2243    }
2244    None
2245}
2246
2247/// Emit `base^n` for `n >= 1` as a binary-expansion mul chain.
2248/// Worst-case op count is `2·floor(log2(n))` — i.e. 1 op for n=2, 2
2249/// for n=3/4, 3 for n=5..8.
2250fn emit_int_pow(base: usize, n: u32, ops: &mut Vec<TapeOp>) -> usize {
2251    debug_assert!(n >= 1);
2252    if n == 1 {
2253        return base;
2254    }
2255    let half = emit_int_pow(base, n / 2, ops);
2256    let squared = ops.len();
2257    ops.push(TapeOp::Mul(half, half));
2258    if n % 2 == 1 {
2259        let idx = ops.len();
2260        ops.push(TapeOp::Mul(squared, base));
2261        idx
2262    } else {
2263        squared
2264    }
2265}
2266
2267// ============================================================
2268// HybridTape: per-summand local tapes + shared CSE prelude.
2269//
2270// Partial separability — the .nl Sum/Add structure — gets each
2271// summand its own local Vec<SummandOp>. CSE bodies (V-segments
2272// in .nl) that appear in two or more summands are promoted into
2273// a single shared `prelude: Vec<TapeOp>`; per-summand references
2274// to a promoted CSE are SummandOp::Shared(prelude_slot).
2275//
2276// This is strictly better than either extreme:
2277//   - per-summand Tape (no cross-summand sharing): re-inlines
2278//     every shared CSE, blows up tape size when many constraints
2279//     share a stencil derivative (Mittelmann *120 problems).
2280//   - GlobalTape (single shared Vec<TapeOp> for everything):
2281//     per-root reverse sweeps scatter across a many-MB buffer,
2282//     thrashing cache when no CSE is actually shared (lane_emden
2283//     120: each constraint owns its own ops → 50% regression
2284//     vs per-summand tapes).
2285//
2286// Forward: prelude once, then each summand's local pass.
2287// Reverse / forward-over-reverse: per-summand sweep over local
2288// reach (which propagates adjoints into prelude_adj at Shared
2289// boundaries), then a small reverse pass over the summand's
2290// prelude_reach to fold those into grad / Hessian.
2291// ============================================================
2292
2293/// One slot in a per-summand local tape.
2294#[derive(Debug, Clone)]
2295pub enum SummandOp {
2296    /// Local op — operand indices reference other slots in the
2297    /// same per-summand vector.
2298    Local(TapeOp),
2299    /// Pull a value from the shared prelude at slot `usize`. No
2300    /// downstream cost beyond the lookup; adjoints flowing into
2301    /// this slot accumulate into the prelude adjoint buffer.
2302    Shared(usize),
2303}
2304
2305#[derive(Debug, Clone)]
2306pub struct Summand {
2307    pub ops: Vec<SummandOp>,
2308    /// Local slot holding the summand's final value.
2309    pub root_slot: usize,
2310    /// Local slots reachable from `root_slot`, ascending (topo).
2311    pub local_reach: Vec<usize>,
2312    /// Prelude slots reachable from the summand's Shared refs,
2313    /// ascending (topo in prelude's operand DAG).
2314    pub prelude_reach: Vec<usize>,
2315    /// Variables touched by Var ops inside `local_reach`.
2316    pub local_vars: Vec<usize>,
2317    /// Variables touched by Var ops inside `prelude_reach`.
2318    pub prelude_vars: Vec<usize>,
2319    /// `local_vars ∪ prelude_vars`, sorted — every problem variable this
2320    /// summand can contribute a derivative for. `eval_h` maps it through
2321    /// the Hessian coloring to decide which colors the summand
2322    /// participates in (issue #557); it must include `prelude_vars` or a
2323    /// summand would be skipped for colors it reaches only through a
2324    /// shared CSE body.
2325    pub all_vars: Vec<usize>,
2326}
2327
2328#[derive(Debug, Clone)]
2329pub struct HybridTape {
2330    /// Shared CSE bodies. Slot indices in `SummandOp::Shared`
2331    /// point here; this Vec is built bottom-up by `build_recursive`,
2332    /// so operand indices are always less than the consumer's
2333    /// index (topo in ascending order).
2334    pub prelude: Vec<TapeOp>,
2335    pub summands: Vec<Summand>,
2336}
2337
2338impl HybridTape {
2339    /// Build hybrid tape from a list of root expressions. CSE
2340    /// bodies referenced from ≥ 2 roots are promoted into the
2341    /// shared prelude; CSEs touched by only one root are inlined
2342    /// into that summand's local ops.
2343    pub fn build_multi(exprs: &[Expr]) -> Self {
2344        // Pass 1: per-Cse-pointer count of how many roots reference
2345        // it (each root contributes at most 1 to the count). The
2346        // ≥2 threshold means a CSE is shared across summands.
2347        let mut cse_count: HashMap<*const Expr, usize> = HashMap::new();
2348        for e in exprs {
2349            let mut seen_in_root: HashSet<*const Expr> = HashSet::new();
2350            count_cse_appearances(e, &mut seen_in_root, &mut cse_count);
2351        }
2352
2353        // Pass 2: build prelude + each summand. The summand builder
2354        // hits the prelude path lazily — only when it encounters a
2355        // promoted Cse — so the prelude grows only with bodies that
2356        // are actually referenced from multiple summands.
2357        let mut prelude: Vec<TapeOp> = Vec::new();
2358        let mut prelude_map: HashMap<*const Expr, usize> = HashMap::new();
2359        let mut summands: Vec<Summand> = Vec::with_capacity(exprs.len());
2360        for e in exprs {
2361            let mut local: Vec<SummandOp> = Vec::new();
2362            let mut local_cache: HashMap<*const Expr, usize> = HashMap::new();
2363            let root_slot = build_into_summand(
2364                e,
2365                &mut local,
2366                &mut local_cache,
2367                &mut prelude,
2368                &mut prelude_map,
2369                &cse_count,
2370            );
2371            summands.push(Summand {
2372                ops: local,
2373                root_slot,
2374                local_reach: Vec::new(),
2375                prelude_reach: Vec::new(),
2376                local_vars: Vec::new(),
2377                prelude_vars: Vec::new(),
2378                all_vars: Vec::new(),
2379            });
2380        }
2381
2382        // Pass 3: per-summand reach / vars. Prelude reach uses an
2383        // epoch-tagged shared visited buffer so total cost stays
2384        // O(Σ |prelude_reach_i|) rather than O(n_summands × |prelude|).
2385        let mut p_visited: Vec<u32> = vec![0; prelude.len()];
2386        let mut p_epoch: u32 = 0;
2387        let mut p_stack: Vec<usize> = Vec::new();
2388        for s in &mut summands {
2389            let (local_reach, shared_refs) = compute_local_reach(&s.ops, s.root_slot);
2390            s.local_reach = local_reach;
2391
2392            let mut lv: BTreeSet<usize> = BTreeSet::new();
2393            for &i in &s.local_reach {
2394                if let SummandOp::Local(TapeOp::Var(j)) = &s.ops[i] {
2395                    lv.insert(*j);
2396                }
2397            }
2398            s.local_vars = lv.iter().copied().collect();
2399
2400            if !shared_refs.is_empty() {
2401                p_epoch += 1;
2402                let mut preach: Vec<usize> = Vec::new();
2403                for &start in &shared_refs {
2404                    bfs_prelude(
2405                        &prelude,
2406                        start,
2407                        &mut p_visited,
2408                        p_epoch,
2409                        &mut p_stack,
2410                        &mut preach,
2411                    );
2412                }
2413                preach.sort_unstable();
2414                s.prelude_vars = vars_in(&prelude, &preach);
2415                s.prelude_reach = preach;
2416            }
2417
2418            let mut av: BTreeSet<usize> = lv;
2419            for &v in &s.prelude_vars {
2420                av.insert(v);
2421            }
2422            s.all_vars = av.into_iter().collect();
2423        }
2424
2425        HybridTape { prelude, summands }
2426    }
2427
2428    pub fn n_prelude_ops(&self) -> usize {
2429        self.prelude.len()
2430    }
2431    pub fn n_summands(&self) -> usize {
2432        self.summands.len()
2433    }
2434    pub fn max_summand_ops(&self) -> usize {
2435        self.summands.iter().map(|s| s.ops.len()).max().unwrap_or(0)
2436    }
2437    pub fn total_local_ops(&self) -> usize {
2438        self.summands.iter().map(|s| s.ops.len()).sum()
2439    }
2440
2441    /// Forward sweep over the shared prelude. `prelude_vals` must
2442    /// have length `n_prelude_ops`.
2443    pub fn forward_prelude(&self, x: &[f64], prelude_vals: &mut [f64]) {
2444        debug_assert_eq!(prelude_vals.len(), self.prelude.len());
2445        for i in 0..self.prelude.len() {
2446            prelude_vals[i] = fwd_step(&self.prelude[i], x, prelude_vals);
2447        }
2448    }
2449
2450    /// Forward sweep over one summand. `local_vals` must hold at
2451    /// least `s.ops.len()` entries.
2452    pub fn forward_summand(
2453        &self,
2454        s: &Summand,
2455        x: &[f64],
2456        prelude_vals: &[f64],
2457        local_vals: &mut [f64],
2458    ) {
2459        debug_assert!(local_vals.len() >= s.ops.len());
2460        for i in 0..s.ops.len() {
2461            local_vals[i] = match &s.ops[i] {
2462                SummandOp::Local(op) => fwd_step(op, x, local_vals),
2463                SummandOp::Shared(k) => prelude_vals[*k],
2464            };
2465        }
2466    }
2467
2468    /// Value at the summand root after `forward_summand`.
2469    #[inline]
2470    pub fn root_value(&self, s: &Summand, local_vals: &[f64]) -> f64 {
2471        local_vals[s.root_slot]
2472    }
2473
2474    /// Reverse-mode gradient for one summand. Walks `local_reach`
2475    /// in reverse — propagating adjoints into `prelude_adj` at
2476    /// Shared boundaries — and then walks `prelude_reach` in
2477    /// reverse to land contributions in `grad`. Scratch arrays
2478    /// `local_adj` and `prelude_adj` are zeroed only at the slots
2479    /// actually touched.
2480    #[allow(clippy::too_many_arguments)]
2481    pub fn gradient_summand(
2482        &self,
2483        s: &Summand,
2484        prelude_vals: &[f64],
2485        local_vals: &[f64],
2486        seed: f64,
2487        grad: &mut [f64],
2488        local_adj: &mut [f64],
2489        prelude_adj: &mut [f64],
2490    ) {
2491        if seed == 0.0 || s.local_reach.is_empty() {
2492            return;
2493        }
2494        for &i in &s.local_reach {
2495            local_adj[i] = 0.0;
2496        }
2497        for &i in &s.prelude_reach {
2498            prelude_adj[i] = 0.0;
2499        }
2500        local_adj[s.root_slot] = seed;
2501        for &i in s.local_reach.iter().rev() {
2502            let a = local_adj[i];
2503            if a == 0.0 {
2504                continue;
2505            }
2506            match &s.ops[i] {
2507                SummandOp::Local(op) => rev_step(op, i, local_vals, local_adj, a, grad),
2508                SummandOp::Shared(k) => {
2509                    prelude_adj[*k] += a;
2510                }
2511            }
2512        }
2513        for &i in s.prelude_reach.iter().rev() {
2514            let a = prelude_adj[i];
2515            if a == 0.0 {
2516                continue;
2517            }
2518            rev_step(&self.prelude[i], i, prelude_vals, prelude_adj, a, grad);
2519        }
2520    }
2521
2522    /// Forward tangent over the whole prelude for one dense seed
2523    /// vector (a Hessian color): `prelude_dot[i] = (∂prelude[i]/∂x) ·
2524    /// seed`. Runs **once per color for the entire constraint block**
2525    /// — this is the sweep the coloring lets every summand of that
2526    /// color share, and the reason the directional Hessian path
2527    /// exists at all (issue #557): a per-summand-per-variable seeding
2528    /// strategy would repeat it for every referencing summand.
2529    /// `reach` is the set of prelude slots this color actually uses —
2530    /// the union of `prelude_reach` over the color's summands. It must
2531    /// be **ascending** (the prelude is emitted bottom-up, so operand
2532    /// indices are always below their consumer's) and **closed under
2533    /// operands**, which a union of `prelude_reach` sets is by
2534    /// construction. Slots outside it are neither written nor read:
2535    /// a summand only pulls `prelude_dot[k]` for `k` in its own
2536    /// `prelude_reach`, so a stale tangent left in an untouched slot by
2537    /// a previous color can never be observed. Passing every slot is
2538    /// therefore always correct, just not always cheap — walking the
2539    /// whole prelude once per color is `n_colors × |prelude|` work
2540    /// against the `|prelude|` the op-ratio gate assumes.
2541    pub fn prelude_tangent(
2542        &self,
2543        prelude_vals: &[f64],
2544        seed: &[f64],
2545        reach: &[u32],
2546        prelude_dot: &mut [f64],
2547    ) {
2548        debug_assert!(prelude_dot.len() >= self.prelude.len());
2549        for &i in reach {
2550            let i = i as usize;
2551            prelude_dot[i] = fwd_dir_step(&self.prelude[i], seed, prelude_vals, prelude_dot, i);
2552        }
2553    }
2554
2555    /// Directional forward-over-reverse Hessian pass for one summand
2556    /// with multiplier `weight`, sharing the per-color prelude tangent
2557    /// computed by [`prelude_tangent`]: forward-tangent the local ops
2558    /// (pulling `prelude_dot` at `Shared` boundaries), then
2559    /// reverse-over-tangent them, writing `weight * (H · seed)[k]`
2560    /// into the dense `out[k]` at each `Var(k)` — the same contract as
2561    /// [`Tape::hessian_directional`], so contributions land straight
2562    /// in the coloring's `compressed[c]` buffer.
2563    ///
2564    /// Adjoints crossing a `Shared` boundary are folded into
2565    /// `prelude_adj` / `prelude_adj_dot` **scaled by `weight`**, and
2566    /// left there: reverse-over-tangent is linear in its adjoint
2567    /// seeds, so accumulating `λ_k`-weighted adjoints across every
2568    /// summand of a color and running [`prelude_reverse_directional`]
2569    /// once with unit weight computes the same second-order prelude
2570    /// contribution as a per-summand sweep — while paying for the
2571    /// prelude walk once per color instead of once per (summand,
2572    /// color) pair.
2573    ///
2574    /// `local_vals` must hold this summand's forward values (see
2575    /// [`forward_summand`]); `local_dot` / `local_adj` /
2576    /// `local_adj_dot` are scratch arenas (≥ `s.ops.len()`), zeroed
2577    /// here only at the slots the summand reaches.
2578    ///
2579    /// [`prelude_tangent`]: HybridTape::prelude_tangent
2580    /// [`prelude_reverse_directional`]: HybridTape::prelude_reverse_directional
2581    /// [`forward_summand`]: HybridTape::forward_summand
2582    #[allow(clippy::too_many_arguments)]
2583    pub fn hessian_summand_directional(
2584        &self,
2585        s: &Summand,
2586        local_vals: &[f64],
2587        prelude_dot: &[f64],
2588        seed: &[f64],
2589        weight: f64,
2590        out: &mut [f64],
2591        local_dot: &mut [f64],
2592        local_adj: &mut [f64],
2593        local_adj_dot: &mut [f64],
2594        prelude_adj: &mut [f64],
2595        prelude_adj_dot: &mut [f64],
2596    ) {
2597        if weight == 0.0 || s.local_reach.is_empty() {
2598            return;
2599        }
2600        for &i in &s.local_reach {
2601            local_adj[i] = 0.0;
2602            local_adj_dot[i] = 0.0;
2603        }
2604        // `local_dot` needs no pre-zeroing: `local_reach` is ascending
2605        // and every operand of a reached op is itself reached, so each
2606        // slot is written before any read.
2607        for &i in &s.local_reach {
2608            local_dot[i] = match &s.ops[i] {
2609                SummandOp::Local(op) => fwd_dir_step(op, seed, local_vals, local_dot, i),
2610                SummandOp::Shared(k) => prelude_dot[*k],
2611            };
2612        }
2613        local_adj[s.root_slot] = 1.0;
2614        for &i in s.local_reach.iter().rev() {
2615            let w = local_adj[i];
2616            let wd = local_adj_dot[i];
2617            if w == 0.0 && wd == 0.0 {
2618                continue;
2619            }
2620            match &s.ops[i] {
2621                SummandOp::Local(op) => {
2622                    ror_dir_step(
2623                        op,
2624                        i,
2625                        local_vals,
2626                        local_dot,
2627                        local_adj,
2628                        local_adj_dot,
2629                        w,
2630                        wd,
2631                        weight,
2632                        out,
2633                    );
2634                }
2635                SummandOp::Shared(k) => {
2636                    prelude_adj[*k] += weight * w;
2637                    prelude_adj_dot[*k] += weight * wd;
2638                }
2639            }
2640        }
2641    }
2642
2643    /// Reverse-over-tangent over the prelude with unit weight,
2644    /// consuming the adjoint accumulators built by
2645    /// [`hessian_summand_directional`] across all summands of one
2646    /// color and writing variable contributions into the dense `out`.
2647    ///
2648    /// `prelude_adj` / `prelude_adj_dot` must be all-zero except for
2649    /// the accumulated seeds on entry (allocate them zeroed and this
2650    /// invariant maintains itself); each slot is re-zeroed as it is
2651    /// consumed — adjoints only ever propagate to lower slots, which
2652    /// the reverse walk has not visited yet — so the buffers come back
2653    /// all-zero, ready for the next color, without an O(prelude) fill.
2654    ///
2655    /// [`hessian_summand_directional`]: HybridTape::hessian_summand_directional
2656    pub fn prelude_reverse_directional(
2657        &self,
2658        prelude_vals: &[f64],
2659        prelude_dot: &[f64],
2660        reach: &[u32],
2661        out: &mut [f64],
2662        prelude_adj: &mut [f64],
2663        prelude_adj_dot: &mut [f64],
2664    ) {
2665        for &i in reach.iter().rev() {
2666            let i = i as usize;
2667            let w = prelude_adj[i];
2668            let wd = prelude_adj_dot[i];
2669            if w == 0.0 && wd == 0.0 {
2670                continue;
2671            }
2672            prelude_adj[i] = 0.0;
2673            prelude_adj_dot[i] = 0.0;
2674            ror_dir_step(
2675                &self.prelude[i],
2676                i,
2677                prelude_vals,
2678                prelude_dot,
2679                prelude_adj,
2680                prelude_adj_dot,
2681                w,
2682                wd,
2683                1.0,
2684                out,
2685            );
2686        }
2687    }
2688
2689    /// Structural Hessian sparsity over the whole hybrid tape:
2690    /// every pair the prelude or any summand can produce.
2691    pub fn hessian_sparsity_all(&self) -> BTreeSet<(usize, usize)> {
2692        let mut pairs = hessian_sparsity_impl(&self.prelude);
2693
2694        // Per-prelude-slot var-set, reused across summands as the
2695        // var-set carrier for Shared refs.
2696        let prelude_var_sets = compute_var_sets(&self.prelude);
2697
2698        for s in &self.summands {
2699            summand_sparsity(&s.ops, &prelude_var_sets, &mut pairs);
2700        }
2701        pairs
2702    }
2703}
2704
2705/// Whether [`HybridTape::build_multi`] can build `exprs`, i.e. none of
2706/// them uses an opcode the hybrid (partial-separability) path rejects:
2707/// comparisons, AND/OR/NOT, if-then-else, min/max lists, or AMPL external
2708/// function calls. `build_into_summand` *panics* on those, so a caller
2709/// choosing between the hybrid path and the flat [`Tape`] path has to ask
2710/// first — there is nothing to catch.
2711///
2712/// Walks with an explicit stack (expression DAGs from `.nl` files can be
2713/// deeper than the default thread stack) and visits each shared CSE body
2714/// once, so the cost is linear in the DAG rather than in its unfolding.
2715pub fn hybrid_supported(exprs: &[Expr]) -> bool {
2716    let mut stack: Vec<&Expr> = exprs.iter().collect();
2717    let mut seen_cse: HashSet<*const Expr> = HashSet::new();
2718    while let Some(e) = stack.pop() {
2719        match e {
2720            Expr::Const(_) | Expr::Var(_) => {}
2721            Expr::Binary(_, a, b) => {
2722                stack.push(a);
2723                stack.push(b);
2724            }
2725            Expr::Unary(_, a) => stack.push(a),
2726            Expr::Sum(args) => stack.extend(args.iter()),
2727            Expr::Cse(body) => {
2728                if seen_cse.insert(Arc::as_ptr(body)) {
2729                    stack.push(body);
2730                }
2731            }
2732            Expr::Compare(..)
2733            | Expr::And(..)
2734            | Expr::Or(..)
2735            | Expr::Not(_)
2736            | Expr::Cond { .. }
2737            | Expr::MinList(_)
2738            | Expr::MaxList(_)
2739            | Expr::Funcall { .. } => return false,
2740        }
2741    }
2742    true
2743}
2744
2745/// True when `expr` (or any subexpression) is an AMPL external function
2746/// call. The hybrid summand path rejects funcalls outright, but the
2747/// *promoted*-CSE branch emits a shared CSE body via `build_recursive`
2748/// with an **empty** `ExternalResolver::default()` — it has no resolver
2749/// of its own. Without this pre-scan a funcall buried in a promoted CSE
2750/// would reach `build_recursive`'s `Expr::Funcall` arm and panic with the
2751/// misleading `unresolved AMPL funcall id <n>` message, instead of the
2752/// clear "not supported on the hybrid path" message the non-promoted
2753/// summand path raises. Pre-scanning makes both paths report the same
2754/// reason. (Funcalls are unsupported on the hybrid path regardless of
2755/// whether the id would resolve, so this never rejects a buildable tape.)
2756///
2757/// Walks with an explicit stack and visits each `Cse` body once, so the
2758/// cost is linear in the DAG rather than in its unfolding (gh#1066: a
2759/// chain `e = e + h*(u - e*e)` has `2^K` paths over `O(K)` nodes, and the
2760/// unmemoized walk never returned at K = 40). It also stops at a body
2761/// already in `built` — the prelude map — because that body was emitted
2762/// by `build_recursive` under the empty resolver, which would have
2763/// panicked on a funcall in it. Across one `build_multi` that bounds the
2764/// total scan by the DAG too, not by DAG times promoted bodies.
2765fn cse_contains_funcall(expr: &Expr, built: &HashMap<*const Expr, usize>) -> bool {
2766    let mut stack: Vec<&Expr> = vec![expr];
2767    let mut seen: HashSet<*const Expr> = HashSet::new();
2768    while let Some(e) = stack.pop() {
2769        match e {
2770            Expr::Funcall { .. } => return true,
2771            Expr::Const(_) | Expr::Var(_) => {}
2772            Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
2773                stack.push(a);
2774                stack.push(b);
2775            }
2776            Expr::Unary(_, a) | Expr::Not(a) => stack.push(a),
2777            Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
2778                stack.extend(args.iter())
2779            }
2780            Expr::Cond { cond, then_, else_ } => {
2781                stack.push(cond);
2782                stack.push(then_);
2783                stack.push(else_);
2784            }
2785            Expr::Cse(body) => {
2786                let key = Arc::as_ptr(body);
2787                if !built.contains_key(&key) && seen.insert(key) {
2788                    stack.push(body);
2789                }
2790            }
2791        }
2792    }
2793    false
2794}
2795
2796/// Pass-1 helper: per-root walk that increments `counts[ptr]` the
2797/// first time a Cse pointer is encountered in this root. Recursing
2798/// into the body is gated on the first visit to avoid quadratic
2799/// blowup on heavily shared CSE DAGs.
2800fn count_cse_appearances(
2801    e: &Expr,
2802    seen_in_root: &mut HashSet<*const Expr>,
2803    counts: &mut HashMap<*const Expr, usize>,
2804) {
2805    match e {
2806        Expr::Const(_) | Expr::Var(_) => {}
2807        Expr::Binary(_, a, b) => {
2808            count_cse_appearances(a, seen_in_root, counts);
2809            count_cse_appearances(b, seen_in_root, counts);
2810        }
2811        Expr::Unary(_, a) => count_cse_appearances(a, seen_in_root, counts),
2812        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
2813            for a in args {
2814                count_cse_appearances(a, seen_in_root, counts);
2815            }
2816        }
2817        Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
2818            count_cse_appearances(a, seen_in_root, counts);
2819            count_cse_appearances(b, seen_in_root, counts);
2820        }
2821        Expr::Not(a) => count_cse_appearances(a, seen_in_root, counts),
2822        Expr::Cond { cond, then_, else_ } => {
2823            count_cse_appearances(cond, seen_in_root, counts);
2824            count_cse_appearances(then_, seen_in_root, counts);
2825            count_cse_appearances(else_, seen_in_root, counts);
2826        }
2827        Expr::Cse(body) => {
2828            let key = Arc::as_ptr(body) as *const Expr;
2829            if seen_in_root.insert(key) {
2830                *counts.entry(key).or_insert(0) += 1;
2831                count_cse_appearances(body, seen_in_root, counts);
2832            }
2833        }
2834        Expr::Funcall { args, .. } => {
2835            for arg in args {
2836                if let FuncallArg::Real(e) = arg {
2837                    count_cse_appearances(e, seen_in_root, counts);
2838                }
2839            }
2840        }
2841    }
2842}
2843
2844/// Recursive summand builder. CSEs that meet the promotion bar
2845/// (≥ 2 roots reference them per `cse_count`) get a single prelude
2846/// emission via `build_recursive`; the summand records a Shared op
2847/// pointing at the prelude slot. Non-promoted CSEs are inlined
2848/// into the summand with intra-summand Arc-pointer dedup.
2849fn build_into_summand(
2850    expr: &Expr,
2851    local: &mut Vec<SummandOp>,
2852    local_cache: &mut HashMap<*const Expr, usize>,
2853    prelude: &mut Vec<TapeOp>,
2854    prelude_map: &mut HashMap<*const Expr, usize>,
2855    cse_count: &HashMap<*const Expr, usize>,
2856) -> usize {
2857    match expr {
2858        Expr::Const(c) => {
2859            let i = local.len();
2860            local.push(SummandOp::Local(TapeOp::Const(*c)));
2861            i
2862        }
2863        Expr::Var(j) => {
2864            let i = local.len();
2865            local.push(SummandOp::Local(TapeOp::Var(*j)));
2866            i
2867        }
2868        Expr::Binary(op, a, b) => {
2869            if let BinOp::Pow = op {
2870                if let Some(c) = peek_const(b) {
2871                    if let Some(i) = try_emit_const_pow_summand(
2872                        a,
2873                        c,
2874                        local,
2875                        local_cache,
2876                        prelude,
2877                        prelude_map,
2878                        cse_count,
2879                    ) {
2880                        return i;
2881                    }
2882                }
2883            }
2884            let l = build_into_summand(a, local, local_cache, prelude, prelude_map, cse_count);
2885            let r = build_into_summand(b, local, local_cache, prelude, prelude_map, cse_count);
2886            let i = local.len();
2887            local.push(SummandOp::Local(match op {
2888                BinOp::Add => TapeOp::Add(l, r),
2889                BinOp::Sub => TapeOp::Sub(l, r),
2890                BinOp::Mul => TapeOp::Mul(l, r),
2891                BinOp::Div => TapeOp::Div(l, r),
2892                BinOp::Pow => TapeOp::Pow(l, r),
2893                BinOp::Atan2 => TapeOp::Atan2(l, r),
2894                BinOp::CEntropy => TapeOp::CEntropy(l, r),
2895            }));
2896            i
2897        }
2898        Expr::Unary(op, a) => {
2899            let v = build_into_summand(a, local, local_cache, prelude, prelude_map, cse_count);
2900            let i = local.len();
2901            local.push(SummandOp::Local(match op {
2902                UnaryOp::Neg => TapeOp::Neg(v),
2903                UnaryOp::Sqrt => TapeOp::Sqrt(v),
2904                UnaryOp::Log => TapeOp::Log(v),
2905                UnaryOp::Log10 => TapeOp::Log10(v),
2906                UnaryOp::Exp => TapeOp::Exp(v),
2907                UnaryOp::Abs => TapeOp::Abs(v),
2908                UnaryOp::Sin => TapeOp::Sin(v),
2909                UnaryOp::Cos => TapeOp::Cos(v),
2910                UnaryOp::Tan => TapeOp::Tan(v),
2911                UnaryOp::Atan => TapeOp::Atan(v),
2912                UnaryOp::Acos => TapeOp::Acos(v),
2913                UnaryOp::Sinh => TapeOp::Sinh(v),
2914                UnaryOp::Cosh => TapeOp::Cosh(v),
2915                UnaryOp::Tanh => TapeOp::Tanh(v),
2916                UnaryOp::Asin => TapeOp::Asin(v),
2917                UnaryOp::Acosh => TapeOp::Acosh(v),
2918                UnaryOp::Asinh => TapeOp::Asinh(v),
2919                UnaryOp::Atanh => TapeOp::Atanh(v),
2920                UnaryOp::Erf => TapeOp::Erf(v),
2921                UnaryOp::XLogX => TapeOp::XLogX(v),
2922            }));
2923            i
2924        }
2925        Expr::Sum(args) => {
2926            if args.is_empty() {
2927                let i = local.len();
2928                local.push(SummandOp::Local(TapeOp::Const(0.0)));
2929                return i;
2930            }
2931            let mut acc = build_into_summand(
2932                &args[0],
2933                local,
2934                local_cache,
2935                prelude,
2936                prelude_map,
2937                cse_count,
2938            );
2939            for a in &args[1..] {
2940                let nxt =
2941                    build_into_summand(a, local, local_cache, prelude, prelude_map, cse_count);
2942                let i = local.len();
2943                local.push(SummandOp::Local(TapeOp::Add(acc, nxt)));
2944                acc = i;
2945            }
2946            acc
2947        }
2948        Expr::Cse(body) => {
2949            let key = Arc::as_ptr(body) as *const Expr;
2950            if let Some(&li) = local_cache.get(&key) {
2951                return li;
2952            }
2953            let promoted = cse_count.get(&key).copied().unwrap_or(0) >= 2;
2954            if promoted {
2955                // `build_recursive` below runs with an empty resolver, so a
2956                // funcall hidden inside the promoted body would panic with the
2957                // misleading "unresolved AMPL funcall id" message rather than
2958                // the clear hybrid-unsupported message the non-promoted summand
2959                // path (and the `Expr::Funcall` arm at the bottom) raises.
2960                // Reject it up front so both CSE paths report the same reason.
2961                if cse_contains_funcall(body, prelude_map) {
2962                    panic!(
2963                        "HybridTape: AMPL external function calls are not supported on the \
2964                         hybrid (partial-separability) tape path. Build with \
2965                         Tape::build_with_externals instead."
2966                    );
2967                }
2968                // Build (or reuse) the prelude slot for this CSE.
2969                // `build_recursive(expr, ...)` hits the Cse arm,
2970                // emits the body once into prelude, and caches it
2971                // in `prelude_map` keyed by this Arc pointer.
2972                let pslot =
2973                    build_recursive(expr, prelude, prelude_map, &ExternalResolver::default());
2974                let li = local.len();
2975                local.push(SummandOp::Shared(pslot));
2976                local_cache.insert(key, li);
2977                li
2978            } else {
2979                let li =
2980                    build_into_summand(body, local, local_cache, prelude, prelude_map, cse_count);
2981                local_cache.insert(key, li);
2982                li
2983            }
2984        }
2985        Expr::Compare(_, _, _)
2986        | Expr::And(_, _)
2987        | Expr::Or(_, _)
2988        | Expr::Not(_)
2989        | Expr::Cond { .. }
2990        | Expr::MinList(_)
2991        | Expr::MaxList(_) => {
2992            panic!(
2993                "HybridTape: conditional / logical / min-max opcodes (comparisons, \
2994                 AND/OR/NOT, if-then-else, min/max lists) are not supported on the \
2995                 hybrid (partial-separability) tape path. Build with \
2996                 Tape::build_with_externals instead."
2997            );
2998        }
2999        Expr::Funcall { .. } => {
3000            panic!(
3001                "HybridTape: AMPL external function calls are not supported on the \
3002                 hybrid (partial-separability) tape path. Build with Tape::build_with_externals \
3003                 instead."
3004            );
3005        }
3006    }
3007}
3008
3009/// Pow-lowering specialised for summand builds. Mirrors
3010/// `try_emit_const_pow` but with summand-flavoured emission.
3011fn try_emit_const_pow_summand(
3012    base_expr: &Expr,
3013    c: f64,
3014    local: &mut Vec<SummandOp>,
3015    local_cache: &mut HashMap<*const Expr, usize>,
3016    prelude: &mut Vec<TapeOp>,
3017    prelude_map: &mut HashMap<*const Expr, usize>,
3018    cse_count: &HashMap<*const Expr, usize>,
3019) -> Option<usize> {
3020    if c == 0.0 {
3021        let i = local.len();
3022        local.push(SummandOp::Local(TapeOp::Const(1.0)));
3023        return Some(i);
3024    }
3025    if c == 1.0 {
3026        return Some(build_into_summand(
3027            base_expr,
3028            local,
3029            local_cache,
3030            prelude,
3031            prelude_map,
3032            cse_count,
3033        ));
3034    }
3035    if c == 0.5 {
3036        let b = build_into_summand(
3037            base_expr,
3038            local,
3039            local_cache,
3040            prelude,
3041            prelude_map,
3042            cse_count,
3043        );
3044        let i = local.len();
3045        local.push(SummandOp::Local(TapeOp::Sqrt(b)));
3046        return Some(i);
3047    }
3048    if c.is_finite() && c.fract() == 0.0 && c.abs() <= 8.0 {
3049        let n = c.abs() as u32;
3050        if n == 0 {
3051            let i = local.len();
3052            local.push(SummandOp::Local(TapeOp::Const(1.0)));
3053            return Some(i);
3054        }
3055        let b = build_into_summand(
3056            base_expr,
3057            local,
3058            local_cache,
3059            prelude,
3060            prelude_map,
3061            cse_count,
3062        );
3063        let pos = emit_int_pow_summand(b, n, local);
3064        if c < 0.0 {
3065            let one_idx = local.len();
3066            local.push(SummandOp::Local(TapeOp::Const(1.0)));
3067            let i = local.len();
3068            local.push(SummandOp::Local(TapeOp::Div(one_idx, pos)));
3069            return Some(i);
3070        }
3071        return Some(pos);
3072    }
3073    None
3074}
3075
3076fn emit_int_pow_summand(base: usize, n: u32, local: &mut Vec<SummandOp>) -> usize {
3077    debug_assert!(n >= 1);
3078    if n == 1 {
3079        return base;
3080    }
3081    let half = emit_int_pow_summand(base, n / 2, local);
3082    let squared = local.len();
3083    local.push(SummandOp::Local(TapeOp::Mul(half, half)));
3084    if n % 2 == 1 {
3085        let i = local.len();
3086        local.push(SummandOp::Local(TapeOp::Mul(squared, base)));
3087        i
3088    } else {
3089        squared
3090    }
3091}
3092
3093/// Walk a summand's local op DAG from `root`, returning the
3094/// reachable local slots (sorted ascending) plus the distinct
3095/// prelude slots referenced by any Shared op along the way.
3096fn compute_local_reach(ops: &[SummandOp], root: usize) -> (Vec<usize>, Vec<usize>) {
3097    let mut visited = vec![false; ops.len()];
3098    let mut reach: Vec<usize> = Vec::new();
3099    let mut shared: BTreeSet<usize> = BTreeSet::new();
3100    let mut stack: Vec<usize> = Vec::with_capacity(16);
3101    visited[root] = true;
3102    reach.push(root);
3103    stack.push(root);
3104    while let Some(s) = stack.pop() {
3105        match &ops[s] {
3106            SummandOp::Local(op) => {
3107                let (a, b) = op_operands(op);
3108                if let Some(a) = a {
3109                    if !visited[a] {
3110                        visited[a] = true;
3111                        reach.push(a);
3112                        stack.push(a);
3113                    }
3114                }
3115                if let Some(b) = b {
3116                    if !visited[b] {
3117                        visited[b] = true;
3118                        reach.push(b);
3119                        stack.push(b);
3120                    }
3121                }
3122            }
3123            SummandOp::Shared(k) => {
3124                shared.insert(*k);
3125            }
3126        }
3127    }
3128    reach.sort_unstable();
3129    (reach, shared.into_iter().collect())
3130}
3131
3132/// Epoch-tagged BFS over the prelude operand DAG, accumulating
3133/// reachable slots into `out`. Caller is responsible for sorting
3134/// `out` after a batch of starts has been processed.
3135fn bfs_prelude(
3136    prelude: &[TapeOp],
3137    start: usize,
3138    visited: &mut [u32],
3139    cur: u32,
3140    stack: &mut Vec<usize>,
3141    out: &mut Vec<usize>,
3142) {
3143    if visited[start] == cur {
3144        return;
3145    }
3146    visited[start] = cur;
3147    out.push(start);
3148    stack.push(start);
3149    while let Some(s) = stack.pop() {
3150        let (a, b) = op_operands(&prelude[s]);
3151        if let Some(a) = a {
3152            if visited[a] != cur {
3153                visited[a] = cur;
3154                out.push(a);
3155                stack.push(a);
3156            }
3157        }
3158        if let Some(b) = b {
3159            if visited[b] != cur {
3160                visited[b] = cur;
3161                out.push(b);
3162                stack.push(b);
3163            }
3164        }
3165    }
3166}
3167
3168/// Per-op var-set for the prelude — every slot's transitive
3169/// variable footprint. Used by `summand_sparsity` to expand
3170/// `SummandOp::Shared(k)` into its var-set carrier.
3171fn compute_var_sets(ops: &[TapeOp]) -> Vec<BTreeSet<usize>> {
3172    let mut out: Vec<BTreeSet<usize>> = Vec::with_capacity(ops.len());
3173    for op in ops {
3174        let vs: BTreeSet<usize> = match op {
3175            TapeOp::Const(_) => BTreeSet::new(),
3176            TapeOp::Var(j) => {
3177                let mut s = BTreeSet::new();
3178                s.insert(*j);
3179                s
3180            }
3181            TapeOp::Add(a, b)
3182            | TapeOp::Sub(a, b)
3183            | TapeOp::Mul(a, b)
3184            | TapeOp::Div(a, b)
3185            | TapeOp::Pow(a, b)
3186            | TapeOp::Atan2(a, b)
3187            | TapeOp::CEntropy(a, b) => out[*a].union(&out[*b]).copied().collect(),
3188            TapeOp::Neg(a)
3189            | TapeOp::Abs(a)
3190            | TapeOp::Sqrt(a)
3191            | TapeOp::Exp(a)
3192            | TapeOp::Log(a)
3193            | TapeOp::Log10(a)
3194            | TapeOp::Sin(a)
3195            | TapeOp::Cos(a)
3196            | TapeOp::Tan(a)
3197            | TapeOp::Atan(a)
3198            | TapeOp::Acos(a)
3199            | TapeOp::Sinh(a)
3200            | TapeOp::Cosh(a)
3201            | TapeOp::Tanh(a)
3202            | TapeOp::Asin(a)
3203            | TapeOp::Acosh(a)
3204            | TapeOp::Asinh(a)
3205            | TapeOp::Erf(a)
3206            | TapeOp::XLogX(a)
3207            | TapeOp::Atanh(a) => out[*a].clone(),
3208            TapeOp::Cmp(_, _, _)
3209            | TapeOp::And(_, _)
3210            | TapeOp::Or(_, _)
3211            | TapeOp::Not(_)
3212            | TapeOp::Select(_, _, _)
3213            | TapeOp::Min(_, _)
3214            | TapeOp::Max(_, _) => unreachable!(
3215                "HybridTape prelude cannot contain conditional / logical / min-max \
3216                 TapeOps; build_into_summand panics on those Expr variants."
3217            ),
3218            TapeOp::Funcall(_) => unreachable!(
3219                "HybridTape prelude cannot contain TapeOp::Funcall; \
3220                 build_into_summand panics on Expr::Funcall."
3221            ),
3222        };
3223        out.push(vs);
3224    }
3225    out
3226}
3227
3228/// Per-op Hessian-sparsity propagation over a summand's mixed
3229/// SummandOp slice. Shared refs contribute their prelude var-set
3230/// but do not themselves emit pairs (those came from
3231/// `hessian_sparsity_impl(&prelude)`).
3232fn summand_sparsity(
3233    ops: &[SummandOp],
3234    prelude_var_sets: &[BTreeSet<usize>],
3235    pairs: &mut BTreeSet<(usize, usize)>,
3236) {
3237    let mut var_sets: Vec<BTreeSet<usize>> = Vec::with_capacity(ops.len());
3238    let emit_cross =
3239        |s1: &BTreeSet<usize>, s2: &BTreeSet<usize>, pairs: &mut BTreeSet<(usize, usize)>| {
3240            for &v1 in s1 {
3241                for &v2 in s2 {
3242                    let (r, c) = if v1 >= v2 { (v1, v2) } else { (v2, v1) };
3243                    pairs.insert((r, c));
3244                }
3245            }
3246        };
3247    let emit_self = |s: &BTreeSet<usize>, pairs: &mut BTreeSet<(usize, usize)>| {
3248        let vars: Vec<usize> = s.iter().copied().collect();
3249        for (ai, &vi) in vars.iter().enumerate() {
3250            for &vj in &vars[..=ai] {
3251                let (r, c) = if vi >= vj { (vi, vj) } else { (vj, vi) };
3252                pairs.insert((r, c));
3253            }
3254        }
3255    };
3256    for so in ops {
3257        let vset: BTreeSet<usize> = match so {
3258            SummandOp::Shared(k) => prelude_var_sets[*k].clone(),
3259            SummandOp::Local(op) => match op {
3260                TapeOp::Const(_) => BTreeSet::new(),
3261                TapeOp::Var(j) => {
3262                    let mut s = BTreeSet::new();
3263                    s.insert(*j);
3264                    s
3265                }
3266                TapeOp::Add(a, b) | TapeOp::Sub(a, b) => {
3267                    var_sets[*a].union(&var_sets[*b]).copied().collect()
3268                }
3269                TapeOp::Neg(a) | TapeOp::Abs(a) => var_sets[*a].clone(),
3270                TapeOp::Mul(a, b) => {
3271                    emit_cross(&var_sets[*a], &var_sets[*b], pairs);
3272                    var_sets[*a].union(&var_sets[*b]).copied().collect()
3273                }
3274                TapeOp::Div(a, b) => {
3275                    emit_cross(&var_sets[*a], &var_sets[*b], pairs);
3276                    emit_self(&var_sets[*b], pairs);
3277                    var_sets[*a].union(&var_sets[*b]).copied().collect()
3278                }
3279                TapeOp::Pow(a, b) | TapeOp::Atan2(a, b) | TapeOp::CEntropy(a, b) => {
3280                    let combined: BTreeSet<usize> =
3281                        var_sets[*a].union(&var_sets[*b]).copied().collect();
3282                    emit_self(&combined, pairs);
3283                    combined
3284                }
3285                TapeOp::Sqrt(a)
3286                | TapeOp::Exp(a)
3287                | TapeOp::Log(a)
3288                | TapeOp::Log10(a)
3289                | TapeOp::Sin(a)
3290                | TapeOp::Cos(a)
3291                | TapeOp::Tan(a)
3292                | TapeOp::Atan(a)
3293                | TapeOp::Acos(a)
3294                | TapeOp::Sinh(a)
3295                | TapeOp::Cosh(a)
3296                | TapeOp::Tanh(a)
3297                | TapeOp::Asin(a)
3298                | TapeOp::Acosh(a)
3299                | TapeOp::Asinh(a)
3300                | TapeOp::Erf(a)
3301                | TapeOp::XLogX(a)
3302                | TapeOp::Atanh(a) => {
3303                    emit_self(&var_sets[*a], pairs);
3304                    var_sets[*a].clone()
3305                }
3306                TapeOp::Cmp(_, _, _)
3307                | TapeOp::And(_, _)
3308                | TapeOp::Or(_, _)
3309                | TapeOp::Not(_)
3310                | TapeOp::Select(_, _, _)
3311                | TapeOp::Min(_, _)
3312                | TapeOp::Max(_, _) => unreachable!(
3313                    "HybridTape summand cannot contain conditional / logical / min-max \
3314                     TapeOps; build_into_summand panics on those Expr variants."
3315                ),
3316                TapeOp::Funcall(_) => unreachable!(
3317                    "HybridTape summand cannot contain TapeOp::Funcall; \
3318                     build_into_summand panics on Expr::Funcall."
3319                ),
3320            },
3321        };
3322        var_sets.push(vset);
3323    }
3324}
3325
3326/// Operand indices of a `TapeOp`, normalized into a fixed-length
3327/// array so callers don't need to re-match every site.
3328#[inline]
3329pub(crate) fn op_operands(op: &TapeOp) -> (Option<usize>, Option<usize>) {
3330    match op {
3331        TapeOp::Const(_) | TapeOp::Var(_) => (None, None),
3332        TapeOp::Add(a, b)
3333        | TapeOp::Sub(a, b)
3334        | TapeOp::Mul(a, b)
3335        | TapeOp::Div(a, b)
3336        | TapeOp::Pow(a, b)
3337        | TapeOp::Atan2(a, b)
3338        | TapeOp::CEntropy(a, b) => (Some(*a), Some(*b)),
3339        TapeOp::Neg(a)
3340        | TapeOp::Abs(a)
3341        | TapeOp::Sqrt(a)
3342        | TapeOp::Exp(a)
3343        | TapeOp::Log(a)
3344        | TapeOp::Log10(a)
3345        | TapeOp::Sin(a)
3346        | TapeOp::Cos(a)
3347        | TapeOp::Tan(a)
3348        | TapeOp::Atan(a)
3349        | TapeOp::Acos(a)
3350        | TapeOp::Sinh(a)
3351        | TapeOp::Cosh(a)
3352        | TapeOp::Tanh(a)
3353        | TapeOp::Asin(a)
3354        | TapeOp::Acosh(a)
3355        | TapeOp::Asinh(a)
3356        | TapeOp::Erf(a)
3357        | TapeOp::XLogX(a)
3358        | TapeOp::Atanh(a) => (Some(*a), None),
3359        // Conditional / logical TapeOps never reach the HybridTape
3360        // operand-walk (build_into_summand rejects them). Cmp/And/Or
3361        // have two operands; Not has one; Select's three can't be
3362        // expressed in this two-slot shape, so it would be a bug to
3363        // see it here.
3364        TapeOp::Cmp(_, a, b) | TapeOp::And(a, b) | TapeOp::Or(a, b) => (Some(*a), Some(*b)),
3365        TapeOp::Not(a) => (Some(*a), None),
3366        TapeOp::Select(_, _, _) => unreachable!(
3367            "op_operands: TapeOp::Select has three operands and is unsupported on \
3368             the HybridTape path"
3369        ),
3370        TapeOp::Min(_, _) | TapeOp::Max(_, _) => unreachable!(
3371            "op_operands: TapeOp::Min/Max are unsupported on the HybridTape path \
3372             (build_into_summand rejects min/max lists)"
3373        ),
3374        // Returning `(None, None)` here would be a silent wrong answer, not a
3375        // conservative one: a Funcall's tape arguments would be missing from
3376        // `local_reach`, and the directional Hessian deliberately does NOT
3377        // pre-zero `local_dot`, so those slots would be read as stale scratch
3378        // from a previous summand. Unreachable today —
3379        // `build_into_summand` panics on `Expr::Funcall` before any tape is
3380        // built — and this keeps it loud if that ever changes.
3381        TapeOp::Funcall(_) => unreachable!(
3382            "op_operands: TapeOp::Funcall is unsupported on the HybridTape path \
3383             (build_into_summand rejects external function calls)"
3384        ),
3385    }
3386}
3387
3388fn vars_in(ops: &[TapeOp], reach: &[usize]) -> Vec<usize> {
3389    let mut s: BTreeSet<usize> = BTreeSet::new();
3390    for &i in reach {
3391        if let TapeOp::Var(j) = &ops[i] {
3392            s.insert(*j);
3393        }
3394    }
3395    s.into_iter().collect()
3396}
3397
3398// ----- Free-function AD step kernels used by GlobalTape -----
3399
3400#[inline]
3401fn fwd_step(op: &TapeOp, x: &[f64], vals: &[f64]) -> f64 {
3402    match op {
3403        TapeOp::Const(c) => *c,
3404        TapeOp::Var(i) => x[*i],
3405        TapeOp::Add(a, b) => vals[*a] + vals[*b],
3406        TapeOp::Sub(a, b) => vals[*a] - vals[*b],
3407        TapeOp::Mul(a, b) => vals[*a] * vals[*b],
3408        TapeOp::Div(a, b) => vals[*a] / vals[*b],
3409        TapeOp::Pow(a, b) => vals[*a].powf(vals[*b]),
3410        TapeOp::Neg(a) => -vals[*a],
3411        TapeOp::Abs(a) => vals[*a].abs(),
3412        TapeOp::Sqrt(a) => vals[*a].sqrt(),
3413        TapeOp::Exp(a) => vals[*a].exp(),
3414        TapeOp::Log(a) => vals[*a].ln(),
3415        TapeOp::Log10(a) => vals[*a].log10(),
3416        TapeOp::Sin(a) => vals[*a].sin(),
3417        TapeOp::Cos(a) => vals[*a].cos(),
3418        TapeOp::Tan(a) => vals[*a].tan(),
3419        TapeOp::Atan(a) => vals[*a].atan(),
3420        TapeOp::Acos(a) => vals[*a].acos(),
3421        TapeOp::Sinh(a) => vals[*a].sinh(),
3422        TapeOp::Cosh(a) => vals[*a].cosh(),
3423        TapeOp::Tanh(a) => vals[*a].tanh(),
3424        TapeOp::Asin(a) => vals[*a].asin(),
3425        TapeOp::Acosh(a) => vals[*a].acosh(),
3426        TapeOp::Asinh(a) => vals[*a].asinh(),
3427        TapeOp::Atanh(a) => vals[*a].atanh(),
3428        TapeOp::Erf(a) => erf(vals[*a]),
3429        TapeOp::XLogX(a) => xlogx(vals[*a]),
3430        TapeOp::CEntropy(a, b) => centropy(vals[*a], vals[*b]),
3431        TapeOp::Atan2(a, b) => vals[*a].atan2(vals[*b]),
3432        TapeOp::Cmp(_, _, _)
3433        | TapeOp::And(_, _)
3434        | TapeOp::Or(_, _)
3435        | TapeOp::Not(_)
3436        | TapeOp::Select(_, _, _)
3437        | TapeOp::Min(_, _)
3438        | TapeOp::Max(_, _) => panic!(
3439            "GlobalTape free-function kernels do not implement conditional / logical \
3440             / min-max TapeOps; use the Tape (build_with_externals) interpreter path \
3441             instead."
3442        ),
3443        TapeOp::Funcall(fc) => {
3444            let FuncallData { lib, name, args } = fc.as_ref();
3445            let call_args = funcall_to_ext_args(args, vals);
3446            let res = lib
3447                .eval(name, &call_args, false, false)
3448                .unwrap_or_else(|e| panic!("external function '{name}' eval failed: {e}"));
3449            res.value
3450        }
3451    }
3452}
3453
3454#[inline]
3455fn rev_step(op: &TapeOp, i: usize, vals: &[f64], adj: &mut [f64], a: f64, grad: &mut [f64]) {
3456    match op {
3457        TapeOp::Const(_) => {}
3458        TapeOp::Var(j) => {
3459            grad[*j] += a;
3460        }
3461        TapeOp::Add(l, r) => {
3462            adj[*l] += a;
3463            adj[*r] += a;
3464        }
3465        TapeOp::Sub(l, r) => {
3466            adj[*l] += a;
3467            adj[*r] -= a;
3468        }
3469        TapeOp::Mul(l, r) => {
3470            adj[*l] += a * vals[*r];
3471            adj[*r] += a * vals[*l];
3472        }
3473        TapeOp::Div(l, r) => {
3474            // Kahan form -- see the identical arm in the reverse sweep above.
3475            let rv = vals[*r];
3476            adj[*l] += a / rv;
3477            adj[*r] -= a * vals[i] / rv;
3478        }
3479        TapeOp::Pow(l, r) => {
3480            let lv = vals[*l];
3481            let rv = vals[*r];
3482            if rv != 0.0 {
3483                adj[*l] += a * rv * lv.powf(rv - 1.0);
3484            }
3485            if lv > 0.0 {
3486                adj[*r] += a * vals[i] * lv.ln();
3487            }
3488        }
3489        TapeOp::Neg(j) => {
3490            adj[*j] -= a;
3491        }
3492        TapeOp::Abs(j) => {
3493            if vals[*j] >= 0.0 {
3494                adj[*j] += a;
3495            } else {
3496                adj[*j] -= a;
3497            }
3498        }
3499        TapeOp::Sqrt(j) => {
3500            let sv = vals[i];
3501            if sv > 0.0 {
3502                adj[*j] += a * 0.5 / sv;
3503            }
3504        }
3505        TapeOp::Exp(j) => {
3506            adj[*j] += a * vals[i];
3507        }
3508        TapeOp::Log(j) => {
3509            adj[*j] += a / vals[*j];
3510        }
3511        TapeOp::Log10(j) => {
3512            adj[*j] += a / (vals[*j] * std::f64::consts::LN_10);
3513        }
3514        TapeOp::Sin(j) => {
3515            adj[*j] += a * vals[*j].cos();
3516        }
3517        TapeOp::Cos(j) => {
3518            adj[*j] -= a * vals[*j].sin();
3519        }
3520        TapeOp::Tan(j) => {
3521            let t = vals[i];
3522            adj[*j] += a * (1.0 + t * t);
3523        }
3524        TapeOp::Atan(j) => {
3525            let u = vals[*j];
3526            adj[*j] += a / (1.0 + u * u);
3527        }
3528        TapeOp::Acos(j) => {
3529            let u = vals[*j];
3530            adj[*j] -= a / (1.0 - u * u).sqrt();
3531        }
3532        TapeOp::Sinh(j) => {
3533            adj[*j] += a * vals[*j].cosh();
3534        }
3535        TapeOp::Cosh(j) => {
3536            adj[*j] += a * vals[*j].sinh();
3537        }
3538        TapeOp::Tanh(j) => {
3539            let t = vals[i];
3540            adj[*j] += a * (1.0 - t * t);
3541        }
3542        TapeOp::Asin(j) => {
3543            let u = vals[*j];
3544            adj[*j] += a / (1.0 - u * u).sqrt();
3545        }
3546        TapeOp::Acosh(j) => {
3547            let u = vals[*j];
3548            adj[*j] += a / (u * u - 1.0).sqrt();
3549        }
3550        TapeOp::Asinh(j) => {
3551            let u = vals[*j];
3552            adj[*j] += a / (u * u + 1.0).sqrt();
3553        }
3554        TapeOp::Atanh(j) => {
3555            let u = vals[*j];
3556            adj[*j] += a / (1.0 - u * u);
3557        }
3558        TapeOp::Erf(j) => {
3559            adj[*j] += a * erf_d1(vals[*j]);
3560        }
3561        TapeOp::XLogX(j) => {
3562            adj[*j] += a * xlogx_d1(vals[*j]);
3563        }
3564        TapeOp::CEntropy(l, r) => {
3565            adj[*l] += a * centropy_da(vals[*l], vals[*r]);
3566            adj[*r] += a * centropy_db(vals[*l], vals[*r]);
3567        }
3568        TapeOp::Atan2(l, r) => {
3569            let y = vals[*l];
3570            let x = vals[*r];
3571            let d = y * y + x * x;
3572            adj[*l] += a * (x / d);
3573            adj[*r] += a * (-y / d);
3574        }
3575        TapeOp::Cmp(_, _, _)
3576        | TapeOp::And(_, _)
3577        | TapeOp::Or(_, _)
3578        | TapeOp::Not(_)
3579        | TapeOp::Select(_, _, _)
3580        | TapeOp::Min(_, _)
3581        | TapeOp::Max(_, _) => panic!(
3582            "GlobalTape free-function kernels do not implement conditional / logical \
3583             / min-max TapeOps; use the Tape (build_with_externals) interpreter path \
3584             instead."
3585        ),
3586        TapeOp::Funcall(fc) => {
3587            let FuncallData { lib, name, args } = fc.as_ref();
3588            let call_args = funcall_to_ext_args(args, vals);
3589            let res = lib
3590                .eval(name, &call_args, true, false)
3591                .unwrap_or_else(|e| panic!("external function '{name}' reverse eval failed: {e}"));
3592            let derivs = res.derivs.expect("want_derivs=true returns derivs");
3593            let mut k = 0usize;
3594            for arg in args {
3595                if let TapeFuncallArg::Tape(idx) = arg {
3596                    adj[*idx] += a * derivs[k];
3597                    k += 1;
3598                }
3599            }
3600            let _ = i;
3601            let _ = grad;
3602        }
3603    }
3604}
3605
3606/// Directional forward-tangent step: [`fwd_tan_step`] with a dense
3607/// seed vector instead of a single seed variable. The per-op chain
3608/// rule is identical (and identical to the inline arms of
3609/// [`Tape::hessian_directional`], which the hybrid Hessian path must
3610/// agree with); only the `Var` arm differs — it reads `seed[k]`
3611/// rather than testing `k == seed_var` — which is what lets one pass
3612/// carry a whole Hessian color.
3613#[inline]
3614fn fwd_dir_step(op: &TapeOp, seed: &[f64], vals: &[f64], dot: &[f64], i: usize) -> f64 {
3615    match op {
3616        TapeOp::Const(_) => 0.0,
3617        TapeOp::Var(k) => seed[*k],
3618        TapeOp::Add(a, b) => dot[*a] + dot[*b],
3619        TapeOp::Sub(a, b) => dot[*a] - dot[*b],
3620        TapeOp::Mul(a, b) => dot[*a] * vals[*b] + vals[*a] * dot[*b],
3621        TapeOp::Div(a, b) => {
3622            // Kahan form -- see the identical arm in `forward_tangent` for why the
3623            // squared denominator loses representable tangents at extreme |b|.
3624            (dot[*a] - vals[i] * dot[*b]) / vals[*b]
3625        }
3626        TapeOp::Pow(a, b) => {
3627            let u = vals[*a];
3628            let r = vals[*b];
3629            let du = dot[*a];
3630            let dr = dot[*b];
3631            let mut result = 0.0;
3632            // Match the reverse-mode gradient's guard (`rv != 0.0` only): at base
3633            // u == 0 the slope is still well defined for r >= 1 (and a genuine
3634            // ±inf for r < 1), so it must not be silently dropped, or the forward
3635            // tangent disagrees with the reverse gradient.
3636            if r != 0.0 {
3637                result += r * u.powf(r - 1.0) * du;
3638            }
3639            if u > 0.0 {
3640                result += vals[i] * u.ln() * dr;
3641            }
3642            result
3643        }
3644        TapeOp::Neg(a) => -dot[*a],
3645        TapeOp::Abs(a) => {
3646            if vals[*a] >= 0.0 {
3647                dot[*a]
3648            } else {
3649                -dot[*a]
3650            }
3651        }
3652        TapeOp::Sqrt(a) => {
3653            let sv = vals[i];
3654            if sv > 0.0 { dot[*a] * 0.5 / sv } else { 0.0 }
3655        }
3656        TapeOp::Exp(a) => vals[i] * dot[*a],
3657        TapeOp::Log(a) => dot[*a] / vals[*a],
3658        TapeOp::Log10(a) => dot[*a] / (vals[*a] * std::f64::consts::LN_10),
3659        TapeOp::Sin(a) => vals[*a].cos() * dot[*a],
3660        TapeOp::Cos(a) => -vals[*a].sin() * dot[*a],
3661        TapeOp::Tan(a) => {
3662            let t = vals[i];
3663            (1.0 + t * t) * dot[*a]
3664        }
3665        TapeOp::Atan(a) => {
3666            let u = vals[*a];
3667            dot[*a] / (1.0 + u * u)
3668        }
3669        TapeOp::Acos(a) => {
3670            let u = vals[*a];
3671            -dot[*a] / (1.0 - u * u).sqrt()
3672        }
3673        TapeOp::Sinh(a) => dot[*a] * vals[*a].cosh(),
3674        TapeOp::Cosh(a) => dot[*a] * vals[*a].sinh(),
3675        TapeOp::Tanh(a) => {
3676            let t = vals[i];
3677            (1.0 - t * t) * dot[*a]
3678        }
3679        TapeOp::Asin(a) => {
3680            let u = vals[*a];
3681            dot[*a] / (1.0 - u * u).sqrt()
3682        }
3683        TapeOp::Acosh(a) => {
3684            let u = vals[*a];
3685            dot[*a] / (u * u - 1.0).sqrt()
3686        }
3687        TapeOp::Asinh(a) => {
3688            let u = vals[*a];
3689            dot[*a] / (u * u + 1.0).sqrt()
3690        }
3691        TapeOp::Atanh(a) => {
3692            let u = vals[*a];
3693            dot[*a] / (1.0 - u * u)
3694        }
3695        TapeOp::Erf(a) => erf_d1(vals[*a]) * dot[*a],
3696        TapeOp::XLogX(a) => xlogx_d1(vals[*a]) * dot[*a],
3697        TapeOp::CEntropy(a, b) => {
3698            centropy_da(vals[*a], vals[*b]) * dot[*a] + centropy_db(vals[*a], vals[*b]) * dot[*b]
3699        }
3700        TapeOp::Atan2(a, b) => {
3701            let y = vals[*a];
3702            let x = vals[*b];
3703            let d = y * y + x * x;
3704            (x * dot[*a] - y * dot[*b]) / d
3705        }
3706        TapeOp::Cmp(_, _, _)
3707        | TapeOp::And(_, _)
3708        | TapeOp::Or(_, _)
3709        | TapeOp::Not(_)
3710        | TapeOp::Select(_, _, _)
3711        | TapeOp::Min(_, _)
3712        | TapeOp::Max(_, _) => panic!(
3713            "GlobalTape free-function kernels do not implement conditional / logical \
3714             / min-max TapeOps; use the Tape (build_with_externals) interpreter path \
3715             instead."
3716        ),
3717        TapeOp::Funcall(fc) => {
3718            let FuncallData { lib, name, args } = fc.as_ref();
3719            let call_args = funcall_to_ext_args(args, vals);
3720            let res = lib
3721                .eval(name, &call_args, true, false)
3722                .unwrap_or_else(|e| panic!("external function '{name}' tangent eval failed: {e}"));
3723            let derivs = res.derivs.expect("want_derivs=true returns derivs");
3724            let mut acc = 0.0;
3725            let mut k = 0usize;
3726            for arg in args {
3727                if let TapeFuncallArg::Tape(idx) = arg {
3728                    acc += derivs[k] * dot[*idx];
3729                    k += 1;
3730                }
3731            }
3732            let _ = seed;
3733            acc
3734        }
3735    }
3736}
3737
3738/// Directional reverse-over-tangent step: [`ror_step`] with the
3739/// hash-map scatter replaced by the dense accumulation
3740/// [`Tape::hessian_directional`] uses — at `Var(k)` the emitted
3741/// second-order contribution is `out[k] += weight * wd`, landing in
3742/// the coloring's per-color `compressed` buffer with no hashing. All
3743/// other arms are the exact arithmetic of `hessian_directional`'s
3744/// reverse sweep, so the local part of the hybrid Hessian is
3745/// bit-identical to the flat tape's.
3746#[allow(clippy::too_many_arguments)]
3747#[inline]
3748fn ror_dir_step(
3749    op: &TapeOp,
3750    i: usize,
3751    vals: &[f64],
3752    dot: &[f64],
3753    adj: &mut [f64],
3754    adj_dot: &mut [f64],
3755    w: f64,
3756    wd: f64,
3757    weight: f64,
3758    out: &mut [f64],
3759) {
3760    match op {
3761        TapeOp::Const(_) => {}
3762        TapeOp::Var(k) => {
3763            if wd != 0.0 {
3764                out[*k] += weight * wd;
3765            }
3766        }
3767        TapeOp::Add(a, b) => {
3768            adj[*a] += w;
3769            adj[*b] += w;
3770            adj_dot[*a] += wd;
3771            adj_dot[*b] += wd;
3772        }
3773        TapeOp::Sub(a, b) => {
3774            adj[*a] += w;
3775            adj[*b] -= w;
3776            adj_dot[*a] += wd;
3777            adj_dot[*b] -= wd;
3778        }
3779        TapeOp::Mul(a, b) => {
3780            adj[*a] += w * vals[*b];
3781            adj[*b] += w * vals[*a];
3782            adj_dot[*a] += wd * vals[*b] + w * dot[*b];
3783            adj_dot[*b] += wd * vals[*a] + w * dot[*a];
3784        }
3785        TapeOp::Div(a, b) => {
3786            // Kahan form -- see the identical arm in the dense
3787            // reverse-over-forward sweep for the derivation.
3788            let vb = vals[*b];
3789            let q = vals[i];
3790            let qd = dot[i];
3791            adj[*a] += w / vb;
3792            adj_dot[*a] += wd / vb - w * (dot[*b] / vb) / vb;
3793            adj[*b] -= w * q / vb;
3794            adj_dot[*b] += -(wd * q) / vb + (w / vb) * (-qd + q * (dot[*b] / vb));
3795        }
3796        TapeOp::Pow(a, b) => {
3797            let u = vals[*a];
3798            let r = vals[*b];
3799            let du = dot[*a];
3800            let dr = dot[*b];
3801            if r != 0.0 {
3802                if u != 0.0 {
3803                    let p_a = r * u.powf(r - 1.0);
3804                    adj[*a] += w * p_a;
3805                    let mut dp_a = dr * u.powf(r - 1.0);
3806                    if u > 0.0 {
3807                        dp_a += r * u.powf(r - 1.0) * ((r - 1.0) * du / u + dr * u.ln());
3808                    } else {
3809                        dp_a += r * (r - 1.0) * u.powf(r - 2.0) * du;
3810                    }
3811                    adj_dot[*a] += wd * p_a + w * dp_a;
3812                } else if r >= 2.0 {
3813                    let p_a = 0.0;
3814                    adj[*a] += w * p_a;
3815                    let dp_a = if r == 2.0 {
3816                        2.0 * du
3817                    } else {
3818                        r * (r - 1.0) * (0.0_f64).powf(r - 2.0) * du
3819                    };
3820                    adj_dot[*a] += wd * p_a + w * dp_a;
3821                }
3822            }
3823            if u > 0.0 {
3824                let ln_u = u.ln();
3825                let p_b = vals[i] * ln_u;
3826                adj[*b] += w * p_b;
3827                let dur = vals[i] * (r * du / u + dr * ln_u);
3828                let dp_b = dur * ln_u + vals[i] * du / u;
3829                adj_dot[*b] += wd * p_b + w * dp_b;
3830            }
3831        }
3832        TapeOp::Neg(a) => {
3833            adj[*a] -= w;
3834            adj_dot[*a] -= wd;
3835        }
3836        TapeOp::Abs(a) => {
3837            let s = if vals[*a] >= 0.0 { 1.0 } else { -1.0 };
3838            adj[*a] += w * s;
3839            adj_dot[*a] += wd * s;
3840        }
3841        TapeOp::Sqrt(a) => {
3842            let sv = vals[i];
3843            if sv > 0.0 {
3844                let fp = 0.5 / sv;
3845                let fpp = -0.25 / (vals[*a] * sv);
3846                adj[*a] += w * fp;
3847                adj_dot[*a] += wd * fp + w * fpp * dot[*a];
3848            }
3849        }
3850        TapeOp::Exp(a) => {
3851            let ev = vals[i];
3852            adj[*a] += w * ev;
3853            adj_dot[*a] += wd * ev + w * ev * dot[*a];
3854        }
3855        TapeOp::Log(a) => {
3856            let u = vals[*a];
3857            adj[*a] += w / u;
3858            adj_dot[*a] += wd / u + w * (-1.0 / (u * u)) * dot[*a];
3859        }
3860        TapeOp::Log10(a) => {
3861            let u = vals[*a];
3862            let c = std::f64::consts::LN_10;
3863            adj[*a] += w / (u * c);
3864            adj_dot[*a] += wd / (u * c) + w * (-1.0 / (u * u * c)) * dot[*a];
3865        }
3866        TapeOp::Sin(a) => {
3867            let u = vals[*a];
3868            let cu = u.cos();
3869            adj[*a] += w * cu;
3870            adj_dot[*a] += wd * cu + w * (-u.sin()) * dot[*a];
3871        }
3872        TapeOp::Cos(a) => {
3873            let u = vals[*a];
3874            let su = u.sin();
3875            adj[*a] -= w * su;
3876            adj_dot[*a] += wd * (-su) + w * (-u.cos()) * dot[*a];
3877        }
3878        TapeOp::Tan(a) => {
3879            let t = vals[i];
3880            let gp = 1.0 + t * t;
3881            let gpp = 2.0 * t * gp;
3882            adj[*a] += w * gp;
3883            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3884        }
3885        TapeOp::Atan(a) => {
3886            let u = vals[*a];
3887            let d = 1.0 + u * u;
3888            let gp = 1.0 / d;
3889            let gpp = -2.0 * u / (d * d);
3890            adj[*a] += w * gp;
3891            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3892        }
3893        TapeOp::Acos(a) => {
3894            let u = vals[*a];
3895            let s = 1.0 - u * u;
3896            let r = s.sqrt();
3897            let gp = -1.0 / r;
3898            let gpp = -u / (s * r);
3899            adj[*a] += w * gp;
3900            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3901        }
3902        TapeOp::Sinh(a) => {
3903            let u = vals[*a];
3904            let gp = u.cosh();
3905            let gpp = vals[i]; // sinh(u)
3906            adj[*a] += w * gp;
3907            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3908        }
3909        TapeOp::Cosh(a) => {
3910            let u = vals[*a];
3911            let gp = u.sinh();
3912            let gpp = vals[i]; // cosh(u)
3913            adj[*a] += w * gp;
3914            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3915        }
3916        TapeOp::Tanh(a) => {
3917            let t = vals[i];
3918            let gp = 1.0 - t * t;
3919            let gpp = -2.0 * t * gp;
3920            adj[*a] += w * gp;
3921            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3922        }
3923        TapeOp::Asin(a) => {
3924            let u = vals[*a];
3925            let s = 1.0 - u * u;
3926            let r = s.sqrt();
3927            let gp = 1.0 / r;
3928            let gpp = u / (s * r);
3929            adj[*a] += w * gp;
3930            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3931        }
3932        TapeOp::Acosh(a) => {
3933            let u = vals[*a];
3934            let s = u * u - 1.0;
3935            let r = s.sqrt();
3936            let gp = 1.0 / r;
3937            let gpp = -u / (s * r);
3938            adj[*a] += w * gp;
3939            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3940        }
3941        TapeOp::Asinh(a) => {
3942            let u = vals[*a];
3943            let s = u * u + 1.0;
3944            let r = s.sqrt();
3945            let gp = 1.0 / r;
3946            let gpp = -u / (s * r);
3947            adj[*a] += w * gp;
3948            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3949        }
3950        TapeOp::Atanh(a) => {
3951            let u = vals[*a];
3952            let d = 1.0 - u * u;
3953            let gp = 1.0 / d;
3954            let gpp = 2.0 * u / (d * d);
3955            adj[*a] += w * gp;
3956            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3957        }
3958        TapeOp::Erf(a) => {
3959            let u = vals[*a];
3960            let gp = erf_d1(u);
3961            let gpp = erf_d2(u);
3962            adj[*a] += w * gp;
3963            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3964        }
3965        TapeOp::XLogX(a) => {
3966            // See the identical arm in `hessian_accumulate`: gpp is 1/u, never
3967            // the unrepresentable -1/u² of `ln''`.
3968            let u = vals[*a];
3969            let gp = xlogx_d1(u);
3970            let gpp = xlogx_d2(u);
3971            adj[*a] += w * gp;
3972            adj_dot[*a] += wd * gp + w * gpp * dot[*a];
3973        }
3974        TapeOp::CEntropy(a, b) => {
3975            let ua = vals[*a];
3976            let ub = vals[*b];
3977            let fa = centropy_da(ua, ub);
3978            let fb = centropy_db(ua, ub);
3979            let faa = centropy_daa(ua);
3980            let fab = centropy_dab(ub);
3981            let fbb = centropy_dbb(ua, ub);
3982            adj[*a] += w * fa;
3983            adj[*b] += w * fb;
3984            adj_dot[*a] += wd * fa + w * (faa * dot[*a] + fab * dot[*b]);
3985            adj_dot[*b] += wd * fb + w * (fab * dot[*a] + fbb * dot[*b]);
3986        }
3987        TapeOp::Atan2(a, b) => {
3988            let y = vals[*a];
3989            let x = vals[*b];
3990            let d = y * y + x * x;
3991            let d2 = d * d;
3992            let fa = x / d;
3993            let fb = -y / d;
3994            let faa = -2.0 * x * y / d2;
3995            let fab = (y * y - x * x) / d2;
3996            let fbb = 2.0 * x * y / d2;
3997            adj[*a] += w * fa;
3998            adj[*b] += w * fb;
3999            adj_dot[*a] += wd * fa + w * (faa * dot[*a] + fab * dot[*b]);
4000            adj_dot[*b] += wd * fb + w * (fab * dot[*a] + fbb * dot[*b]);
4001        }
4002        TapeOp::Cmp(_, _, _)
4003        | TapeOp::And(_, _)
4004        | TapeOp::Or(_, _)
4005        | TapeOp::Not(_)
4006        | TapeOp::Select(_, _, _)
4007        | TapeOp::Min(_, _)
4008        | TapeOp::Max(_, _) => panic!(
4009            "GlobalTape free-function kernels do not implement conditional / logical \
4010             / min-max TapeOps; use the Tape (build_with_externals) interpreter path \
4011             instead."
4012        ),
4013        TapeOp::Funcall(fc) => {
4014            let FuncallData { lib, name, args } = fc.as_ref();
4015            let call_args = funcall_to_ext_args(args, vals);
4016            let res = lib.eval(name, &call_args, true, true).unwrap_or_else(|e| {
4017                panic!("external function '{name}' 2nd-order eval failed: {e}")
4018            });
4019            let derivs = res.derivs.expect("want_derivs=true returns derivs");
4020            let hes = res.hessian.expect("want_hes=true returns hessian");
4021            let real_tape: Vec<usize> = args
4022                .iter()
4023                .filter_map(|a| match a {
4024                    TapeFuncallArg::Tape(t) => Some(*t),
4025                    TapeFuncallArg::Str(_) => None,
4026                })
4027                .collect();
4028            for (k, &tk) in real_tape.iter().enumerate() {
4029                adj[tk] += w * derivs[k];
4030                let mut second_term = 0.0;
4031                for (l, &tl) in real_tape.iter().enumerate() {
4032                    let (lo, hi) = if k <= l { (k, l) } else { (l, k) };
4033                    let h_kl = hes[lo + hi * (hi + 1) / 2];
4034                    second_term += h_kl * dot[tl];
4035                }
4036                adj_dot[tk] += wd * derivs[k] + w * second_term;
4037            }
4038            let _ = out;
4039            let _ = weight;
4040            let _ = i;
4041        }
4042    }
4043}
4044
4045/// Per-op Hessian-sparsity propagation. Same algorithm as
4046/// `Tape::hessian_sparsity` but as a free function so `GlobalTape`
4047/// can call it over its shared `ops` slice.
4048fn hessian_sparsity_impl(ops: &[TapeOp]) -> BTreeSet<(usize, usize)> {
4049    let n = ops.len();
4050    let mut var_sets: Vec<BTreeSet<usize>> = Vec::with_capacity(n);
4051    let mut pairs: BTreeSet<(usize, usize)> = BTreeSet::new();
4052
4053    let emit_cross =
4054        |s1: &BTreeSet<usize>, s2: &BTreeSet<usize>, pairs: &mut BTreeSet<(usize, usize)>| {
4055            for &v1 in s1 {
4056                for &v2 in s2 {
4057                    let (r, c) = if v1 >= v2 { (v1, v2) } else { (v2, v1) };
4058                    pairs.insert((r, c));
4059                }
4060            }
4061        };
4062    let emit_self = |s: &BTreeSet<usize>, pairs: &mut BTreeSet<(usize, usize)>| {
4063        let vars: Vec<usize> = s.iter().copied().collect();
4064        for (ai, &vi) in vars.iter().enumerate() {
4065            for &vj in &vars[..=ai] {
4066                let (r, c) = if vi >= vj { (vi, vj) } else { (vj, vi) };
4067                pairs.insert((r, c));
4068            }
4069        }
4070    };
4071
4072    for op in ops {
4073        let vset = match op {
4074            TapeOp::Const(_) => BTreeSet::new(),
4075            TapeOp::Var(j) => {
4076                let mut s = BTreeSet::new();
4077                s.insert(*j);
4078                s
4079            }
4080            TapeOp::Add(a, b) | TapeOp::Sub(a, b) => {
4081                var_sets[*a].union(&var_sets[*b]).copied().collect()
4082            }
4083            TapeOp::Neg(a) | TapeOp::Abs(a) => var_sets[*a].clone(),
4084            TapeOp::Mul(a, b) => {
4085                emit_cross(&var_sets[*a], &var_sets[*b], &mut pairs);
4086                var_sets[*a].union(&var_sets[*b]).copied().collect()
4087            }
4088            TapeOp::Div(a, b) => {
4089                emit_cross(&var_sets[*a], &var_sets[*b], &mut pairs);
4090                emit_self(&var_sets[*b], &mut pairs);
4091                var_sets[*a].union(&var_sets[*b]).copied().collect()
4092            }
4093            TapeOp::Pow(a, b) | TapeOp::Atan2(a, b) | TapeOp::CEntropy(a, b) => {
4094                let combined: BTreeSet<usize> =
4095                    var_sets[*a].union(&var_sets[*b]).copied().collect();
4096                emit_self(&combined, &mut pairs);
4097                combined
4098            }
4099            TapeOp::Sqrt(a)
4100            | TapeOp::Exp(a)
4101            | TapeOp::Log(a)
4102            | TapeOp::Log10(a)
4103            | TapeOp::Sin(a)
4104            | TapeOp::Cos(a)
4105            | TapeOp::Tan(a)
4106            | TapeOp::Atan(a)
4107            | TapeOp::Acos(a)
4108            | TapeOp::Sinh(a)
4109            | TapeOp::Cosh(a)
4110            | TapeOp::Tanh(a)
4111            | TapeOp::Asin(a)
4112            | TapeOp::Acosh(a)
4113            | TapeOp::Asinh(a)
4114            | TapeOp::Erf(a)
4115            | TapeOp::XLogX(a)
4116            | TapeOp::Atanh(a) => {
4117                emit_self(&var_sets[*a], &mut pairs);
4118                var_sets[*a].clone()
4119            }
4120            TapeOp::Funcall(fc) => {
4121                let args = &fc.args;
4122                let mut combined: BTreeSet<usize> = BTreeSet::new();
4123                for arg in args {
4124                    if let TapeFuncallArg::Tape(t) = arg {
4125                        for &vv in &var_sets[*t] {
4126                            combined.insert(vv);
4127                        }
4128                    }
4129                }
4130                emit_self(&combined, &mut pairs);
4131                combined
4132            }
4133            TapeOp::Cmp(_, _, _) | TapeOp::And(_, _) | TapeOp::Or(_, _) | TapeOp::Not(_) => {
4134                // Comparisons / logical ops have identically-zero derivative, so
4135                // they contribute no Hessian structure.
4136                BTreeSet::new()
4137            }
4138            TapeOp::Select(_, t, e) => {
4139                // Either branch may be active; the structural superset is the
4140                // union of both branches' variable sets.
4141                var_sets[*t].union(&var_sets[*e]).copied().collect()
4142            }
4143            TapeOp::Min(a, b) | TapeOp::Max(a, b) => {
4144                // min/max are piecewise linear: zero second derivative (no
4145                // pairs); dependence set is the union of both operands.
4146                var_sets[*a].union(&var_sets[*b]).copied().collect()
4147            }
4148        };
4149        var_sets.push(vset);
4150    }
4151    pairs
4152}
4153
4154#[cfg(test)]
4155mod tests {
4156    use super::*;
4157
4158    fn cnst(c: f64) -> Expr {
4159        Expr::Const(c)
4160    }
4161    fn var(i: usize) -> Expr {
4162        Expr::Var(i)
4163    }
4164    fn add(a: Expr, b: Expr) -> Expr {
4165        Expr::Binary(BinOp::Add, Box::new(a), Box::new(b))
4166    }
4167    fn mul(a: Expr, b: Expr) -> Expr {
4168        Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b))
4169    }
4170    fn pow(a: Expr, b: Expr) -> Expr {
4171        Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b))
4172    }
4173    fn div(a: Expr, b: Expr) -> Expr {
4174        Expr::Binary(BinOp::Div, Box::new(a), Box::new(b))
4175    }
4176    fn unary(op: UnaryOp, a: Expr) -> Expr {
4177        Expr::Unary(op, Box::new(a))
4178    }
4179    fn cmp(op: CmpOp, a: Expr, b: Expr) -> Expr {
4180        Expr::Compare(op, Box::new(a), Box::new(b))
4181    }
4182    fn cond(c: Expr, t: Expr, e: Expr) -> Expr {
4183        Expr::Cond {
4184            cond: Box::new(c),
4185            then_: Box::new(t),
4186            else_: Box::new(e),
4187        }
4188    }
4189
4190    #[test]
4191    fn polynomial_eval_and_grad() {
4192        // f = 3*x0^2 + 2*x1
4193        let e = add(
4194            mul(cnst(3.0), pow(var(0), cnst(2.0))),
4195            mul(cnst(2.0), var(1)),
4196        );
4197        let t = Tape::build(&e);
4198        assert!((t.eval(&[2.0, 3.0]) - 18.0).abs() < 1e-12);
4199        let mut g = vec![0.0; 2];
4200        t.gradient_seed(&[2.0, 3.0], 1.0, &mut g);
4201        // df/dx0 = 6*x0 = 12, df/dx1 = 2
4202        assert!((g[0] - 12.0).abs() < 1e-12);
4203        assert!((g[1] - 2.0).abs() < 1e-12);
4204    }
4205
4206    #[test]
4207    fn cse_shared_body_evaluated_once() {
4208        // body = x0 + x1, shared via Arc. f = body^2 + body.
4209        let body = Arc::new(add(var(0), var(1)));
4210        let e = add(
4211            pow(Expr::Cse(body.clone()), cnst(2.0)),
4212            Expr::Cse(body.clone()),
4213        );
4214        let t = Tape::build(&e);
4215        // body should appear once in the tape: count Add(Var(0),Var(1)) ops
4216        let n_body_adds = t
4217            .ops
4218            .iter()
4219            .filter(|op| {
4220                matches!(op, TapeOp::Add(a, b) if {
4221                    matches!(t.ops[*a], TapeOp::Var(0)) && matches!(t.ops[*b], TapeOp::Var(1))
4222                })
4223            })
4224            .count();
4225        assert_eq!(n_body_adds, 1, "CSE body should be emitted exactly once");
4226
4227        // f(1, 2) = 9 + 3 = 12
4228        assert!((t.eval(&[1.0, 2.0]) - 12.0).abs() < 1e-12);
4229        let mut g = vec![0.0; 2];
4230        t.gradient_seed(&[1.0, 2.0], 1.0, &mut g);
4231        // df/dx0 = 2*(x0+x1) + 1 = 7, same for x1
4232        assert!((g[0] - 7.0).abs() < 1e-12);
4233        assert!((g[1] - 7.0).abs() < 1e-12);
4234    }
4235
4236    fn fd_check(tape: &Tape, x: &[f64], n: usize, tol: f64) {
4237        let vars = tape.variables();
4238        let mut hess_map: HashMap<(usize, usize), usize> = HashMap::new();
4239        let mut pairs = Vec::new();
4240        for (ai, &vi) in vars.iter().enumerate() {
4241            for &vj in &vars[..=ai] {
4242                let (r, c) = if vi >= vj { (vi, vj) } else { (vj, vi) };
4243                hess_map.entry((r, c)).or_insert_with(|| {
4244                    let p = pairs.len();
4245                    pairs.push((r, c));
4246                    p
4247                });
4248            }
4249        }
4250        let nnz = pairs.len();
4251        let mut ad = vec![0.0; nnz];
4252        tape.hessian_accumulate(x, 1.0, &hess_map, &mut ad);
4253
4254        let mut fd = vec![0.0; nnz];
4255        let mut xp = x.to_vec();
4256        let mut gp = vec![0.0; n];
4257        let mut gm = vec![0.0; n];
4258        for &j in &vars {
4259            let h = (1e-7_f64).max(x[j].abs() * 1e-7);
4260            xp[j] = x[j] + h;
4261            gp.iter_mut().for_each(|v| *v = 0.0);
4262            tape.gradient_seed(&xp, 1.0, &mut gp);
4263            xp[j] = x[j] - h;
4264            gm.iter_mut().for_each(|v| *v = 0.0);
4265            tape.gradient_seed(&xp, 1.0, &mut gm);
4266            xp[j] = x[j];
4267            for &i in &vars {
4268                if i >= j {
4269                    if let Some(&pos) = hess_map.get(&(i, j)) {
4270                        fd[pos] = (gp[i] - gm[i]) / (2.0 * h);
4271                    }
4272                }
4273            }
4274        }
4275        for (k, &(r, c)) in pairs.iter().enumerate() {
4276            let scale = fd[k].abs().max(1.0);
4277            assert!(
4278                (ad[k] - fd[k]).abs() / scale < tol,
4279                "H[{},{}]: AD={:.6e} FD={:.6e}",
4280                r,
4281                c,
4282                ad[k],
4283                fd[k]
4284            );
4285        }
4286    }
4287
4288    #[test]
4289    fn hessian_quadratic_matches_fd() {
4290        // f = 3 x0^2 + 2 x0 x1 + x1^2
4291        let e = add(
4292            add(
4293                mul(cnst(3.0), pow(var(0), cnst(2.0))),
4294                mul(cnst(2.0), mul(var(0), var(1))),
4295            ),
4296            pow(var(1), cnst(2.0)),
4297        );
4298        let t = Tape::build(&e);
4299        fd_check(&t, &[2.0, 3.0], 2, 1e-5);
4300    }
4301
4302    #[test]
4303    fn hessian_transcendental_matches_fd() {
4304        // f = exp(x0) + sin(x1) + log(x0) + sqrt(x1) + x0*x1
4305        let e = Expr::Sum(vec![
4306            unary(UnaryOp::Exp, var(0)),
4307            unary(UnaryOp::Sin, var(1)),
4308            unary(UnaryOp::Log, var(0)),
4309            unary(UnaryOp::Sqrt, var(1)),
4310            mul(var(0), var(1)),
4311        ]);
4312        let t = Tape::build(&e);
4313        fd_check(&t, &[1.5, 2.0], 2, 1e-5);
4314    }
4315
4316    #[test]
4317    fn inverse_trig_grad_and_hessian_match_fd() {
4318        // f = tan(x0) + atan(x1) + acos(x2) + x0*x1
4319        // Point chosen so every op is in its smooth domain:
4320        // tan away from pi/2, acos arg in (-1, 1).
4321        let e = Expr::Sum(vec![
4322            unary(UnaryOp::Tan, var(0)),
4323            unary(UnaryOp::Atan, var(1)),
4324            unary(UnaryOp::Acos, var(2)),
4325            mul(var(0), var(1)),
4326        ]);
4327        let t = Tape::build(&e);
4328        let x = [0.5, 1.3, 0.3];
4329
4330        // Gradient vs central finite difference of the value. This
4331        // pins the first derivatives independently of the Hessian
4332        // (fd_check only ties the Hessian to the AD gradient).
4333        let mut g = vec![0.0; 3];
4334        t.gradient_seed(&x, 1.0, &mut g);
4335        for j in 0..3 {
4336            let h = (1e-7_f64).max(x[j].abs() * 1e-7);
4337            let mut xp = x;
4338            let mut xm = x;
4339            xp[j] += h;
4340            xm[j] -= h;
4341            let fd = (t.eval(&xp) - t.eval(&xm)) / (2.0 * h);
4342            let scale = fd.abs().max(1.0);
4343            assert!(
4344                (g[j] - fd).abs() / scale < 1e-5,
4345                "grad[{j}]: AD={:.6e} FD={:.6e}",
4346                g[j],
4347                fd
4348            );
4349        }
4350
4351        // Hessian (forward-over-reverse) vs FD of the gradient.
4352        fd_check(&t, &x, 3, 1e-5);
4353    }
4354
4355    /// Shared helper: check AD gradient vs central FD of the value at
4356    /// `x`, then the Hessian via `fd_check`.
4357    fn grad_and_hess_match_fd(e: &Expr, x: &[f64], tol: f64) {
4358        let n = x.len();
4359        let t = Tape::build(e);
4360        let mut g = vec![0.0; n];
4361        t.gradient_seed(x, 1.0, &mut g);
4362        for j in 0..n {
4363            let h = (1e-7_f64).max(x[j].abs() * 1e-7);
4364            let mut xp = x.to_vec();
4365            let mut xm = x.to_vec();
4366            xp[j] += h;
4367            xm[j] -= h;
4368            let fd = (t.eval(&xp) - t.eval(&xm)) / (2.0 * h);
4369            let scale = fd.abs().max(1.0);
4370            assert!(
4371                (g[j] - fd).abs() / scale < tol,
4372                "grad[{j}]: AD={:.6e} FD={:.6e}",
4373                g[j],
4374                fd
4375            );
4376        }
4377        fd_check(&t, x, n, tol);
4378    }
4379
4380    #[test]
4381    fn hyperbolic_grad_and_hessian_match_fd() {
4382        // f = sinh(x0) + cosh(x1) + tanh(x2) + asinh(x3) + x0*x1 + x2*x3
4383        // sinh/cosh/tanh/asinh are smooth on all of R.
4384        let e = Expr::Sum(vec![
4385            unary(UnaryOp::Sinh, var(0)),
4386            unary(UnaryOp::Cosh, var(1)),
4387            unary(UnaryOp::Tanh, var(2)),
4388            unary(UnaryOp::Asinh, var(3)),
4389            mul(var(0), var(1)),
4390            mul(var(2), var(3)),
4391        ]);
4392        grad_and_hess_match_fd(&e, &[0.5, 0.7, 0.3, 1.1], 1e-5);
4393    }
4394
4395    #[test]
4396    fn restricted_inverse_grad_and_hessian_match_fd() {
4397        // f = asin(x0) + acosh(x1) + atanh(x2) + x0*x2
4398        // Point chosen in each op's smooth domain:
4399        // asin/atanh need |arg| < 1; acosh needs arg > 1.
4400        let e = Expr::Sum(vec![
4401            unary(UnaryOp::Asin, var(0)),
4402            unary(UnaryOp::Acosh, var(1)),
4403            unary(UnaryOp::Atanh, var(2)),
4404            mul(var(0), var(2)),
4405        ]);
4406        grad_and_hess_match_fd(&e, &[0.4, 1.8, 0.3], 1e-5);
4407    }
4408
4409    #[test]
4410    fn erf_value_matches_reference() {
4411        // Reference values from the standard erf (musl / C99), to full
4412        // double precision. Pinning these guards the `libm` delegation:
4413        // a swap to a series approximation would fail here long before it
4414        // showed up as a mysteriously loose KKT residual.
4415        let t = Tape::build(&unary(UnaryOp::Erf, var(0)));
4416        for (x, want) in [
4417            (0.0, 0.0),
4418            (0.5, 0.520_499_877_813_046_5),
4419            (1.0, 0.842_700_792_949_714_9),
4420            (-1.0, -0.842_700_792_949_714_9),
4421            (2.0, 0.995_322_265_018_952_7),
4422            (3.0, 0.999_977_909_503_001_4),
4423        ] {
4424            let got = t.eval(&[x]);
4425            assert!(
4426                (got - want).abs() < 1e-15,
4427                "erf({x}): got {got:.17e}, want {want:.17e}"
4428            );
4429        }
4430        // Odd and saturating: the two properties a wrong implementation
4431        // most often breaks.
4432        assert!((t.eval(&[-0.3]) + t.eval(&[0.3])).abs() < 1e-16);
4433        assert!((t.eval(&[10.0]) - 1.0).abs() < 1e-15);
4434    }
4435
4436    #[test]
4437    fn erf_second_derivative_stays_finite_at_extreme_magnitudes() {
4438        // `erf_d1` underflows to 0 around |u| > 27, and `-2.0 * u`
4439        // overflows to ±inf past f64::MAX/2. Written as `(-2u)·erf'(u)`
4440        // the two meet as `inf * 0.0 = NaN`, where the true limit is 0.
4441        // Parenthesizing as `-2·(u·erf'(u))` keeps every finite input
4442        // finite. Nothing in a well-posed model reaches 1e308 — `.nl`'s
4443        // unbounded sentinel is 1e19 — but the Python binding now takes an
4444        // unguarded `x`, and a NaN Hessian entry poisons a whole factorization.
4445        for u in [1e19, 1e150, 1e300, f64::MAX, f64::MAX / 2.0] {
4446            for signed in [u, -u] {
4447                let d2 = erf_d2(signed);
4448                assert!(
4449                    d2.is_finite(),
4450                    "erf_d2({signed:e}) = {d2} — must be finite (the limit is 0)"
4451                );
4452            }
4453        }
4454        // Still correct where it matters: -2u·erf'(u) at a normal point.
4455        let u = 0.7;
4456        let want = -2.0 * u * (2.0 / std::f64::consts::PI.sqrt()) * (-u * u).exp();
4457        assert!((erf_d2(u) - want).abs() < 1e-15, "{}", erf_d2(u));
4458    }
4459
4460    #[test]
4461    fn quotient_rule_keeps_representable_derivatives_at_extreme_denominators() {
4462        // The quotient rule used to form `b*b` explicitly. That overflows for
4463        // |b| > 1.3e154 and underflows below 1.5e-154, and what it destroys is a
4464        // derivative that is itself perfectly representable: at a = b = 1e300,
4465        // ∂(a/b)/∂b = -a/b² = -1e-300, but `b*b` is inf so the squared form
4466        // returned -0.0. A silently ZEROED gradient entry, not a NaN anyone
4467        // would catch downstream.
4468        //
4469        // Reachable in practice: discopt lowers GAMS `centropy(x, y)` to
4470        // `x·log(x/y)`, and a 600-digit audit measured a wrong y-derivative at
4471        // (1e300, 1e300) through exactly this path.
4472        let t = Tape::build(&div(var(0), var(1)));
4473        let mut g = vec![0.0; 2];
4474        for b in [1e300_f64, 1e200, 1e160, -1e300, 1e-160, 1e-300] {
4475            // At a = b: ∂/∂a = 1/b and ∂/∂b = -a/b² = -1/b, both representable
4476            // for every b here even though b² is not.
4477            // `gradient_seed` ACCUMULATES; without this reset the previous
4478            // iteration's entry survives and the assert compares against stale
4479            // data (it did, and caught itself: 1e-160 leaked into the b=-1e300 row).
4480            g.iter_mut().for_each(|v| *v = 0.0);
4481            t.gradient_seed(&[b, b], 1.0, &mut g);
4482            assert_eq!(g[0], 1.0 / b, "d(a/b)/da at a=b={b:e}");
4483            assert_eq!(
4484                g[1],
4485                -1.0 / b,
4486                "d(a/b)/db at a=b={b:e} — b² is not representable"
4487            );
4488        }
4489
4490        // Second order, where the rule squared and CUBED the denominator.
4491        // b = 1e155 makes b² = 1e310 overflow while the cross partial
4492        // ∂²(a/b)/∂a∂b = -1/b² = -1e-310 is still (subnormally) representable.
4493        let b = 1e155_f64;
4494        let mut hess_map: HashMap<(usize, usize), usize> = HashMap::new();
4495        hess_map.insert((0, 0), 0);
4496        hess_map.insert((1, 0), 1);
4497        hess_map.insert((1, 1), 2);
4498        let mut ad = vec![0.0; 3];
4499        t.hessian_accumulate(&[b, b], 1.0, &hess_map, &mut ad);
4500        // Compute the truth as -(1/b)/b, NOT -1/(b*b) -- the latter is the very
4501        // expression under test and evaluates to -0.0 here, which would have made
4502        // this assertion compare the bug against itself and pass vacuously.
4503        let want = -(1.0 / b) / b;
4504        assert!(
4505            want != 0.0 && want.is_finite(),
4506            "test premise: -1/b² must be representable, got {want:e}"
4507        );
4508        assert!(
4509            (ad[1] - want).abs() <= 1e-10 * want.abs(),
4510            "d²(a/b)/da db at a=b={b:e}: got {:e}, want {want:e}",
4511            ad[1]
4512        );
4513    }
4514
4515    #[test]
4516    fn erf_grad_and_hessian_match_fd() {
4517        // f = erf(x0) + erf(2*x1) + x0*x1. The inner `2*x1` makes the
4518        // chain rule non-trivial, so a missing factor in the tangent or
4519        // adjoint arm shows up rather than cancelling.
4520        let e = Expr::Sum(vec![
4521            unary(UnaryOp::Erf, var(0)),
4522            unary(UnaryOp::Erf, mul(cnst(2.0), var(1))),
4523            mul(var(0), var(1)),
4524        ]);
4525        grad_and_hess_match_fd(&e, &[0.4, -0.7], 1e-5);
4526    }
4527
4528    #[test]
4529    fn erf_directional_hessian_matches_accumulated() {
4530        // `hessian_directional` (the HVP / coloring sweep) and
4531        // `hessian_accumulate` (the sparse-entry sweep) are separate
4532        // match arms over the same op — the easiest place for an erf
4533        // second derivative to be right in one and wrong in the other.
4534        let e = Expr::Sum(vec![
4535            unary(UnaryOp::Erf, var(0)),
4536            unary(UnaryOp::Erf, mul(var(0), var(1))),
4537        ]);
4538        let tape = Tape::build(&e);
4539        let x = [0.6, -0.9];
4540        let n = x.len();
4541
4542        let pairs: Vec<(usize, usize)> = tape.hessian_sparsity().into_iter().collect();
4543        let hess_map: HashMap<(usize, usize), usize> =
4544            pairs.iter().enumerate().map(|(k, p)| (*p, k)).collect();
4545        let mut acc = vec![0.0; pairs.len()];
4546        tape.hessian_accumulate(&x, 1.0, &hess_map, &mut acc);
4547
4548        // One directional product per unit seed reproduces column j.
4549        let ops = tape.ops.len();
4550        let mut vals = vec![0.0; ops];
4551        tape.forward_into(&x, &mut vals);
4552        for j in 0..n {
4553            let mut seed = vec![0.0; n];
4554            seed[j] = 1.0;
4555            let mut col = vec![0.0; n];
4556            let (mut dot, mut adj, mut adj_dot) = (vec![0.0; ops], vec![0.0; ops], vec![0.0; ops]);
4557            tape.hessian_directional(
4558                &vals,
4559                &seed,
4560                1.0,
4561                &mut col,
4562                &mut dot,
4563                &mut adj,
4564                &mut adj_dot,
4565            );
4566            for i in 0..n {
4567                let (r, c) = if i >= j { (i, j) } else { (j, i) };
4568                let want = hess_map.get(&(r, c)).map_or(0.0, |&k| acc[k]);
4569                assert!(
4570                    (col[i] - want).abs() < 1e-12,
4571                    "H[{i},{j}]: directional={:.6e} accumulated={want:.6e}",
4572                    col[i]
4573                );
4574            }
4575        }
4576    }
4577
4578    fn centropy_expr(a: Expr, b: Expr) -> Expr {
4579        Expr::Binary(BinOp::CEntropy, Box::new(a), Box::new(b))
4580    }
4581
4582    /// Dense 2×2 Hessian via `hessian_accumulate`, as `(h00, h10, h11)`.
4583    fn hess2(e: &Expr, x: &[f64; 2]) -> (f64, f64, f64) {
4584        let tape = Tape::build(e);
4585        let mut hess_map: HashMap<(usize, usize), usize> = HashMap::new();
4586        hess_map.insert((0, 0), 0);
4587        hess_map.insert((1, 0), 1);
4588        hess_map.insert((1, 1), 2);
4589        let mut ad = vec![0.0; 3];
4590        tape.hessian_accumulate(x, 1.0, &hess_map, &mut ad);
4591        (ad[0], ad[1], ad[2])
4592    }
4593
4594    #[test]
4595    fn xlogx_and_centropy_grad_and_hessian_match_fd() {
4596        // f = xlogx(x0) + centropy(x0, x1) + x0*x1. The trailing product
4597        // makes the cross partial nonzero, so a dropped ∂²/∂a∂b term shows
4598        // up instead of hiding in an all-zero off-diagonal.
4599        let e = Expr::Sum(vec![
4600            unary(UnaryOp::XLogX, var(0)),
4601            centropy_expr(var(0), var(1)),
4602            mul(var(0), var(1)),
4603        ]);
4604        grad_and_hess_match_fd(&e, &[1.7, 0.6], 1e-5);
4605    }
4606
4607    #[test]
4608    fn centropy_directional_hessian_matches_accumulated() {
4609        // `hessian_directional` and `hessian_accumulate` are separate match
4610        // arms over the same op — the easiest place for a second derivative
4611        // to be right in one sweep and wrong in the other.
4612        let e = Expr::Sum(vec![
4613            centropy_expr(var(0), var(1)),
4614            unary(UnaryOp::XLogX, mul(var(0), var(1))),
4615        ]);
4616        let tape = Tape::build(&e);
4617        let x = [1.3, 0.8];
4618        let n = x.len();
4619
4620        let pairs: Vec<(usize, usize)> = tape.hessian_sparsity().into_iter().collect();
4621        let hess_map: HashMap<(usize, usize), usize> =
4622            pairs.iter().enumerate().map(|(k, p)| (*p, k)).collect();
4623        let mut acc = vec![0.0; pairs.len()];
4624        tape.hessian_accumulate(&x, 1.0, &hess_map, &mut acc);
4625
4626        let ops = tape.ops.len();
4627        let mut vals = vec![0.0; ops];
4628        tape.forward_into(&x, &mut vals);
4629        let mut compared = 0usize;
4630        for j in 0..n {
4631            let mut seed = vec![0.0; n];
4632            seed[j] = 1.0;
4633            let mut col = vec![0.0; n];
4634            let (mut dot, mut adj, mut adj_dot) = (vec![0.0; ops], vec![0.0; ops], vec![0.0; ops]);
4635            tape.hessian_directional(
4636                &vals,
4637                &seed,
4638                1.0,
4639                &mut col,
4640                &mut dot,
4641                &mut adj,
4642                &mut adj_dot,
4643            );
4644            for i in 0..n {
4645                let (r, c) = if i >= j { (i, j) } else { (j, i) };
4646                let want = hess_map.get(&(r, c)).map_or(0.0, |&k| acc[k]);
4647                assert!(
4648                    (col[i] - want).abs() < 1e-12,
4649                    "H[{i},{j}]: directional={:.6e} accumulated={want:.6e}",
4650                    col[i]
4651                );
4652                compared += 1;
4653            }
4654        }
4655        // §6: prove the probe fired. A tape whose sparsity came back empty
4656        // would sail through the loop above comparing 0.0 against 0.0.
4657        assert_eq!(compared, 4, "expected a full 2x2 comparison");
4658        assert!(
4659            acc.iter().any(|v| v.abs() > 1e-12),
4660            "every accumulated Hessian entry was zero — the arms under test never ran"
4661        );
4662    }
4663
4664    #[test]
4665    fn fused_entropy_ops_reach_derivatives_the_chain_rule_cannot() {
4666        // The reason these ops exist. `(a·ln a)'' = 1/a` is finite for every
4667        // positive a down to 1e-308, but EVERY decomposition of it routes
4668        // through `ln''(a) = -1/a²`, which leaves f64 range below a ≈ 1e-154.
4669        // The composite is in range while the factor it is built from is not,
4670        // so this is a structural limit of the chain rule, not a sloppy rule.
4671        let a = 1e-299_f64;
4672        let want = 1.0 / a; // 1e299 — an ordinary number.
4673        assert!(want.is_finite(), "test premise: 1/a must be representable");
4674
4675        let (fused, _, _) = hess2(&unary(UnaryOp::XLogX, var(0)), &[a, 0.0]);
4676        assert!(
4677            (fused - want).abs() <= 1e-12 * want,
4678            "xlogx''({a:e}): got {fused:e}, want {want:e}"
4679        );
4680
4681        // The control: the same function written as `a * log(a)`. Asserting it
4682        // is BROKEN is what proves the test is in the regime the fusion exists
4683        // for — without this, a passing `fused` assertion could just mean the
4684        // point was never extreme enough to matter.
4685        let (decomposed, _, _) = hess2(&mul(var(0), unary(UnaryOp::Log, var(0))), &[a, 0.0]);
4686        assert!(
4687            !decomposed.is_finite(),
4688            "test premise: a*log(a) is supposed to lose this second derivative, \
4689             but it returned {decomposed:e} — if the Log/Mul arms now reach it, \
4690             re-derive whether the fused op is still needed"
4691        );
4692
4693        // Same story for centropy's ∂²/∂a², which is also 1/a.
4694        let (fused_aa, _, _) = hess2(&centropy_expr(var(0), var(1)), &[a, 1.0]);
4695        assert!(
4696            (fused_aa - want).abs() <= 1e-12 * want,
4697            "centropy ∂²/∂a² at a={a:e}: got {fused_aa:e}, want {want:e}"
4698        );
4699    }
4700
4701    #[test]
4702    fn centropy_second_derivative_in_b_survives_a_squared_denominator() {
4703        // ∂²/∂b² = a/b². At (1e300, 1e200) the answer is 1e-100 while b² is
4704        // 1e400 — out of range. Evaluating it as (a/b)/b keeps it.
4705        let (a, b) = (1e300_f64, 1e200_f64);
4706        let want = (a / b) / b;
4707        // The squared form does not blow up loudly — `b*b` is inf and
4708        // `a/inf` is a silent 0.0, i.e. a ZEROED second derivative. Assert
4709        // that shape explicitly so the premise says what actually goes wrong.
4710        assert!(
4711            want.is_finite() && want != 0.0 && a / (b * b) == 0.0,
4712            "test premise: a/b² representable ({want:e}) but a/(b*b) collapses"
4713        );
4714        let (_, _, h11) = hess2(&centropy_expr(var(0), var(1)), &[a, b]);
4715        assert!(
4716            (h11 - want).abs() <= 1e-12 * want,
4717            "centropy ∂²/∂b² at ({a:e}, {b:e}): got {h11:e}, want {want:e}"
4718        );
4719    }
4720
4721    #[test]
4722    fn centropy_value_survives_an_out_of_range_ratio_and_the_zero_limit() {
4723        let t = |e: &Expr, x: &[f64]| Tape::build(e).eval(x);
4724        let ce = centropy_expr(var(0), var(1));
4725
4726        // a/b = 1e600 overflows, but ln of it is an ordinary 1381.55, so the
4727        // product is a perfectly representable 1.38e303. `ln_ratio` falls back
4728        // to ln(a) - ln(b) exactly here.
4729        let want = 1e300 * (1e300_f64.ln() - 1e-300_f64.ln());
4730        let got = t(&ce, &[1e300, 1e-300]);
4731        assert!(
4732            got.is_finite() && (got - want).abs() <= 1e-12 * want,
4733            "centropy(1e300, 1e-300): got {got:e}, want {want:e}"
4734        );
4735        // The naive form is what this replaces.
4736        assert!(
4737            !t(
4738                &mul(var(0), unary(UnaryOp::Log, div(var(0), var(1)))),
4739                &[1e300, 1e-300]
4740            )
4741            .is_finite(),
4742            "test premise: a*log(a/b) is supposed to overflow here"
4743        );
4744
4745        // Near a = b, where forming the ratio first throws away the digits
4746        // that carry the answer. At a = 1e10+1, b = 1e10 the quotient rounds
4747        // to 1 + 1e-10 with ~1e-16 absolute slop — 1e-6 RELATIVE slop in the
4748        // part that survives ln — while (a - b) is exactly 1.0 by Sterbenz.
4749        // Taylor: a·ln(1+1e-10) = 1 + 5e-11 - 1.7e-21, so 1.00000000005 is
4750        // the correctly-rounded f64 (the neglected term is ~1e-5 ulp).
4751        let (a, b) = (1e10 + 1.0, 1e10_f64);
4752        assert_eq!(a - b, 1.0, "test premise: a - b must be exact here");
4753        let got = t(&ce, &[a, b]);
4754        assert!(
4755            (got - 1.00000000005).abs() <= 2.3e-16,
4756            "centropy(1e10+1, 1e10): got {got:.17e}, want 1.00000000005"
4757        );
4758        // The naive form is off by 8e-8 here — eight digits, not one ulp.
4759        let naive = t(
4760            &mul(var(0), unary(UnaryOp::Log, div(var(0), var(1)))),
4761            &[a, b],
4762        );
4763        assert!(
4764            (naive - 1.00000000005).abs() > 1e-9,
4765            "test premise: a*log(a/b) is supposed to lose digits here, got {naive:.17e}"
4766        );
4767
4768        // 0·ln(0/b) = 0 by the limit, not NaN.
4769        assert_eq!(t(&ce, &[0.0, 2.0]), 0.0);
4770        assert_eq!(t(&unary(UnaryOp::XLogX, var(0)), &[0.0]), 0.0);
4771    }
4772
4773    #[test]
4774    fn cond_does_not_leak_a_non_finite_from_its_inactive_branch() {
4775        // Frontends clamp `entropy`/`centropy` with a Cond so the x -> 0+ limit
4776        // stays finite: `select(x < 1e-300, x*ln(1e-300), xlogx(x))`. The tape's
4777        // forward sweep evaluates EVERY slot, so at x = -1 the inactive
4778        // `xlogx(-1)` arm is NaN and its derivative arms are NaN too. Whether
4779        // that NaN reaches the caller is the difference between a usable clamp
4780        // and a poisoned Hessian row, and it is decided independently in three
4781        // sweeps — so check all three, not just the gradient.
4782        let cond = Expr::Compare(CmpOp::Lt, Box::new(var(0)), Box::new(cnst(1e-300)));
4783        let e = Expr::Cond {
4784            cond: Box::new(cond),
4785            then_: Box::new(mul(var(0), cnst(1e-300f64.ln()))),
4786            else_: Box::new(unary(UnaryOp::XLogX, var(0))),
4787        };
4788        let t = Tape::build(&e);
4789        let x = [-1.0_f64];
4790
4791        // Premise: the inactive arm really is NaN at this point, so the test is
4792        // exercising the leak it claims to rule out.
4793        assert!(
4794            Tape::build(&unary(UnaryOp::XLogX, var(0)))
4795                .eval(&x)
4796                .is_nan(),
4797            "test premise: xlogx(-1) must be NaN"
4798        );
4799
4800        assert_eq!(t.eval(&x), -1e-300_f64.ln());
4801
4802        let mut g = vec![0.0; 1];
4803        t.gradient_seed(&x, 1.0, &mut g);
4804        assert_eq!(g[0], 1e-300_f64.ln(), "gradient leaked the inactive branch");
4805
4806        let mut hess_map: HashMap<(usize, usize), usize> = HashMap::new();
4807        hess_map.insert((0, 0), 0);
4808        let mut acc = vec![0.0; 1];
4809        t.hessian_accumulate(&x, 1.0, &hess_map, &mut acc);
4810        assert_eq!(acc[0], 0.0, "hessian_accumulate leaked the inactive branch");
4811
4812        let ops = t.ops.len();
4813        let mut vals = vec![0.0; ops];
4814        t.forward_into(&x, &mut vals);
4815        let mut col = vec![0.0; 1];
4816        let (mut dot, mut adj, mut adj_dot) = (vec![0.0; ops], vec![0.0; ops], vec![0.0; ops]);
4817        t.hessian_directional(
4818            &vals,
4819            &[1.0],
4820            1.0,
4821            &mut col,
4822            &mut dot,
4823            &mut adj,
4824            &mut adj_dot,
4825        );
4826        assert_eq!(
4827            col[0], 0.0,
4828            "hessian_directional leaked the inactive branch"
4829        );
4830    }
4831
4832    #[test]
4833    fn atan2_grad_and_hessian_match_fd() {
4834        // f = atan2(x0, x1) + x0*x1, away from the origin.
4835        let atan2 = |a: Expr, b: Expr| Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b));
4836        let e = Expr::Sum(vec![atan2(var(0), var(1)), mul(var(0), var(1))]);
4837        grad_and_hess_match_fd(&e, &[1.2, 0.7], 1e-5);
4838    }
4839
4840    #[test]
4841    fn minmax_grad_and_hessian_match_fd() {
4842        // f = min(x0, x1, x2) + max(x1, x2) + x0*x2
4843        // Point chosen so each list has a UNIQUE strictly-active
4844        // operand, so the subgradient equals the FD slope (the ±h
4845        // probes never cross a kink):
4846        //   min(0.5, 3.0, 2.0) = 0.5  -> active x0
4847        //   max(3.0, 2.0)      = 3.0  -> active x1
4848        let e = Expr::Sum(vec![
4849            Expr::MinList(vec![var(0), var(1), var(2)]),
4850            Expr::MaxList(vec![var(1), var(2)]),
4851            mul(var(0), var(2)),
4852        ]);
4853        grad_and_hess_match_fd(&e, &[0.5, 3.0, 2.0], 1e-5);
4854    }
4855
4856    #[test]
4857    fn minmax_value_and_active_operand() {
4858        // Spot-check the value and that the gradient routes entirely
4859        // through the active operand (zero second derivative).
4860        let e = Expr::Sum(vec![
4861            Expr::MinList(vec![var(0), var(1)]),
4862            Expr::MaxList(vec![var(0), var(1)]),
4863        ]);
4864        let t = Tape::build(&e);
4865        // min(x0,x1) + max(x0,x1) == x0 + x1 for any inputs.
4866        let x = [1.3, -0.4];
4867        assert!((t.eval(&x) - (x[0] + x[1])).abs() < 1e-12);
4868        let mut g = vec![0.0; 2];
4869        t.gradient_seed(&x, 1.0, &mut g);
4870        // min active = x1 (smaller), max active = x0 (larger):
4871        // d/dx0 = 1 (from max), d/dx1 = 1 (from min).
4872        assert!((g[0] - 1.0).abs() < 1e-12, "g0={}", g[0]);
4873        assert!((g[1] - 1.0).abs() < 1e-12, "g1={}", g[1]);
4874    }
4875
4876    #[test]
4877    fn hessian_division_matches_fd() {
4878        // f = x0/x1 + cos(x0)
4879        let e = add(div(var(0), var(1)), unary(UnaryOp::Cos, var(0)));
4880        let t = Tape::build(&e);
4881        fd_check(&t, &[0.5, 1.2], 2, 1e-5);
4882    }
4883
4884    #[test]
4885    fn conditional_value_grad_hessian_active_branch() {
4886        // f = if x0 >= 1 then x0*x1 else x1^2
4887        // The if-then-else differentiates only the active branch; the
4888        // condition (a comparison) contributes no derivative.
4889        let e = cond(
4890            cmp(CmpOp::Ge, var(0), cnst(1.0)),
4891            mul(var(0), var(1)),
4892            pow(var(1), cnst(2.0)),
4893        );
4894        let t = Tape::build(&e);
4895
4896        // x0 = 2 (>= 1) -> "then" branch x0*x1 is active.
4897        let x = [2.0, 5.0];
4898        assert!((t.eval(&x) - 10.0).abs() < 1e-12);
4899        let mut g = vec![0.0; 2];
4900        t.gradient_seed(&x, 1.0, &mut g);
4901        // d(x0*x1) = (x1, x0) = (5, 2)
4902        assert!((g[0] - 5.0).abs() < 1e-10);
4903        assert!((g[1] - 2.0).abs() < 1e-10);
4904        // H[0,1] = 1, diagonals 0. (Stay clear of the x0 = 1 kink.)
4905        fd_check(&t, &x, 2, 1e-5);
4906
4907        // x0 = 0 (< 1) -> "else" branch x1^2 is active; x0 drops out.
4908        let x2 = [0.0, 5.0];
4909        assert!((t.eval(&x2) - 25.0).abs() < 1e-12);
4910        let mut g2 = vec![0.0; 2];
4911        t.gradient_seed(&x2, 1.0, &mut g2);
4912        assert!(g2[0].abs() < 1e-10);
4913        assert!((g2[1] - 10.0).abs() < 1e-10);
4914        fd_check(&t, &x2, 2, 1e-5);
4915    }
4916
4917    #[test]
4918    fn comparison_and_logical_have_zero_derivative() {
4919        // f = (x0 < x1) + (x0 > 0 && x1 > 0) + !(x0 == x1)
4920        // Every term is piecewise-constant in the variables, so the
4921        // gradient must be identically zero away from the kinks.
4922        let lt = cmp(CmpOp::Lt, var(0), var(1));
4923        let and = Expr::And(
4924            Box::new(cmp(CmpOp::Gt, var(0), cnst(0.0))),
4925            Box::new(cmp(CmpOp::Gt, var(1), cnst(0.0))),
4926        );
4927        let notc = Expr::Not(Box::new(cmp(CmpOp::Eq, var(0), var(1))));
4928        let e = add(add(lt, and), notc);
4929        let t = Tape::build(&e);
4930
4931        let x = [1.0, 2.0];
4932        // 1 (1<2) + 1 (both > 0) + 1 (1 != 2) = 3
4933        assert!((t.eval(&x) - 3.0).abs() < 1e-12);
4934        let mut g = vec![0.0; 2];
4935        t.gradient_seed(&x, 1.0, &mut g);
4936        assert!(g[0].abs() < 1e-12, "d/dx0 should be 0, got {}", g[0]);
4937        assert!(g[1].abs() < 1e-12, "d/dx1 should be 0, got {}", g[1]);
4938    }
4939
4940    #[test]
4941    fn logical_or_value() {
4942        // f = (x0 > 0 || x1 > 0)
4943        let e = Expr::Or(
4944            Box::new(cmp(CmpOp::Gt, var(0), cnst(0.0))),
4945            Box::new(cmp(CmpOp::Gt, var(1), cnst(0.0))),
4946        );
4947        let t = Tape::build(&e);
4948        assert!((t.eval(&[-1.0, 3.0]) - 1.0).abs() < 1e-12);
4949        assert!((t.eval(&[-1.0, -3.0]) - 0.0).abs() < 1e-12);
4950    }
4951
4952    /// `hessian_directional` (one forward-over-reverse pass with
4953    /// a seed vector) recovers `H · e_j` for each unit-vector seed,
4954    /// matching column `j` of the dense Hessian computed by
4955    /// `hessian_accumulate`.
4956    fn directional_matches_accumulate(tape: &Tape, x: &[f64], n: usize) {
4957        let vars = tape.variables();
4958        let mut hess_map: HashMap<(usize, usize), usize> = HashMap::new();
4959        let mut pairs = Vec::new();
4960        for (ai, &vi) in vars.iter().enumerate() {
4961            for &vj in &vars[..=ai] {
4962                let (r, c) = if vi >= vj { (vi, vj) } else { (vj, vi) };
4963                hess_map.entry((r, c)).or_insert_with(|| {
4964                    let p = pairs.len();
4965                    pairs.push((r, c));
4966                    p
4967                });
4968            }
4969        }
4970        let nnz = pairs.len();
4971        let mut ad = vec![0.0; nnz];
4972        tape.hessian_accumulate(x, 1.0, &hess_map, &mut ad);
4973
4974        let nops = tape.ops.len();
4975        let mut vals = vec![0.0; nops];
4976        tape.forward_into(x, &mut vals);
4977        let mut dot = vec![0.0; nops];
4978        let mut adj = vec![0.0; nops];
4979        let mut adj_dot = vec![0.0; nops];
4980
4981        for &j in &vars {
4982            let mut seed = vec![0.0; n];
4983            seed[j] = 1.0;
4984            let mut col = vec![0.0; n];
4985            tape.hessian_directional(
4986                &vals,
4987                &seed,
4988                1.0,
4989                &mut col,
4990                &mut dot,
4991                &mut adj,
4992                &mut adj_dot,
4993            );
4994            for &i in &vars {
4995                let (r, c) = if i >= j { (i, j) } else { (j, i) };
4996                let expect = ad[hess_map[&(r, c)]];
4997                assert!(
4998                    (col[i] - expect).abs() < 1e-10,
4999                    "directional H[{i},{j}] = {} vs accumulate {}",
5000                    col[i],
5001                    expect
5002                );
5003            }
5004        }
5005    }
5006
5007    #[test]
5008    fn directional_quadratic_matches_accumulate() {
5009        // f = 3 x0^2 + 2 x0 x1 + x1^2
5010        let e = add(
5011            add(
5012                mul(cnst(3.0), pow(var(0), cnst(2.0))),
5013                mul(mul(cnst(2.0), var(0)), var(1)),
5014            ),
5015            pow(var(1), cnst(2.0)),
5016        );
5017        let t = Tape::build(&e);
5018        directional_matches_accumulate(&t, &[0.5, -0.3], 2);
5019    }
5020
5021    #[test]
5022    fn directional_transcendental_matches_accumulate() {
5023        let e = Expr::Sum(vec![
5024            unary(UnaryOp::Exp, var(0)),
5025            unary(UnaryOp::Sin, var(1)),
5026            unary(UnaryOp::Log, var(0)),
5027            unary(UnaryOp::Sqrt, var(1)),
5028            mul(var(0), var(1)),
5029        ]);
5030        let t = Tape::build(&e);
5031        directional_matches_accumulate(&t, &[1.5, 2.0], 2);
5032    }
5033
5034    #[test]
5035    fn directional_with_division_matches_accumulate() {
5036        let e = add(div(var(0), var(1)), unary(UnaryOp::Cos, var(0)));
5037        let t = Tape::build(&e);
5038        directional_matches_accumulate(&t, &[0.5, 1.2], 2);
5039    }
5040
5041    #[test]
5042    fn hessian_sparsity_separable() {
5043        // f = sin(x0) + x1*x2; couplings: (0,0) from sin, (2,1) from x1*x2
5044        let e = add(unary(UnaryOp::Sin, var(0)), mul(var(1), var(2)));
5045        let t = Tape::build(&e);
5046        let s = t.hessian_sparsity();
5047        assert!(s.contains(&(0, 0)));
5048        assert!(s.contains(&(2, 1)));
5049        assert!(!s.contains(&(1, 0)));
5050        assert!(!s.contains(&(2, 0)));
5051    }
5052
5053    fn count_op<F: Fn(&TapeOp) -> bool>(t: &Tape, pred: F) -> usize {
5054        t.ops.iter().filter(|o| pred(o)).count()
5055    }
5056
5057    #[test]
5058    fn pow_zero_const_folds_to_one() {
5059        // x^0 → 1 (no Pow, no reference to x in the tape)
5060        let e = pow(var(0), cnst(0.0));
5061        let t = Tape::build(&e);
5062        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 0);
5063        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Var(_))), 0);
5064        assert!((t.eval(&[7.0]) - 1.0).abs() < 1e-12);
5065    }
5066
5067    #[test]
5068    fn pow_one_passes_through() {
5069        // x^1 → x (no Pow, no Const introduced for the exponent)
5070        let e = pow(var(0), cnst(1.0));
5071        let t = Tape::build(&e);
5072        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 0);
5073        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Const(_))), 0);
5074        assert!((t.eval(&[3.5]) - 3.5).abs() < 1e-12);
5075    }
5076
5077    #[test]
5078    fn pow_half_lowers_to_sqrt() {
5079        let e = pow(var(0), cnst(0.5));
5080        let t = Tape::build(&e);
5081        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 0);
5082        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Sqrt(_))), 1);
5083        assert!((t.eval(&[16.0]) - 4.0).abs() < 1e-12);
5084    }
5085
5086    #[test]
5087    fn pow_two_lowers_to_single_mul() {
5088        let e = pow(var(0), cnst(2.0));
5089        let t = Tape::build(&e);
5090        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 0);
5091        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Mul(..))), 1);
5092        assert!((t.eval(&[3.0]) - 9.0).abs() < 1e-12);
5093    }
5094
5095    #[test]
5096    fn pow_three_lowers_to_two_muls() {
5097        let e = pow(var(0), cnst(3.0));
5098        let t = Tape::build(&e);
5099        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 0);
5100        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Mul(..))), 2);
5101        assert!((t.eval(&[2.0]) - 8.0).abs() < 1e-12);
5102    }
5103
5104    #[test]
5105    fn pow_eight_lowers_to_three_muls() {
5106        // Binary expansion: x → x² → x⁴ → x⁸ (3 squarings)
5107        let e = pow(var(0), cnst(8.0));
5108        let t = Tape::build(&e);
5109        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 0);
5110        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Mul(..))), 3);
5111        assert!((t.eval(&[2.0]) - 256.0).abs() < 1e-12);
5112    }
5113
5114    #[test]
5115    fn pow_negative_two_lowers_to_div() {
5116        // x^-2 → 1 / (x*x)
5117        let e = pow(var(0), cnst(-2.0));
5118        let t = Tape::build(&e);
5119        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 0);
5120        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Div(..))), 1);
5121        assert!((t.eval(&[4.0]) - (1.0 / 16.0)).abs() < 1e-12);
5122    }
5123
5124    #[test]
5125    fn pow_large_const_stays_generic() {
5126        // x^9 stays as Pow — beyond the cutoff, generic is cheaper.
5127        let e = pow(var(0), cnst(9.0));
5128        let t = Tape::build(&e);
5129        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 1);
5130    }
5131
5132    #[test]
5133    fn pow_non_integer_const_stays_generic() {
5134        // x^1.5 stays as Pow until half-integer handling is added.
5135        let e = pow(var(0), cnst(1.5));
5136        let t = Tape::build(&e);
5137        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 1);
5138    }
5139
5140    #[test]
5141    fn pow_const_through_cse_const() {
5142        // Exponent wrapped in Cse — peek_const should still see it.
5143        let two = Arc::new(cnst(2.0));
5144        let e = Expr::Binary(BinOp::Pow, Box::new(var(0)), Box::new(Expr::Cse(two)));
5145        let t = Tape::build(&e);
5146        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Pow(..))), 0);
5147        assert_eq!(count_op(&t, |o| matches!(o, TapeOp::Mul(..))), 1);
5148    }
5149
5150    #[test]
5151    fn hessian_pow_three_matches_fd() {
5152        // f = 5 * x0^3 + x0 * x1 — exercises the lowered cubic + cross term.
5153        let e = add(mul(cnst(5.0), pow(var(0), cnst(3.0))), mul(var(0), var(1)));
5154        let t = Tape::build(&e);
5155        fd_check(&t, &[1.7, 0.8], 2, 1e-5);
5156    }
5157
5158    #[test]
5159    fn hessian_pow_negative_matches_fd() {
5160        // f = 1/x0^2 + x1^2 — exercises lowered x^-2 and x^2.
5161        let e = add(pow(var(0), cnst(-2.0)), pow(var(1), cnst(2.0)));
5162        let t = Tape::build(&e);
5163        fd_check(&t, &[1.3, 2.4], 2, 1e-5);
5164    }
5165
5166    #[test]
5167    fn hessian_pow_half_matches_fd() {
5168        // f = sqrt(x0) + x0*x1 (via Pow(_, 0.5) → Sqrt)
5169        let e = add(pow(var(0), cnst(0.5)), mul(var(0), var(1)));
5170        let t = Tape::build(&e);
5171        fd_check(&t, &[2.5, 1.1], 2, 1e-5);
5172    }
5173
5174    #[test]
5175    fn hessian_sparsity_through_cse() {
5176        // body = x0+x1 (CSE). f = body^2 + body.
5177        // d²/dx² of body^2 couples (0,0), (1,0), (1,1).
5178        let body = Arc::new(add(var(0), var(1)));
5179        let e = add(
5180            pow(Expr::Cse(body.clone()), cnst(2.0)),
5181            Expr::Cse(body.clone()),
5182        );
5183        let t = Tape::build(&e);
5184        let s = t.hessian_sparsity();
5185        assert!(s.contains(&(0, 0)));
5186        assert!(s.contains(&(1, 0)));
5187        assert!(s.contains(&(1, 1)));
5188        assert_eq!(s.len(), 3);
5189    }
5190
5191    #[test]
5192    fn pow_forward_tangent_matches_reverse_gradient_at_base_zero() {
5193        // Code review L29: `Pow` first-order tangent disagreed with the
5194        // reverse-mode gradient at base 0. f = x0 ^ x1 keeps a genuine `Pow`
5195        // op (variable exponent is not lowered to a Mul/Sqrt chain). At the
5196        // `.nl` default start x0 = 0, the base derivative d/dx0 (x0^1) = 1 is
5197        // well defined; reverse mode has always computed it, but the forward
5198        // tangent used to guard on `u != 0` and drop it, so Jacobian-vector
5199        // products silently disagreed with the gradient at x = 0. After the
5200        // fix both arms must agree.
5201        let e = pow(var(0), var(1));
5202        let t = Tape::build(&e);
5203        // Guard: the op must survive as a real Pow (not lowered away), else
5204        // this test would no longer exercise the fixed branch.
5205        assert!(
5206            t.ops.iter().any(|op| matches!(op, TapeOp::Pow(_, _))),
5207            "expected a Pow op in the tape; got {:?}",
5208            t.ops
5209        );
5210        let x = [0.0, 1.0];
5211        let n = t.ops.len();
5212
5213        // Reverse-mode gradient w.r.t. x0.
5214        let mut grad = vec![0.0; 2];
5215        t.gradient_seed(&x, 1.0, &mut grad);
5216
5217        // Forward tangent seeded on x0: dot[output] = df/dx0.
5218        let vals = t.forward(&x);
5219        let mut dot = vec![0.0; n];
5220        t.forward_tangent(&vals, 0, &mut dot);
5221        let fwd_dfx0 = dot[n - 1];
5222
5223        assert!(
5224            (grad[0] - 1.0).abs() < 1e-12,
5225            "reverse gradient df/dx0 at base 0 should be 1, got {}",
5226            grad[0]
5227        );
5228        assert!(
5229            (fwd_dfx0 - grad[0]).abs() < 1e-12,
5230            "forward tangent df/dx0 = {fwd_dfx0} must match reverse gradient {} at base 0",
5231            grad[0]
5232        );
5233    }
5234
5235    #[test]
5236    #[should_panic(expected = "external function calls are not supported on the")]
5237    fn hybrid_promoted_cse_with_funcall_reports_clear_message() {
5238        // Code review L34: `HybridTape::build_multi` builds a promoted CSE
5239        // (one shared across ≥2 summands) via `build_recursive` with an empty
5240        // resolver. A funcall inside that promoted body used to panic with the
5241        // misleading "unresolved AMPL funcall id 0" — implying a resolution
5242        // failure — instead of the real reason: funcalls are unsupported on
5243        // the hybrid path. Here the funcall body is shared across two roots so
5244        // it is promoted; assert the clear hybrid-unsupported message fires.
5245        let body = Arc::new(Expr::Funcall {
5246            id: 0,
5247            args: vec![FuncallArg::Real(var(0))],
5248        });
5249        let exprs = vec![
5250            add(Expr::Cse(body.clone()), cnst(1.0)),
5251            add(Expr::Cse(body.clone()), cnst(2.0)),
5252        ];
5253        HybridTape::build_multi(&exprs);
5254    }
5255
5256    /// The single-shooting chain `e_{i+1} = e_i + 0.1*(x_i - e_i*e_i)` as
5257    /// `build_nl_problem` hands it over (gh#1066): each `e_i` one shared
5258    /// `Cse` body, the top level split into two summands so the bodies
5259    /// are promoted. `leaf` is the innermost operand.
5260    fn shooting_chain_roots(k: usize, leaf: Expr) -> Vec<Expr> {
5261        let step = |e: &Expr, i: usize| {
5262            let sq = mul(e.clone(), e.clone());
5263            mul(cnst(0.1), add(var(i), mul(cnst(-1.0), sq)))
5264        };
5265        let mut e = leaf;
5266        for i in 0..k - 1 {
5267            e = Expr::Cse(Arc::new(add(e.clone(), step(&e, i))));
5268        }
5269        let last = step(&e, k - 1);
5270        vec![e, last]
5271    }
5272
5273    /// gh#1066: the promoted-CSE funcall pre-scan walked every path through
5274    /// the shared bodies, `2^K` of them on this chain — K = 20 took 17 s
5275    /// and K = 40 never returned. It is linear in the DAG now, so K = 64
5276    /// builds at once (an exponential walk would need ~`3^64` visits).
5277    #[test]
5278    fn hybrid_build_of_a_shared_square_chain_is_linear() {
5279        let k = 64;
5280        let roots = shooting_chain_roots(k, cnst(0.5));
5281        let tape = HybridTape::build_multi(&roots);
5282        // The value agrees with the flat tape of the whole expression.
5283        let x: Vec<f64> = (0..k).map(|i| 0.01 * i as f64).collect();
5284        let flat = Tape::build(&add(roots[0].clone(), roots[1].clone())).eval(&x);
5285        let mut prelude = vec![0.0; tape.n_prelude_ops()];
5286        tape.forward_prelude(&x, &mut prelude);
5287        let mut local = vec![0.0; tape.max_summand_ops()];
5288        let mut hybrid = 0.0;
5289        for s in &tape.summands {
5290            tape.forward_summand(s, &x, &prelude, &mut local);
5291            hybrid += tape.root_value(s, &local);
5292        }
5293        assert!(
5294            (hybrid - flat).abs() <= 1e-12 * flat.abs().max(1.0),
5295            "{hybrid} vs {flat}"
5296        );
5297    }
5298
5299    /// The linear scan must still see a funcall at the bottom of the same
5300    /// chain: stopping at already-built bodies and at revisits may skip
5301    /// work, never a body that was not checked.
5302    #[test]
5303    #[should_panic(expected = "external function calls are not supported on the")]
5304    fn hybrid_funcall_under_a_shared_square_chain_still_reports() {
5305        let leaf = Expr::Funcall {
5306            id: 0,
5307            args: vec![FuncallArg::Real(var(0))],
5308        };
5309        HybridTape::build_multi(&shooting_chain_roots(64, leaf));
5310    }
5311
5312    /// The directional hybrid Hessian (issue #557) against the flat
5313    /// [`Tape::hessian_directional`] it replaces: three summands sharing one
5314    /// transcendental CSE body, each with its own multiplier, swept with a
5315    /// dense two-variable seed. The per-summand *local* contributions are the
5316    /// exact arithmetic of the flat tape; the shared-prelude contribution
5317    /// folds the multipliers into the boundary adjoints and runs one unit-
5318    /// weight prelude sweep, which reassociates the floating-point products —
5319    /// mathematically identical by linearity of reverse-over-tangent in the
5320    /// adjoint seeds, so the comparison is at near-machine tolerance rather
5321    /// than bitwise.
5322    #[test]
5323    fn directional_hybrid_hessian_matches_flat_directional() {
5324        // body = exp(0.5*(x0 + x1)), shared by all three summands:
5325        //   s0 = body^2,  s1 = body * x2,  s2 = sin(body) + x2^2.
5326        let body = Arc::new(unary(UnaryOp::Exp, mul(cnst(0.5), add(var(0), var(1)))));
5327        let exprs = vec![
5328            pow(Expr::Cse(body.clone()), cnst(2.0)),
5329            mul(Expr::Cse(body.clone()), var(2)),
5330            add(
5331                unary(UnaryOp::Sin, Expr::Cse(body.clone())),
5332                pow(var(2), cnst(2.0)),
5333            ),
5334        ];
5335        let weights = [1.25, -0.75, 2.5];
5336        let x = [0.3, -0.1, 0.7];
5337        let n = 3;
5338
5339        let hybrid = HybridTape::build_multi(&exprs);
5340        assert!(
5341            hybrid.n_prelude_ops() > 0,
5342            "a CSE shared by 3 roots must be promoted into the prelude"
5343        );
5344
5345        // Flat reference: independent tapes, one directional pass each.
5346        let seeds = [[1.0, 0.0, 1.0], [0.0, 1.0, 0.0]];
5347        for seed in &seeds {
5348            let mut flat_out = vec![0.0; n];
5349            for (e, &wt) in exprs.iter().zip(&weights) {
5350                let t = Tape::build(e);
5351                let vals = t.forward(&x);
5352                let m = t.ops.len();
5353                let (mut dot, mut adj, mut adj_dot) = (vec![0.0; m], vec![0.0; m], vec![0.0; m]);
5354                t.hessian_directional(
5355                    &vals,
5356                    seed,
5357                    wt,
5358                    &mut flat_out,
5359                    &mut dot,
5360                    &mut adj,
5361                    &mut adj_dot,
5362                );
5363            }
5364
5365            let mut hyb_out = vec![0.0; n];
5366            let np = hybrid.n_prelude_ops();
5367            let ml = hybrid.max_summand_ops();
5368            let mut prelude_vals = vec![0.0; np];
5369            let mut prelude_dot = vec![0.0; np];
5370            let mut prelude_adj = vec![0.0; np];
5371            let mut prelude_adj_dot = vec![0.0; np];
5372            let (mut local_dot, mut local_adj, mut local_adj_dot) =
5373                (vec![0.0; ml], vec![0.0; ml], vec![0.0; ml]);
5374            // The per-color prelude reach the caller is required to pass:
5375            // the union of the color's summands' `prelude_reach`, ascending.
5376            // Built here rather than passing `0..np` so the test exercises the
5377            // narrowed walk `eval_h` actually performs — a reach that wrongly
5378            // omitted a slot would leave its tangent stale and show up below.
5379            let creach: Vec<u32> = {
5380                let mut u: BTreeSet<u32> = BTreeSet::new();
5381                for s in &hybrid.summands {
5382                    u.extend(s.prelude_reach.iter().map(|&p| p as u32));
5383                }
5384                u.into_iter().collect()
5385            };
5386            hybrid.forward_prelude(&x, &mut prelude_vals);
5387            hybrid.prelude_tangent(&prelude_vals, seed, &creach, &mut prelude_dot);
5388            for (s, &wt) in hybrid.summands.iter().zip(&weights) {
5389                let mut local_vals = vec![0.0; s.ops.len()];
5390                hybrid.forward_summand(s, &x, &prelude_vals, &mut local_vals);
5391                hybrid.hessian_summand_directional(
5392                    s,
5393                    &local_vals,
5394                    &prelude_dot,
5395                    seed,
5396                    wt,
5397                    &mut hyb_out,
5398                    &mut local_dot,
5399                    &mut local_adj,
5400                    &mut local_adj_dot,
5401                    &mut prelude_adj,
5402                    &mut prelude_adj_dot,
5403                );
5404            }
5405            hybrid.prelude_reverse_directional(
5406                &prelude_vals,
5407                &prelude_dot,
5408                &creach,
5409                &mut hyb_out,
5410                &mut prelude_adj,
5411                &mut prelude_adj_dot,
5412            );
5413
5414            for k in 0..n {
5415                let scale = flat_out[k].abs().max(1.0);
5416                assert!(
5417                    (hyb_out[k] - flat_out[k]).abs() <= 1e-13 * scale,
5418                    "seed {seed:?} entry {k}: hybrid {} vs flat {}",
5419                    hyb_out[k],
5420                    flat_out[k]
5421                );
5422            }
5423            assert!(
5424                hyb_out.iter().any(|v| *v != 0.0),
5425                "an all-zero H·s is no comparison"
5426            );
5427            // The accumulators must come back all-zero, ready for the
5428            // next color (`prelude_reverse_directional`'s contract).
5429            assert!(prelude_adj.iter().all(|v| *v == 0.0));
5430            assert!(prelude_adj_dot.iter().all(|v| *v == 0.0));
5431        }
5432    }
5433}