Skip to content

Prototype reverse-mode AD of SCM outputs - #553

Open
dfalster wants to merge 141 commits into
developfrom
spike-ff16-scm-emergent
Open

Prototype reverse-mode AD of SCM outputs#553
dfalster wants to merge 141 commits into
developfrom
spike-ff16-scm-emergent

Conversation

@dfalster

@dfalster dfalster commented Jul 1, 2026

Copy link
Copy Markdown
Member

Adds exact reverse-mode automatic differentiation of SCM outputs with respect
to traits and the birth-rate driver, for FF16, TF24 and TF24f. One backward
sweep returns the derivative of a scalar emergent output w.r.t. all inputs at
once — the shape of a calibration objective or a selection gradient — at
machine precision, with no per-trait re-run and no step-size lottery.

The C++ core gains a third template axis, the scalar type S, defaulting to
double. Every existing use and the whole R interface still instantiates
double and is bit-identical; the reference suite is untouched.

This is a prototype, not a finalised API: the access-point names and the engine
structure are expected to change. Every access point is either finite and
validated against a finite difference over the same reconstruction, or gated
with a clear error — never plausible-but-wrong. notes/ad-refactor-optimize-roadmap.md
has the follow-ups and the guide added here has the techniques and the limits.

Refs #472 (scope B), #537.

dfalster and others added 30 commits June 26, 2026 18:52
…container (#472 scope B)

Internals -> template basic_internals<S> with `using Internals =
basic_internals<double>`, so the per-plant state vector can hold an AD active
type for reverse-mode gradients. Foundational for templating Individual/Node/
Species on the scalar type. Additive: the double alias keeps every existing use
bit-identical (full suite PASS 2289, FAIL 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e B)

FF16_Pars -> template basic_FF16_Pars<S> with `using FF16_Pars =
basic_FF16_Pars<double>`, so traits can be AD active types for reverse-mode
calibration. Additive: RcppR6 references FF16_Pars only by name (no forward
declarations), so the R interface and every existing use are unchanged --
compiles clean, full suite PASS 2289 FAIL 0 bit-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ent via prod_pars() (#472 scope B)

Add FF16_Strategy::prod_pars() -> FF16ProdPars<double>, gathering the
net-production kernel's parameter set from a prepared strategy's actual pars +
derived eta_c. Lifting it to FF16ProdPars<ad> (registering a trait as a tape
input) gives reverse-mode trait gradients driven by the real, configured model
rather than hand-supplied numbers -- the calibration loop on live objects.

test-ff16-live-prod-pars-ad.R: a live FF16_Strategy's net production
differentiates to ~1e-11/1e-10 vs FD w.r.t. lma and a_l1. The test links
plant.so (for the strategy's compiled methods/vtable) + odelia.so (XAD tape);
runs on the installed package in CI, skips under load_all. Additive method;
FF16 reference comparison unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…A1) (#472 scope B)

Add the height-growth pieces to the kernel (ff16_fraction_allocation_growth,
ff16_dheight_darea_leaf, ff16_darea_leaf_dmass_live, ff16_height_dt_crown_top),
mirroring compute_rates' dheight/dt assembly; extend FF16ProdPars + prod_pars()
with the allometry/allocation params (a_l1,a_l2,a_f1,a_f2,hmat).

test-ff16-growth-rate-ad.R: the kernel reproduces a LIVE crown-top
FF16_Strategy's dheight/dt exactly (faithfulness), and reverse-mode AD gives the
exact d(growth rate)/d(height) -- the quantity Node::growth_rate_gradient
currently obtains by finite difference (#537 A1) -- matching a fine FD of the
live model to ~1e-8, with trait gradients (d/dlma) in the same sweep. Additive;
FF16 reference comparison bit-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 scope B)

Individual<T,E> -> Individual<T,E,S=double>; holds basic_internals<S>, state/
rate/aux accessors carry S. Additive: S=double default keeps every existing
instantiation and the R interface bit-identical (full suite FAIL 0). Structural
foundation for a plant's ODE state to carry an AD active type; the double-only
ODE-iterator methods stay uncompiled for AD instantiations until the ODE-state
boundary is wired, and a useful Individual<...,ad> additionally needs the
strategy's compute_rates templated on the state scalar (next).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tegy method (#537 A1) (#472 scope B)

Add FF16_Strategy::growth_rate_gradient_height_ad(height, env): the exact
d(dheight/dt)/d(height) via forward-mode AD over the growth kernel (header-only
XAD, no tape, as in Leaf::dprofit_droot_collar_psi), compiled into plant.so. This
is the direct drop-in for the finite difference in Node::growth_rate_gradient
(#537 A1). Matches a fine FD of the live crown-top model to ~1e-8 across heights
(test-ff16-growth-gradient-method-ad.R). Additive; full suite FAIL 0.

Next: dispatch Node::growth_rate_gradient to this exact gradient for strategies
that provide it (generic wiring); deep-crown variant; trait gradients need the
strategy templated on the scalar (FF16_Strategy<S>).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ING light profile (#537 A1)

Expose the analytic light derivative (ResourceSpline::get_value_deriv_at_height,
FF16_Environment::get_environment_deriv_at_height; smooth models only -- NaN for
the PPA stepped profile) and seed it into growth_rate_gradient_height_ad's
forward-AD pass: as height changes, the crown sampling point height*eta_c moves
through the light profile, so d(light)/d(height) = light'(z)*eta_c is now
included. The gradient is therefore exact in a real (varying) light environment,
not just a fixed one -- matches the live FD to ~1e-9 across heights
(test-ff16-growth-gradient-method-ad.R, varying-profile case). Additive
(new accessors; get_value_at_height unchanged); full suite FAIL 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…H in PKG_CPPFLAGS)

R CMD check on macOS failed with 'Package BH referenced from Rcpp::depends ... is
not available' -- Rcpp's depends-plugin lookup for BH fails in the check sandbox.
The BH include path is already supplied via PKG_CPPFLAGS (-I BH), which is how
the other AD tests resolve boost, so the depends attribute was redundant. Remove
it from the three tests that had it; verified ff16_strategy.h (boost via qag)
still compiles with -I BH alone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…CROWN, the default model (#537 A1)

growth_rate_gradient_height_ad now handles FF16's default deep-crown
assimilation, not just crown-top: differentiate the Gauss-Kronrod crown integral
through its MOVING nodes (bounds [0,height] scale with height) and the canopy
density q, with each node's light carrying d(light)/dz via the environment's
analytic spline derivative (value+slope injection). Adds QK::integrate_ad<S>
(scalar-templated Kronrod estimate reusing the rule constants) and
ff16_canopy_q<S>/ff16_height_dt_from_net<S> kernels. Matches the live deep-crown
FD to ~1e-8 in a varying light profile (test-ff16-growth-gradient-method-ad.R).
Additive; full suite FAIL 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gh the growth ODE (#472 scope B)

Add ff16_grow_height<S>: a single plant's height trajectory integrated over fixed
RK4 steps (the frozen-schedule formulation end-to-end AD needs) in a fixed light.
The whole trajectory is scalar-templated, so reverse-mode AD gives
d(height at age T)/d(trait) -- a calibration gradient through the growth ODE, the
bridge from instantaneous-rate gradients to emergent (time-integrated) outputs.
A plant grows h0=0.4 -> h(5yr)=4.65; d/d{lma,a_p1} match FD to ~1e-12/1e-9 in one
reverse sweep (test-ff16-grow-trajectory-ad.R). Additive; full suite FAIL 0.

Next: the SCM analogue (multi-cohort, resource-spline-coupled, emergent fitness)
needs Species/Patch/SCM on S + the ODE-state boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nresolvable

On macOS R CMD check, system.file('include', package='BH') returns '' (the BH
include dir is not on the test process's view in the check sandbox), so the AD
tests that #include ff16_strategy.h (boost via BH) failed to compile (-I'' ->
Rcpp.h/boost not found). Skip those four tests when the BH include path can't be
resolved -- they are compile-on-the-fly integration tests needing a full
toolchain + the odelia/plant shared libs + boost; they run in a properly
set-up installed environment and skip otherwise. The double path stays covered
by the FF16 reference-comparison test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…into Node (opt-in) (#537 A1)

Node::growth_rate_gradient uses the strategy's exact AD gradient when
control.node_gradient_exact_ad is set and the strategy provides one, else falls
back to the finite difference (the default -> bit-identical, full suite FAIL 0).
Wiring:
- Control::node_gradient_exact_ad (default false).
- Strategy<E>::growth_rate_gradient_height_ad default returns NA (unavailable);
  FF16 overrides it (forward-mode AD). Non-FF16 strategies fall back to FD.
- Individual::growth_rate_gradient_exact delegates to the strategy at the current
  height; Node calls it and uses the result when finite.

The exact gradient matches a fine FD of the REAL growth_rate_given_height (the
actual compute_rates path) to ~1e-8 in a varying light profile
(test-ff16-node-exact-gradient-ad.R). The FD inside Node::growth_rate_gradient
(the #537 A1 target) is now replaceable by the exact AD gradient on the live
solver path. (Control flag is C++-settable; exposing it to R via the yml is a
follow-up.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o R; CI-runnable AD test (#537 A1)

Add Individual$growth_rate_gradient_exact(environment) to the RcppR6 interface
(regenerated R/RcppR6.R, src/RcppR6.cpp, RcppExports), so the exact AD
growth-rate gradient is a first-class R method -- the calibration entry point --
and testable on CI WITHOUT on-the-fly compilation (removing the BH/toolchain
fragility of the sourceCpp tests).

test-ff16-exact-gradient.R (plain R, runs on CI): the FF16 exact gradient matches
a fine FD of the real growth rate to ~1e-8; K93/TF24 return NA (Strategy<E> base
default) so Node falls back to FD. Replaces the redundant sourceCpp node test.
Full suite PASS 2296, FAIL 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cope B)

Mirror Individual<T,E,S>: Node gains a defaulted scalar type S so every
existing Node<T,E> is Node<T,E,double> and bit-identical. The individual_type
becomes Individual<T,E,S>; the state-derived accessors (height,
compute_competition, consumption_rate) return S. Demographic bookkeeping
(log_density/density/fecundity/offspring) stays double -> a Node<...,ad> is
intentionally MIXED, and (like Individual) members compile per-member-on-use,
so the double-bound ODE-iterator / demographic methods stay uncompiled for ad
until the ODE-state boundary is wired.

Additive: make_node stays 2-arg (returns Node<T,E,double>); all RcppR6 Node<T,E>
references resolve via the default. Full suite PASS 2363 (0 fail), unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…scope B)

Species gains a defaulted scalar type S, mirroring Node<T,E,S>. The node
storage becomes Node<T,E,S> (the element passed to SpeciesBase, which is
already generic over its element), individual_type -> Individual<T,E,S>, so a
Species<...,ad> holds ad-typed individual state -- the container plant's two-pass
replay loop needs to step ad nodes outside odelia's double adaptive solver.

R-facing accessors (r_heights, r_get_state, the std::vector<double> reductions,
height_max/compute_competition) keep double locals/returns; like the rest of the
hierarchy they compile per-member-on-use, so for the ad instantiation only the
trait-carrying path is compiled. Additive: all Species<T,E> references resolve
via the default (RcppR6, Patch, SCM). Full suite PASS 2363 (0 fail), unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…scope B)

Patch gains a defaulted scalar type S, mirroring Species<T,E,S>. species_type ->
Species<T,E,S> (and node_type/individual_type), so a Patch<...,ad> holds ad-typed
individual state. The environment member deliberately stays type E (double): a
Patch<...,ad> is MIXED -- ad species over a frozen-double resident environment,
matching the proven two-pass replay design (freeze the resident light schedule,
replay the node ODE with the active scalar). An ad-valued resident light spline
(self-shading through traits) is a later piece (odelia AD interpolator, PR #32).

parameters_type stays Parameters<T,E>. R-facing / ODE-iterator / lifetime-fitness
methods keep double; per-member-on-use compilation leaves them out of the ad
instantiation. Additive: all Patch<T,E> references resolve via the default (SCM,
RcppR6). Full suite PASS 2363 (0 fail), unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ area_leaf on S (#472 scope B)

Unblocks CONSTRUCTING a live Node<FF16,FF16_Env,ad> (the boundary the incr-14
probe hit at individual.h:56). FF16_Strategy::update_dependent_aux becomes a
member template over the Internals' value type S, so Individual<...,ad>'s ctor
(set_state("height", ...)) and every set_state can run with ad state; the
dependent aux (competition_effect = area_leaf, height_inverse) then carry the
active scalar. Added a scalar-templated area_leaf<S> overload (lifts the double
allometry pars via S(pars.*)); the non-template double overload still wins for a
double argument, so the hot path is unchanged.

S deduces to double for every existing caller (Individual<...,double>), so the
double codegen + FF16 reference test are bit-identical. Full suite PASS 2363
(0 fail), unchanged.

Validated at runtime (scratch sourceCpp, plant.so+odelia.so tape link): a live
Node<...,ad> constructs, set_state drives update_dependent_aux<ad>, and one
reverse sweep gives d(area_leaf)/d(height) matching the double FD to ~2e-10 --
the first trait/state gradient through a live constructed object of the templated
hierarchy (vs hand-built kernel drivers). Runtime AD remains CI-gated (skips
under load_all), as for all prior AD increments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ate fill (#472 scope B)

Adds the full compute_rates fill as a templated kernel so the WHOLE demographic
rate vector (not just growth) differentiates w.r.t. a trait by reverse-mode AD:

- FF16ProdPars<S> extended with omega, a_f3 (fecundity [eqn 17]) and d_I, a_dG1,
  a_dG2 (mortality [eqn 21]); prod_pars() gathers them from the live strategy.
- ff16_fraction_allocation_reproduction<S> added; growth refactored as
  1 - reproduction (single source, arithmetic identical).
- FF16Rates<S> + ff16_compute_rates_crown_top<S>(p, height, light_E,
  mortality_finite): mirrors FF16_Strategy::compute_rates EXACTLY -- the net>0
  growth clamp gates height/fecundity/heartwood rates, mortality is the
  growth-independent + growth-dependent sum (productivity = net/area_leaf). The
  is_finite(mortality) test is passed in as a frozen pass-1 branch so the taped
  replay is branch-free. Deep-crown reuses all of this by substituting the
  frozen-replay crown-integral net.

Additive: nothing in the double hot path calls the new kernel (compute_rates is
unchanged), so the suite is bit-identical (PASS 2363, 0 fail).

RUNTIME-VALIDATED (scratch sourceCpp, plant.so+odelia.so tape link): all 5 rates
match the live crown-top compute_rates BIT-EXACTLY (rel 0), and
d(fecundity_dt)/d(a_p1) via one reverse sweep matches the double-kernel FD to
~1e-12. The node-level ad rate path (crown-top) for the replay is now in hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ernel (#472 scope B)

ff16_grow_demography<S>: integrate all five FF16 ODE states (height, mortality,
fecundity, area_heartwood, mass_heartwood) over fixed RK4 steps in a fixed
crown-top light, via ff16_compute_rates_crown_top each stage. Generalises
ff16_grow_height (height only, C-9) to the demographic vector, so reverse AD
gives d(any emergent state at age T)/d(trait) -- a TIME-INTEGRATED demographic
calibration gradient through the whole single-plant ODE. FF16State<S> added.

Additive/header-only; nothing in the double path calls it -> suite bit-identical
(PASS 2363, 0 fail).

RUNTIME-VALIDATED (scratch sourceCpp, tape-linked): full-state height(T) matches
the height-only kernel exactly, and d(lifetime fecundity at age T)/d(lma) via one
reverse sweep matches the double-trajectory FD to ~1e-8. The single-plant
time-integrated demographic gradient is the per-node building block for the
multi-plant frozen-schedule SCM replay (the emergent community gradient).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…replay primitive (#472 scope B)

The committed replay primitive for the emergent community gradient.
ff16_replay_cohort<S>(p, y, dt, light, step0, mortality_finite): forward-Euler
integrate one cohort's full demographic state (FF16State) from birth step0 to the
end of a per-step FROZEN crown-light schedule, via ff16_compute_rates_crown_top.
Forward Euler (not RK4) is deliberate -- it reproduces the SCM's
control.fixed_time_step integration exactly, so replaying a fixed_time_step
resident run is faithful.

Reverse AD over a weighted sum of cohort outcomes J(theta)=sum_i w_i*f(replay_i)
(w_i + light frozen from pass 1) gives d(emergent stand output)/d(trait), holding
the resident light schedule fixed (the legitimate "resident-light-frozen"
gradient; full self-shading additionally activates light via odelia #32, deferred).

Additive/header-only; suite bit-identical (PASS 2363, 0 fail).

RUNTIME-VALIDATED (scratch sourceCpp): a 6-cohort staggered stand replayed through
a frozen declining light schedule gives emergent stand LAI=1.24, and reverse-mode
d(LAI)/d(lma)=-30.09 and d(LAI)/d(a_p1)=+0.075 both match double FD to ~2-5e-10 --
correct MULTI-TRAIT emergent community gradients through the real FF16 demographic
kernels. Remaining for full production: drive pass-1 (schedule/light/weights) from
the LIVE SCM, and the active-query light spline for the self-shading gradient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…play (#472 scope B)

ff16_replay_cohort_active_light<S,LightFn>: like ff16_replay_cohort, but the
cohort reads its crown light ACTIVELY from a frozen resident profile each step via
a caller-supplied crown_light(S height). In the AD context the caller seeds it
from the resident profile's value + slope (FF16_Environment::get_environment_at_
height / get_environment_deriv_at_height, the C-7 accessors), so d(light)/d(height)
flows -- capturing the within-cohort self-shading feedback (taller cohort reads
higher in the canopy -> more light -> faster growth) that incr-18's frozen per-step
light omits. The profile KNOTS stay frozen double (resident held fixed); making
them active is the full self-shading gradient (odelia #32). LightFn is a template
so XAD never enters the header (mirrors ff16_assimilation_deep_crown_replay).

Additive/header-only; suite bit-identical (PASS 2363, 0 fail).

RUNTIME-VALIDATED (scratch sourceCpp, varying resident light profile): single
cohort, 200 Euler steps -- height(T) matches the double active-light replay
exactly, and d(height(T))/d(lma) WITH the light feedback = -62.02 matches FD of
the double feedback replay to ~5e-11. So the value+slope active-query gives the
correct TOTAL gradient including the crown-moves-through-the-frozen-profile path,
the building block for the resident self-shading gradient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-- full self-shading gradient (#472 scope B)

ff16_resident_light_at<S>(z, a_l1, a_l2, k_I, eta, heights, densities): the
resident light availability E(z)=exp(-sum_i density_i*k_I*area_leaf_i*Q(z/h_i)) at
height z from a FROZEN stand, ACTIVE in the traits through each cohort's area_leaf
(Yokozawa Q leaf-area-above, eta fixed double; Beer's law, matching
FF16_Environment::compute_environment). The resident self-shading coupling
primitive: the trait reshapes every cohort's leaf area -> the whole light profile.

Evaluated at frozen knot positions to fill an active-VALUE light spline (odelia
basic_interpolator<S>, #32), this closes the LAST gap -- the FULL self-shading
gradient where the resident profile RESPONDS to the trait (vs incr-19's
frozen-knot active-query). Additive/header-only; suite bit-identical (PASS 2363).

RUNTIME-VALIDATED (scratch sourceCpp, 5-cohort stand, 41-knot active light spline):
focal mid-canopy net production differentiated w.r.t. a_l1 THROUGH the self-shaded
light profile -- trait -> all cohorts' area_leaf -> competition -> Beer's law ->
active spline knot values -> focal crown light -> focal net production. One reverse
sweep: dJ/d(a_l1)=1.658 matches FD to ~2e-11. The genuine self-shading number on
real FF16 formulas (the toy-stand twopass_resident_probe, now on the real model).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
scripts/ad_gradient_examples.R: a portable, self-contained demonstration that
RUNS the scalar-templated FF16 AD kernels and checks each reverse-mode gradient
against a finite difference. Three examples of increasing scope:
  1. demographic rate fill -- bit-exact faithfulness of ff16_compute_rates_crown_top
     vs live FF16_Strategy::compute_rates, + d(fecundity_dt)/d(a_p1) ~1e-12;
  2. emergent multi-cohort stand LAI via ff16_replay_cohort, d(LAI)/d{lma,a_p1};
  3. full self-shading -- d(focal net production)/d(a_l1) through an active-value
     resident light spline (ff16_resident_light_at + odelia basic_interpolator).

Uses the INSTALLED plant via system.file (so installed headers match the compiled
.so -- a mismatch segfaults), links plant.so + odelia.so like the package's
tape-linked tests. Run from the package root after `R CMD INSTALL .`:
  Rscript scripts/ad_gradient_examples.R
All three examples print AD vs FD and assert agreement (RC 0 verified locally).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pe B)

Two records of how the AD trait-gradient work was reached:
- notes/ff16-ad-templating-plan.md: the design plan (the three-template-axis
  strategy, milestones A/B/C, hardest couplings, what does NOT need templating),
  with a status table mapping Milestone C's increments 12-20 to the committed
  kernels and their FD-validated gradients.
- overstorey-staging/guides/autodiff-trait-gradients.qmd: a narrative guide
  documenting the journey -- why gradients, the third-template-axis idea, the
  additive bit-identical discipline, and the step-by-step route from a templated
  container to the full self-shading gradient. Points at scripts/
  ad_gradient_examples.R as the runnable demonstration. No figures (staging copy).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-sweep gradient

Guide (autodiff-trait-gradients.qmd): add a "Groundwork: three pieces that landed
first" section so the narrative no longer jumps straight to the hierarchy
templating. Covers the leaf-level precedent (forward-mode AD + implicit function
theorem at the psi_stem->ci root-find + analytic spline deriv; #531/#539, incl.
the same-spline-derivative lesson), the differentiable spline (odelia #32), and
the FF16 production kernel with its moving-node Gauss-Kronrod crown integral
(#540 / QK::integrate_ad) -- and the forward-vs-reverse-mode distinction.

Script (ad_gradient_examples.R): add Example 4 -- d(net production)/d(all 19
production traits) from a SINGLE reverse sweep, each checked vs finite difference
(worst rel.err ~2e-9). Makes reverse mode's headline advantage concrete; the
prior examples differentiated one trait at a time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + IFT)

Example 0 in ad_gradient_examples.R demonstrates the groundwork's first piece --
TF24's leaf hydraulics gradient d(profit)/d(root-collar psi), the first exact AD
gradient in plant (#531/#539): forward-mode AD over the photosynthesis/cost
algebra + the implicit function theorem at the psi_stem->ci root-find. It runs in
pure R via the exposed Leaf class (no compilation), checked against a central FD
at strictly-interior feasible points (~1e-11). Updates the guide's "Trying it"
to list the precedent + the four FF16 gradients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 scope B)

Addresses the "PR has no CI coverage of the AD kernels" gap before merge. Adds two
forward-mode [[Rcpp::export]] free functions compiled into plant.so (header-only
XAD, like FF16_Strategy::growth_rate_gradient_height_ad -- no reverse-mode tape,
no odelia link, no DLL-ordering dependency), so the AD path runs on CI WITHOUT
on-the-fly Rcpp::sourceCpp (which skips in the BH-less check sandbox / load_all):
  - ff16_fecundity_dt_grad_ap1(height, light_E): forward-mode d(fecundity_dt)/
    d(a_p1) over ff16_compute_rates_crown_top.
  - ff16_crown_top_fecundity_dt(height, light_E, a_p1): the kernel value with a_p1
    overridden -- the finite-difference reference.

test-ff16-rate-kernel-gradient.R (plain R, runs everywhere -- 19 assertions, 0
skips): (1) the kernel reproduces the LIVE crown-centre fecundity rate bit-exactly
(faithfulness), and (2) the forward-mode gradient matches a central FD to ~1e-6,
with a guard against a vacuous all-zero pass. Full suite PASS 2382 (0 fail).

The broader reverse-mode / emergent-output gradients remain demonstrated runnably
in scripts/ad_gradient_examples.R. Regenerated RcppExports via compileAttributes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#472 scope B)

The faithful pass-1 driver + pass-2 AD replay over a REAL FF16 SCM run -- the
production form of the emergent community-level trait gradient. Replaces the
feasibility forward-Euler replay with the integrator the SCM actually uses.

- ff16_replay_cohort_rkck<S,LightFn> (ff16_production_kernel.h): scalar-templated
  Cash-Karp RKCK cohort replay, constants copied from odelia ode_step.hpp. The
  committed forward-Euler ff16_replay_cohort only mirrors the non-default
  control.fixed_time_step path; the live SCM (and run_mutant's advance_fixed
  replay) integrate with adaptive Cash-Karp. 6 frozen per-RK-stage envs/step;
  FSAL k1 recomputed against the step-start env (numerically identical to the
  solver's first_same_as_last).
- Expose Patch$step_history + Patch$environment_history via RcppR6 (yaml +
  regenerated bindings) so the frozen resident schedule and per-stage resident
  light are harvestable in R without re-running the SCM in C++.
- scripts/ad_emergent_gradient.R: end-to-end demonstration on a real resident
  SCM. Pass 1 harvests a single clean cached run (crown-centre shading);
  pass 2 replays every cohort under XAD for J = sum_i w_i * fecundity_i(t_end).
  Faithfulness: replay heights == live SCM heights to 3.4e-14 over all 156
  cohorts; emergent d(J)/d(a_p1) reverse-AD vs two-pass FD converges to 5.3e-9.

Full suite PASS 2382 (the new bindings are additive; bit-identical otherwise).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ash-Karp stepper (#472 scope B)

Extend the live-SCM two-pass machinery from the stand-weight proxy J to the SCM's
ACTUAL emergent output, offspring_production, and factor the integrator so both
replays share one stepper.

- ff16_cashkarp_replay<State,Deriv,Axpy> (ff16_production_kernel.h): the Cash-Karp
  RKCK tableau + FSAL stage-0 reuse + c2==c5==0 sum now live ONCE, generic over the
  state type (caller supplies deriv + axpy). ff16_replay_cohort_rkck delegates to it
  (signature unchanged; faithfulness still 3e-14 vs the live SCM).
- ff16_replay_cohort_offspring_rkck<S> + FF16LifeState<S>: the 6-state replay (5 FF16
  states + survival-weighted offspring), mirroring Node::compute_rates'
  d(offspring)/dt = fecundity_dt * exp(-mortality) * pr_patch_survival(t)/ppsab, with
  mortality seeded to -log(establishment_probability). The stand's offspring_production
  is the node-spacing trapezium of offspring * patch_density * S_D * birth_rate -- a
  frozen linear post-weighting, so one reverse sweep gives d/d(trait).
- scripts/ad_offspring_gradient.R: end-to-end on a real resident SCM. Reconstructs
  offspring_production = 20.2068 vs SCM 20.2067 (rel 6.3e-6; the residual is the
  establishment birth-env for a few shade cohorts, per-cohort median 2e-7).
  d(offspring_production)/d(a_p1) reverse-AD = 511.0082 vs two-pass FD to 1.9e-9
  (establishment frozen in AD+FD -- a clean separable partial).

Suite FAIL 0 | PASS 2364 (header-only; the demographic replay + emergent-gradient
scripts re-validate bit-identical through the shared stepper).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt (#472 scope B)

The emergent two-pass gradient now covers the DEFAULT FF16 assimilation model
(deep-crown crown integral), not just crown-top/crown-centre.

- ff16_compute_rates_from_net<S> (ff16_production_kernel.h): factor the rate-fill
  tail (the part of compute_rates downstream of `net`) shared by every assimilation
  variant; ff16_compute_rates_crown_top now delegates to it (bit-identical).
- scripts/ad_deep_crown_gradient.R: deep-crown two-pass replay on a real resident
  SCM run with the DEFAULT shading. Pass 2 replays each cohort with the MOVING-NODE
  Gauss-Kronrod crown integral (QK::integrate_ad over [0,height], frozen per-RK-stage
  resident light read at each node with value+slope) -- the deep-crown path of
  FF16_Strategy::growth_rate_gradient_height_ad carried through the whole demographic
  trajectory via the shared ff16_cashkarp_replay stepper + ff16_compute_rates_from_net.
  Faithfulness: replay heights == live SCM to 3.6e-14 over all 163 cohorts; emergent
  d(J)/d(a_p1) reverse-AD = 1729851.88 vs two-pass FD to 1.2e-10.

Suite FAIL 0 | PASS 2364 (header-only; crown-top + emergent scripts re-validate
bit-identical through the shared tail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dfalster and others added 15 commits June 30, 2026 22:27
…ative + long-horizon NaN)

Records session 2's landed work (a_l2 NaN fix, dead-code removal, FF16 MS native, notes
prune) and the two tasks chosen for next: (A) native the TF24f multi-species coupled
gradient (mirror the FF16 MS native; cosmetic/perf), and (B) the TF24f long-horizon
coupled-gradient NaN at H>=6 (a real derivative-only tape gap, with the diagnosis gathered
this session). Job 3 (birth_rate derivative) still pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt (no R harvest)

Mirror of the FF16 MS native (commit bef2503): the lone TF24f public path still
on the R harvest is now fully native.

- Extract `tf24f_coupled_gradient_ms_core` from the ~400-line monolith
  `tf24f_coupled_gradient_ms_impl`: the core takes the joint env / step schedule /
  per-species birth steps / all-species per-RK-stage boundary harvest as ready-built
  C++ structures (the R-list -> C++ build moves into the `_impl` wrapper).
- `_impl` (the FD-reference surface the census test perturbs by re-passing pp_list)
  keeps its exact R-list signature; it now just rebuilds the structures via
  Rcpp::as<> and calls the core.
- New `tf24f_coupled_gradient_ms_native(SEXP scm_, ...)` reads environment_history /
  step_history / stand_newnode_*_stage_history_all / gradient::birth_steps off the
  live Patch (no tf24f_harvest_ms, no Rcpp::as<> env round-trip, no O(stand) patch
  rebuild). gate_only switches the core between the cheap double R0 gate (env_err)
  and the AD sweep.
- Wire `tf24f_resident_census_gradient_ms_ad` to the native entry; the cheap
  per-species scalars (pp / recovered birth rates / k_acclim / use_ad_gradient) are
  read from $parameters in R, exactly as FF16's MS native wrapper does.

Cosmetic/perf only -- changes no results. native == impl bit-for-bit on the MS
fixture stand (jacobian/values/env_err max diff 0.0). Fixture all PASS; whole suite
FAIL=0 SKIP=9 PASS=2587 (identical to baseline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…f long-horizon replay (no more NaN)

Root cause of the H>=6 all-NaN (and the H=5 finite-but-astronomical ~1e+237)
single-species coupled census gradient, diagnosed this session by probing the
discovery pass + step-cap bisecting the AD sweep:

- It is NOT the collar-curvature instability (probe: k_acclim*d2p_dpsi2 ~ -9.9,
  stably DECAYING at every stage), and NOT the g' backward-FD light channel (freezing
  it does not help and breaks H4) -- the two handoff suspects, both ruled out.
- It is the coupled log_density <-> canopy SENSITIVITY feedback going unstable. The
  step-cap bisection localises ignition to a single step (jac -2.1 -> +4e9 at one step,
  then compounding to 1e+237 / NaN with horizon). The probe shows deeply-shaded cohorts
  carry a LARGE leaf dprofit_dL (7 -> 24 as shade deepens) and high density (logd ~ 4.9),
  so they strongly amplify the canopy-reshaping feedback. Once the linearised
  sensitivity loop gain crosses 1, it grows exponentially over the remaining stages.
- The live SCM tames exactly this stiffness by stepping ADAPTIVELY through it; the
  replay reuses the SCM's FROZEN step sizes and cannot, so the sensitivity diverges.
  (The double R0 re-evolution confirms it: joint env_err jumps ~1e-6 at H4 -> ~2e-2 at
  H5+.) FF16's coupled tape stays robust to long horizons because its closed-form net
  has a bounded light response -- no leaf-solve light amplification. So this is
  TF24f-leaf-specific, exactly as the handoff predicted, but the mechanism is the
  shaded-leaf dprofit_dL amplification of the feedback, not g'/curvature.

Fix (the shipped-resolution pattern, mirroring the multi-species path + FF16's coupled
gate): the single-species resident path had NO gate -- it returned NaN/garbage silently.
It now runs the cheap double R0 gate (env_err) and a post-sweep finiteness/magnitude
guard, raising a clear, actionable error ("too stiff on this node schedule ... use a
shorter max_patch_lifetime or a finer fixed schedule, or feedback = 'frozen'") for the
long/stiff horizons instead of a diverged Jacobian. The frozen (invasion) gradient stays
robust at all horizons. Genuinely extending the coupled horizon needs adaptive
sub-stepping in the replay (the documented future hardening), out of scope here.

H<=4 unchanged (gate passes at env_err ~2.8e-6; LAI/lma still -2.08); the fixture
tf24f_resident (H=4) is bit-identical. New regression test in
test-tf24f-census-gradient.R. Fixture all PASS; whole suite FAIL=0 SKIP=9 PASS=2591.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… scoped/deferred)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…axis, demographic equilibrium)

New capability (roadmap Job 3, FF16 slice): d(emergent census metric)/d(birth_rate)
for the per-species birth-rate driver -- a different gradient AXIS than the trait
gradient, for evolving a community toward demographic equilibrium (birth_rate scales
every cohort's establishment density, re-shading the whole resident canopy).

- `assemble_metrics_coupled` is now templated on the birth_rate type (S birth_rate),
  so birth_rate can be an AD input on the coupled whole-stand replay. The two callers
  (double recon + ad_t trait tape) are unchanged -- birth_rate stays a constant there.
- New `ff16_birth_rate_gradient_core` / `ff16_birth_rate_gradient_native`: register
  birth_rate as the SOLE tape input on the same coupled re-evolution the resident trait
  gradient uses; one reverse sweep per metric.
- Public `birth_rate_gradient(scm, metrics, species)` (FF16; LAI/biomass/size_moment).
  offspring_production is excluded -- its birth-rate derivative is the trivial frozen
  identity (every density is linear in birth_rate, so d(metric)/d(birth_rate) =
  metric/birth_rate, and offspring keeps the frozen invasion reading even under
  resident). Only the resident-coupled axis needs a tape.

Why it matters: the resident reading is genuinely non-trivial -- the canopy feedback
dominates and FLIPS the sign of biomass (AD d(biomass)/d(birth_rate) = -6.25e-4 vs the
frozen identity +0.456), which is exactly the demographic-equilibrium object (the
self-shading from denser recruitment offsets the naive density scaling).

Validation: AD == central FD over the SAME coupled reconstruction (perturb the
birth_rate arg of ff16_coupled_metrics_impl) to ~0.7% (the coupled noise floor). New
fixture case ff16_birth_rate (noise tier, pinned bit-identical) + test in
test-ff16-stand-gradient.R. Whole suite FAIL=0 SKIP=9 PASS=2603; fixture all PASS.

Remaining (future): the cross-species birth_rate block, TF24f resident birth_rate
(stiffness-gated like its trait gradient), and wiring birth_rate into the generic
stand_gradient metric loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oad_all skip)

The AD tests self-skip under load_all (pkgload DLL path trips
is_pkgload_dll_plant). New target installs then drives test_dir against the
installed package, with a namespace-parented env to reach unexported internals
and parallel off so callr workers inherit the load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… term)

Completes the FF16 birth_rate surface to match the trait gradient's single + multi-
species coverage. On a multi-species stand, birth_rate_gradient(scm, metrics, species=s)
now returns the CROSS-SPECIES total d(total-stand census metric)/d(birth_rate_s): species
s's recruitment density re-shades the joint canopy every species reads -- the genuinely
new cross term (the frozen reading is the diagonal identity metric_s/birth_rate_s with
zero cross term).

- assemble_metrics_coupled_ms gains an optional active per-species birth_rate vector
  (br_active); a BR(s) helper returns the harvested constant or the active scalar, so the
  three establishment/dens_new sites differentiate w.r.t. one species' birth_rate.
- New ff16_birth_rate_gradient_ms_core / _native (mirror ff16_coupled_gradient_ms_*):
  lift every species' prod_pars constant, register ONLY the target species' birth_rate as
  the tape input, one reverse sweep per metric; return d_birth_rate + values + env_err.
- R birth_rate_gradient() dispatches single- vs multi-species and gates the multi-species
  path on env_err (> 1e-2 -> clear "use a fixed schedule" error), like the MS trait path.

Validated AD == central FD over the SAME coupled MS reconstruction (perturb species 1's
birth_rate arg of ff16_coupled_metrics_ms_impl) to ~0.3% on a 2-species fixed-schedule
stand. New MS test in test-ff16-stand-gradient.R. Whole suite FAIL=0 SKIP=9 PASS=2606;
fixture all PASS (single-species ff16_birth_rate case unchanged, bit-identical).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ate gradient

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he R0=1 / mutant-fitness axis

The exact-AD access point for demographic equilibrium (Dan's mutant framing): with
mutant traits = resident traits, the change in mutant fitness as the resident density
moves IS d(net_reproduction_ratio)/d(birth_rate) -- the density-dependence that the
R0 = 1 self-replacement Newton step (in regnans) needs. birth_rate is not a trait, so it
changes only the resident canopy; with UNIT per-seed weights it enters R0 ONLY through
that canopy (denser recruitment -> more self-shading -> lower per-seed reproduction), the
pure density feedback.

- assemble_metrics_coupled now carries a survival-weighted OFFSPRING accumulator
  (switched its stand state CensusState -> the existing FullState). Offspring is fully
  decoupled from the demog / log_density rates, so the census + density trajectories stay
  bit-identical (fixture ff16_resident / ff16_frozen_all max_rel 0.0). Its rate is the
  frozen offspring tape's: fecundity_dt * exp(-mortality) * ppsurv/ppsab, fecundity reading
  the ACTIVE canopy.
- New metric "net_reproduction_ratio" = sum_i (tw_i / birth_rate0) * offspring_i with
  UNIT weights. birth_rate0 is the FIXED harvest birth_rate (new Frozen field), so
  activating/perturbing birth_rate (the canopy/density driver) does NOT rescale the
  per-seed weight -- the mutant reading. build_frozen_scm sets it; ff16_coupled_metrics_impl
  gains a birth_rate0 arg so the FD reference holds the weight fixed too.
- birth_rate_gradient(scm, metrics = "net_reproduction_ratio") returns dR0/db (single-
  species; cross-species stays census-only). R0 reconstructs the SCM's own
  net_reproduction_ratio exactly; dR0/db < 0 (density-dependent, so R0 = 1 is well-posed).

Validation: AD == FD over the SAME coupled recon (weight held at harvest, canopy
perturbed) to 0.19%. The ~25% gap to a full-SCM FD is the documented frozen-grid response
(same honest scope as the trait gradients). New test + fixture case extended
(net_reproduction_ratio added to ff16_birth_rate). Whole suite FAIL=0 SKIP=9 PASS=2610;
make test-ad FAIL=0 PASS=362; fixture all PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atio) access point

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_rate_gradient + dR0/db) and TF24f coupled scope

- Add birth_rate_gradient to the access-points table (the fifth call, FF16, the
  birth-rate axis rather than a trait).
- New "The birth-rate axis" section: resident-total d(census)/d(birth_rate) (single +
  cross-species, feedback flips biomass sign) and the net_reproduction_ratio = dR0/db
  demographic-equilibrium gradient via Dan's mutant framing (mutant=resident, change in
  mutant fitness with resident density; unit per-seed weights so birth_rate enters only
  via the canopy). Honest scope: frozen-grid total, ~20-30% grid-response gap to full-SCM
  FD; solver lives downstream (regnans).
- "What's next": record the birth-rate axis landing and the TF24f resident-census
  horizon limit (the coupled log_density<->canopy stiffness gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…here each access point works / breaks)

New "Where it works, and where it breaks" section: an empirical envelope table
(swept across patch lifetime and species count for every access point) + the reading:
FF16 has no hard break to H=200 / 5 species (soft limits: cost ∝ cohorts, frozen-grid
recon drifts ~1e-6 rel); TF24f frozen robust, resident single-species gates at H>=5;
TF24 offspring-only. Records the key property for downstream consumers -- every call is
finite-and-validated OR gated-with-an-error, never plausible-but-wrong -- and that the
single common cause of every hard limit is the frozen-schedule replay meeting stiffness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cies in the envelope table

Add a shorthand legend before the operational-envelope table so it is self-contained:
H = patch lifetime (max_patch_lifetime), nsp = number of species, census = the
size-distribution reductions (LAI/biomass/size_moment) vs the lifetime seed-output
metrics, cross-species = the multi-species total-stand d(metric)/d(theta_s) reading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nsus), not "cross-species"

The multi-species rows now read "resident census" like their single-species counterparts;
the multi-species aspect is carried by the nsp = 2->5 sweep column. Drop the "cross-species"
table-shorthand bullet (no longer in the table) and replace it with a note that the
multi-species rows are the resident total-stand d(metric)/d(theta_s) reading (the
cross-species feedback the frozen reading zeroes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…re plausibly related to #550

Same size-density equation (d(log_density)/dt = -dg/dh - mortality) destabilised by a
large dg/dh from the TF24 leaf, but a distinct manifestation (measured): #550 is a genuine
run_scm caustic (density VALUE overflows under drought, no gradient exists); ours has
run_scm healthy (max|log_density| ~5) and the true resident gradient exists (full-SCM FD
finite at H=6) -- only the frozen-step AD replay diverges. #550's root fixes (NSC #517 /
hydraulic-optimum continuity) would help ours too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dfalster
dfalster requested a review from aornugent July 1, 2026 19:53
@dfalster

dfalster commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

@aornugent Could you provide a review on this before merging? We've demonstrated AD capability with one prototype implementation. We ended up implementing outside of the SCM object, but I'm not sure this is the right long-term solution. I'm thinking we land this PR and then re-implement, refine in a new round of work. I'm open to variants. I'm interested in your thoughts.

@dfalster
dfalster marked this pull request as ready for review July 1, 2026 19:55
…on CI; loosen a leaf-FD tolerance

Windows R CMD check failed 10 AD tests (macOS passed) — pure cross-platform FP, not logic:

- test-gradient-regression.R (9): the AD-vs-AD baseline is a SAME-MACHINE bit-precision
  guard (committed snapshot, tiers bit 1e-12 / noise 5e-6). Across compilers/libm the AD
  values legitimately drift ~1e-9 (frozen, closed-form) to ~3e-3 (coupled/resident paths,
  whose cohort-height-crossing sort tie-breaks resolve differently), so the snapshot cannot
  be asserted cross-platform. Add skip_on_ci(); it still runs on the snapshot machine via
  `make test-ad` / `scripts/gradient_fixture.R check`. The AD-vs-FD tests (loose physics
  tolerances, which passed on Windows) are the portable CI correctness net.
- test-tf24-rate-kernel-gradient.R:48 (1): an AD-vs-FD check whose FD reference goes through
  the hydraulic leaf root-find; its noise floor is compiler/libm-dependent (~7e-5 on
  Windows vs ~1e-8 on the dev machine). Loosen 1e-5 -> 1e-3 so it is portable while still
  pinning the gradient (AD 4.153 vs FD 4.153).

Local macOS `make test-ad` (post develop-merge): FAIL 0 SKIP 0 PASS 362.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aornugent

aornugent commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

This is huge!

The spike proves that this is possible — exact reverse-mode gradients through the SCM for the strategies I thought would be hardest, along with validation of that the results are accurate and flushing out the real difficulties on the way

I have opened #559 with a design roadmap for what comes next using this branch as the basis and keeping the tests you developed for regression.

Where #559 goes further is arguing that most of the surrounding machinery isn't needed. plant
already links odelia, which is where the ODE solver, the differentiable spline, and the XAD tape
live. Build on that and a lot falls away: the gradient becomes run_mutant/run differentiated
rather than three *_emergent.cpp engines; the R-side Rcpp::as<Environment> round-trip becomes
a native boundary; stand_stage_history collapses into one "record the adaptive nodes, replay
them fixed" step; <T,E,S> goes back to <T,E>; five hand-managed tapes become odelia's one.
The reasoning is all in the PR.

A useful side effect: because that machinery belongs in odelia rather than plant, it becomes
general AD infrastructure — reverse-mode gradients and Jacobians for any model built on odelia,
not just the SCM.

So the recommendation is to hold off merging the prototype and land the small odelia surface
first, using these Jacobians as the oracle, then port the plant path onto it. Merging as-is means
taking on a second tape lifecycle and scalar axis that the next step just unwinds. Would be good
to get your read on the direction before anyone starts building.

PS. I got here by working in a meta-repo with plant and odelia as submodules, which gave Claude access to both codebases and XAD at once (rather than working on plant with odelia installed).

@dfalster

dfalster commented Jul 5, 2026

Copy link
Copy Markdown
Member Author

Thank you @aornugent ! I agree 100% with your proposed directions. I'll hold off on merging this as we develop a better long-term solution.

@dfalster

dfalster commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Working detail, moved off the PR description so it does not land in git log. Under squash merge the description becomes the commit message verbatim; this comment is just as permanent and is linked from the squashed subject. See the family guideline.

Original description, unchanged:


Status: prototype

This is a prototype of automatic differentiation in plant — a working,
end-to-end-validated spike that establishes the capability and the techniques, not a
finalised API
. It is deliberately additive and bit-identical to the reference model
(see below), so it is safe to land as a foundation, but the access-point names, the engine
structure, and several honest-scope gaps are expected to be refined later (see
"Deferred" and notes/ad-refactor-optimize-roadmap.md). Suggest we land in develop then refine in a new feature branch.

Summary

Adds exact reverse-mode automatic differentiation of plant's SCM outputs w.r.t.
traits and the birth-rate driver, for FF16, TF24 and TF24f (#472 scope B, #537). One
backward sweep returns the derivative of a scalar emergent output w.r.t. all inputs at
once — the "many inputs → one scalar" shape of a calibration objective or a selection
gradient — at machine precision, with no per-trait re-run and no step-size lottery.

The full narrative (how it was built, the techniques, the validation, the honest scope)
is in the guide added here: overstorey-staging/guides/autodiff-trait-gradients.qmd.
This description is the map; the guide is the territory.

The discipline: additive and bit-identical

The C++ core gains a third template axis — the scalar type S, defaulting to
double. Every differentiable piece is templated on S; AD only ever happens in C++ by
instantiating with an XAD active type (odelia's reverse-mode tape). Because S defaults
to double, every existing use and every line of the RcppR6/R interface still names the
double type and is bit-for-bit unchanged. The ordinary model, and the reference test
suite, are untouched — the AD is purely additive. The whole spike landed as a long series
of individually-verified, bit-identical steps rather than one rewrite.

Two recurring principles do all the work (both detailed in the guide):

  1. Never differentiate a solver — differentiate its converged point. Root-finds via
    the implicit function theorem, optimisations via the envelope theorem, adaptive
    integrators via a frozen-schedule replay (a double discovery pass finds the
    schedule + resident light; a second pass replays it with the trait active).
  2. The derivative of a built quantity must come from the same construction as the
    value
    — the same spline, the same harvested operating point.

Prototype access points

The prototype surface (names/signatures may change on refinement). All dispatch on
strategy; all are ordinary compiled package calls (no on-the-fly sourceCpp). Given a
run_scm(save_RK45_cache = TRUE) resident:

Call Differentiates Strategies
stand_gradient(scm, metrics, traits, species, feedback) metrics × traits Jacobian of emergent stand outputs (canonical entry) FF16, TF24f (all metrics); TF24 (offspring only)
offspring_production_gradient(scm, traits, species) d(offspring_production)/d(trait) — the invasion / selection gradient FF16, TF24, TF24f
stand_state_jacobian(scm, traits, species) per-cohort state × trait Jacobian (escape hatch for any downstream metric) FF16, TF24
grow_individual_to_size_gradient(individual, sizes, size_name, env, traits) d(t*)/dθ and total d(state at t*)/dθ for one plant in a fixed env FF16, TF24f
birth_rate_gradient(scm, metrics, species) the birth-rate axis: resident d(census)/d(birth_rate) and d(net_reproduction_ratio)/d(birth_rate) = dR0/db (demographic equilibrium) FF16

feedback = "frozen" (default) is the rare-mutant invasion gradient (canopy held fixed);
feedback = "resident" is the resident-total gradient via a coupled whole-stand replay
(every trait re-shades the canopy; on a multi-species stand, the cross-species total).

Validation

  • Whole test suite green, unchanged reference behaviour: FAIL 0, PASS 2610 (make test / plain-R load_all); the AD-specific tests run against the installed DLL via
    make test-ad (FAIL 0, PASS 362).
  • AD-vs-AD regression fixture (tests/testthat/fixtures/gradient-baseline.rds,
    scripts/gradient_fixture.R) pins every compiled gradient engine to its validated value
    — bit-identical for the relocation/frozen paths, noise-floor for the coupled paths.
  • AD-vs-FD throughout: each engine is validated against a finite difference over the
    same reconstruction (the honest contract), plus the physics where available.
  • FF16 emergent gradients are faithful to ~1e-13…1e-8; TF24/TF24f to the leaf-optimiser
    floor (~5e-7), as expected for a re-solved optimum.

Honest scope & known limits

Stated plainly in the guide's "Where it works, and where it breaks" section (swept
empirically across patch lifetime and species count):

  • FF16 has no hard break in the tested envelope (patch lifetime → 200, → 5 species).
    Soft limits only: cost grows with cohort count (trait-count-free by design), and the
    frozen-grid reconstruction drifts gently (~1e-6 relative at long horizon).
  • The coupled/resident gradients are the frozen-grid total — they freeze the ODE
    schedule + knot positions and re-evolve the stand. Against a full run_scm FD (which
    lets the adaptive grid re-respond) they differ ~16–30 % for geometry traits (~half is
    genuine grid response). This is the invasion/selection gradient's exact object; consumers
    wanting the fully-adaptive derivative should know the gap.
  • TF24f resident census single-species gates at patch lifetime ≳ 5, and multi-species
    resident gates on a refined (clustered) schedule — both raise a clear error, never a
    NaN. Root cause is the frozen-step replay meeting a stiff log_density ↔ canopy
    feedback the live SCM only tames by adaptive stepping. Plausibly the same family as
    [TF24 hydraulics] SCM cohort-density blow-up under extreme seasonal drought #550
    (the SCM size-density blow-up under drought) — same d(log_density)/dt = −dg/dh − mortality equation with a large dg/dh from the TF24 leaf — but a distinct
    manifestation: here run_scm stays healthy and the true gradient exists; only the replay
    diverges. [TF24 hydraulics] SCM cohort-density blow-up under extreme seasonal drought #550's root fixes (NSC [TF24 nsc] Enable resource storage, presumably NSCs, to buffer short-term variation in productivity #517 / hydraulic-optimum continuity) would help both.
  • TF24 census/resident metrics are not available (only offspring_production); the
    census number density needs a leaf-optimiser cross-sensitivity the linearised harvest
    does not capture. TF24f (analytic tracked collar) gets census instead.

Every access point is finite-and-validated or gated-with-an-error — never
plausible-but-wrong
, which is the property a downstream consumer needs.

Deferred (downstream / follow-ups)

Calibration, likelihoods, and adaptive-dynamics / equilibrium solvers live in downstream
packages (e.g. regnans) — plant's job is to expose the gradient cleanly. Follow-ups
noted in notes/ad-refactor-optimize-roadmap.md: TF24 census; the ResidentHarvest seam
for multi-patch/stochastic variants; standalone Leaf calibration; TF24f resident
birth-rate; cross-species net_reproduction_ratio; and (the one change that would retire
most gated cases) adaptive sub-stepping in the replay.

Notes for review

  • Large but layered. ~17.5k net lines / 143 commits, but additive and bit-identical to
    the reference model at every step (the reference suite never changed). The C++ AD engines
    live in src/{ff16,tf24,tf24f}_emergent.cpp + inst/include/plant/gradient/ +
    inst/include/plant/models/*_production_kernel.h; the R API in R/emergent_gradient.R
    and R/tf24*_emergent_gradient.R.
  • The foundation slice (<T,E,S> templating + exact AD growth-rate gradient) was PR [AutoDiff] Scalar-template the state/params + exact AD growth-rate gradient (#472 scope B, Milestone C foundations + #537 A1) #541,
    now closed and folded in here as branch ancestry.
  • The guide is a staging copy destined for the Overstorey site; it builds no figures.

🤖 Generated with Claude Code

@dfalster dfalster changed the title [AutoDiff] Prototype: reverse-mode trait & birth-rate gradients of FF16/TF24/TF24f SCM outputs (#472 scope B / #537) Prototype reverse-mode AD of SCM outputs Aug 5, 2026
aornugent pushed a commit to aornugent/plant-dev that referenced this pull request Sep 13, 2026
traitecoevo/plant#553 answers the same question from a replay of the
solve; this stack answers it from the solve. Records the seven places
that difference reaches an answer, with what each project's own code
and documentation say, and what #553 covers that this does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzrkLH354kPRRYcKzgMLuB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants