Skip to content

R_d rises with temperature, and is settable (#41) - #86

Merged
dfalster merged 13 commits into
masterfrom
feat/rd-to-vcmax-trait
Aug 10, 2026
Merged

R_d rises with temperature, and is settable (#41)#86
dfalster merged 13 commits into
masterfrom
feat/rd-to-vcmax-trait

Conversation

@dfalster

@dfalster dfalster commented Aug 10, 2026

Copy link
Copy Markdown
Member

R_d was rd_to_vcmax_ratio_ * vcmax_(T), so it inherited Vcmax's PEAKED
Arrhenius and FELL above the thermal optimum, where real dark respiration
rises. No value of the ratio fixes a function of the wrong shape. Now
Tjoelker's declining Q10, with R_d_25 a plain trait -- default 1.44, the
same double 0.015 * 96 gave, so the defaults are unchanged.

Respiration no longer follows vcmax_25, and results move at every
temperature but 25 C, where agreement is exact by construction. plant's SCM
is bit-identical at its defaults. The golden grid gains a 40 C block because
it was blind to all of this. Measurements and traps in the comments.

dfalster and others added 9 commits August 10, 2026 16:26
Binding the temperature-response parameters made them writable but not
effective. The cache was keyed on (leaf_temp_, atm_o2_kpa_) alone,
justified by "same inputs -> bit-identical outputs" -- true while those
parameters were unreachable C++ members, false once they were bound.

Silent: setting rd_to_vcmax_ratio_ on a solved leaf and re-supplying the
same drivers left R_d at 1.44 where a fresh leaf gave 2.88, with
assimilation unchanged to every digit.

The key now covers every scalar the block reads, which also closes the
silent half of hazard 10 for vcmax_25/jmax_25.

Golden bit-identical; 2.99 vs 3.00 us/solve interleaved x3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
R_d_25 is a new parameter in umol m-2 s-1 -- what a caller with a
measured respiration sets. Its default is a NaN SENTINEL meaning "derive
as rd_to_vcmax_ratio_ * vcmax_25", not a value computed at construction:
a computed default would silently decouple respiration from Vcmax for
every caller that sets vcmax_25 afterwards, which is what plant does.

The response is now Q10 (settable, default 2.0) on that reference, in
place of inheriting Vcmax's peaked Arrhenius, which made R_d FALL above
the thermal optimum.

Moves results at every T except 25 C, where agreement is exact. Measured:
-0.2% at 15 C, -12.5% at 35 C, -73% at 40 C, and NO OPERATING POINT at
45 C -- psi_stem_to_ci cannot bracket a root once net assimilation is
negative throughout. The compensation-point fallback for exactly that
case fires only on the energy-balance path.

Golden bit-identical BY CONSTRUCTION: its grid is all at 25 C.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A constant Q10 = 2 was implemented first and measured too aggressive at
the top of the range: R_d 4.07 at 40 C, -73% on A, and no operating point
by 45 C. That is a constant Q10 used outside where it holds, which is
what Tjoelker et al. (2001) measured.

Q10(T) = 3.09 - 0.043*(T+25)/2, evaluated at the midpoint as the
land-surface literature implements it. Blast radius now +0.1% at 15 C,
exact at 25 C, -9.7% at 35 C, -53% at 40 C.

Setting the slope to zero recovers a constant Q10 exactly, asserted.

45 C still has no operating point -- the fallback comes next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The compensation-point fallback was gated on use_energy_balance_ because
the prescribed-temperature path "never reaches here". #41 made that
false: a Q10 respiration drives assimilation negative across the whole
[gamma*, ca] bracket by ~45 C, and the prescribed path then threw where
the PM path shut down -- same physics, two outcomes, decided by how
leaf_temp was obtained.

The gate is not simply removed, which would mask real solver failures:
the target is monotone over (gamma*, ca], so "no root exists" is
distinguishable from "the solver broke" by the sign at both ends. Only
the former shuts down.

At 45 C the leaf now reports ci at the compensation point and net
A = -R_d = -3.618 instead of aborting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wires R_d_25 through leaf_traits()/set_traits() and into the gradient
enumeration, where it goes LAST rather than in set_traits' argument
order: inserting it at 13 would displace par_kmax and par_resistance,
and C++ indexes theta by position.

The NA sentinel -- "derive as rd_to_vcmax_ratio_ * vcmax_25" -- is
resolved where theta is built rather than carried into it. That fixes
the batch path, which read it as a missing trait, and makes R_d_25
differentiable at its default rather than only once a finite value is
set, which is what a calibration needs.

dA/dR_d_25 = -0.24874 against a central difference of the solve at
-0.24868.

⚠️ dY/dvcmax_25 is now a PARTIAL at fixed respiration, where it used to
carry rd_to_vcmax_ratio_ * dY/dR_d_25 as well. Moves 14 of 60 recorded
gradient cells, all of them vcmax_25, by 16-250%; nothing else moves,
and the chain rule reconstructs every old value. gradient_golden.tsv
regenerated deliberately, with R_d_25 recorded at all five points.

Also adds the NEWS entries for the three landed commits that had none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rd_tracks_vcmax_ restores R_d = rd_to_vcmax_ratio_ * vcmax_(T), Vcmax's
peaked Arrhenius, bit-identically to the pre-#41 model.

⚠️ This closes a reproducibility hole we made rather than adding an
option. No combination of rd_q10_intercept_ and rd_q10_slope_ produces a
PEAKED function, so without it no published plant result computed under
the old shape could be regenerated. It also makes the plant revalidation
a controlled A/B instead of an argument.

In the temperature cache key, because flipping it changes R_d at every
temperature but 25 C and nothing else the key mentions -- a key that
missed it would take a cache hit and run the whole comparison under
whichever shape was seated first. Setting it with a measured R_d_25 is
refused rather than resolved.

The blast-radius table's "old" column is now the flag rather than an
emulation of it; its printed numbers are unchanged. plant binds Leaf's
fields by name, so it needs its own line for this one.

3.03-3.04 us/solve on both arms, interleaved x3 from one tree, identical
checksums. Golden bit-identical; 496 C++ checks, 940 R.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The inline consumer/main.cpp in cpp-tests.yml initialises theta with a
brace list, which compiles and zero-fills whatever it omits. Appending
R_d_25 to the enumeration therefore made it pass 0.0 -- a leaf with no
respiration -- with no diagnostic, and its arbitrated dA/dvcmax_25 check
still carried the pre-#41 total derivative.

Both fixed and verified by extracting the two heredocs and building the
consumer locally, which is the only way to see this: dA/dvcmax_25 =
0.020934892290554417, matching the recorded gradient, profit unchanged,
exit 0. ctest 2/2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
⚠️ The golden file was blind to every temperature response in the model.
Its 288 points were all at 25 C, where the reference values of Vcmax,
Jmax and R_d are DEFINED -- so a change to any response curve is inert
there by construction. #41 changed R_d's shape at every temperature
except 25 C and this file did not move a bit.

Now the same state grid at 15 / 25 / 35 / 40 C, with a leaf_temp column.
40 C is included deliberately: it is where the response bites hardest,
and the shut-down rows at the hot end pin the limit.

Deliberate regeneration, and checkable as an ADDITION: temperature is
the OUTERMOST loop, so the 25 C block is BYTE-IDENTICAL to the pre-#41
file in all nine output columns. 864 rows added, none moved.

The classification is counted per temperature now, because it moves --
dry-pinned 18/18/3/0 against wet-pinned 24/24/43/80. A hot leaf has less
to gain from water, so its optimum presses against the WET bound. The 48
shut-down rows are temperature-independent, as hydraulics rather than
heat is what forbids transpiration there.

bench_solve deliberately stays at 288 points at 25 C: a timing baseline
is only useful against its own history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
⚠️ Pinning leaf temperature at 25 C would have proved nothing: #41 is
inert there by construction, so bit-identicality only says the reference
point survived. Both new harnesses vary temperature.

Three arms over the 1152-point grid, against origin/master:

  default at 25 C only                       0 of 2592 cells differ
  rd_tracks_vcmax_ = TRUE, all four temps    0 of 10368 cells differ
  default, all four temps                 6018 of 10368 cells differ

The middle arm is the result: with the old shape restored, everything
else in #41 -- cache key, settable R_d_25, shut-down exit, R API -- is
jointly bit-identical to master. A moves +0.05..+3.5% at 15 C, exactly 0
at 25 C, -5.7..-100% at 35 C, -35..-213% at 40 C; no row changed
feasibility.

plant, TF24 SCM offspring production: BIT-IDENTICAL at plant's defaults
(TF24_Environment pins leaf_temp = 25 with PM off, so #41 is inert
there), -7.9% at a 30 C driver, /331 at 35 C, and -47.2% with
Penman-Monteith on -- where leaf temperature reaches 62 C, +22 K above
air, and Tleaf itself moves through the transpiration coupling. plant's
leaf/TF24 tests: 486 checks, 0 failures, and its one error is present
identically on the control.

compare_with_plant.R still does not run; its header blames the wrong
thing and PLAN 41 records the real obstacle.

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

Copy link
Copy Markdown
Member Author

The measurements

Blast radius of the shape change, at the defaults

T R_d old → new A old → new
15 °C 0.669 → 0.646 6.1505 → 6.1572 +0.11%
25 °C 1.440 → 1.440 5.5992 → 5.5992 exact
35 °C 1.610 → 2.592 3.4924 → 3.1550 −9.66%
40 °C 1.012 → 3.171 1.8265 → 0.8555 −53.16%
45 °C 0.507 → 3.618 0.6339 → −3.6176 shut-down

The 25 °C row is exact rather than approximate: the reference value defaults to
rd_to_vcmax_ratio_ * vcmax_25, and vcmax_(25) == vcmax_25 by construction of the
peaked Arrhenius.

Three arms, over the 1152-point golden grid, against origin/master

Harness: tests/validate/rd_shape_grid.cpp (one tree, three binaries, per hazard 5).

arm cells differing what it isolates
default, 25 °C only 0 of 2592 the control
rd_tracks_vcmax_ = TRUE, all four temperatures 0 of 10368 the shape change alone
default, all four temperatures 6018 of 10368 the blast radius

The middle arm is the result of this PR. With the old shape restored, everything
else in #41 — the cache key, the settable R_d_25, the shut-down exit, the whole R
API — is jointly bit-identical to master. That is proved rather than argued, and it
is what the escape hatch was worth adding for.

Assimilation, relative change by temperature:

T min median max
15 °C +0.05% +0.13% +3.51%
25 °C 0 0 0
35 °C −100% −10.86% −5.67%
40 °C −213% −62.35% −34.95%

Per field, worst / median relative: assim 1 / 0.109, profit 1.91 / 0.082,
ci 0.615 / 0.078, psi_stem 0.484 / 0.022, the four flux fields 1 / 0.054.

No row changed feasibility — the 48 shut-down rows per temperature are the same
rows. Of the 864 rows whose A moves, 269 keep their exact operating point: 144
shut down (E = 0), and 125 sit pinned to a bound that temperature does not shift, so
only their carbon balance moves.

plant revalidation, deliberately not pinned at 25 °C

Harness: tests/validate/rd_shape_arms.R. TF24 SCM offspring production, one
species, max_patch_lifetime = 5 — the setup plant's own regression test uses.

plant configuration phylloptim master this branch change
default (leaf_temp = 25, PM off) 81.857087216483691 81.857087216483691 bit-identical
leaf_temp driver = 30 °C 24.283073719078399 22.369992929645893 −7.9%
leaf_temp driver = 35 °C 2.1302e-04 6.4472e-07 ÷331
Penman-Monteith on 2.2034981948510728 1.1638497249192270 −47.2%

⚠️ plant pins 25 °C itself, which is what makes the first row exact rather than
lucky: TF24_Environment sets leaf_temp as a constant extrinsic driver at 25 and
use_energy_balance defaults to 0. So a default plant run is unaffected, by
construction, and the exposure is entirely in the two configurations that leave 25 °C.

⚠️ The PM row is the one to carry forward. Leaf temperature on that path runs
19.1 to 62.0 °C — up to +22 K above air (over PAR 400–2000 × Tair 20–40 × VPD
1–3), so a PM leaf at 25 °C air already sits at 32.9 °C and loses 23% of its mean
assimilation. Tleaf itself moved on 22 of 90 cells: the energy balance couples
back through transpiration, so on that path leaf temperature is an output, not an
input.

plant needs no source change — it never calls set_traits, so the fourteenth
argument is invisible to it, and it binds only R_d_ of the respiration fields. Its
leaf/TF24 test files give 486 checks, 0 failures and one error, and that error is
present identically on the control, so it is pre-existing on plant develop.

The gradient reallocation, which is the one thing a caller must know

Making R_d_25 a parameter turns dY/dvcmax_25 from a total derivative into a
partial at fixed respiration. It used to carry ratio * dY/dR_d_25 as well.

Of the 60 cells in tests/testthat/gradient_golden.tsv, 14 moved and all 14 are
vcmax_25
, by 16% to 250%; every stem_b, psi_crit,
leaf_specific_conductance_max and resistance cell is bit-identical, as is every
solved value. The 250% is the five-layer row, where the two terms nearly cancelled
(8.6e-06 total against 3.0e-05 partial), so dropping one is not a small edit.

The chain rule reconstructs all 14 old values — exactly where both arms are exact,
and to 2.8e-04 at worst where they are not. That residual is the direct term's own
finite-difference round-off, not a modelling discrepancy: it falls to 2.5e-06 at
step = 1e-4 and rises to 0.030 at step = 1e-8, i.e. coarser is better there.
Asserted as the identity rather than left as two unrelated recorded numbers.

Cleanest case, a shut-down point where A = -R_d exactly:

before now
dA/dvcmax_25 −0.015 exactly 0
dA/dR_d_25 (no such column) −1, to 6e-11

Verified against a finite difference of the solve: dA/dR_d_25 = -0.24874 against
−0.24868 at one observation. ⚠️ Not ≈ −1, because the optimiser re-optimises the
operating point, so the total derivative is well below the direct −R_d term.

Cost

Cache key 3.03–3.04 µs/solve on both arms, interleaved ×3 from one tree, with
identical checksums — inside the ±0.01 within-process noise. The key is compared once
per set_physiology(), not per solve iteration.

What was rejected, and why

A constant Q10 = 2 was implemented first and rejected on measurement: R_d 4.07
at 40 °C (2.8× its 25 °C value over 15 K), −73% on assimilation, and no operating
point at all by 45 °C
. That is not the physics; it is a constant Q10 used far
outside where it holds, which is what Tjoelker measured. Slope zero recovers a
constant Q10 exactly, and that is asserted — it is the escape hatch for anyone who
wants the conventional form.

⚠️ Slope zero does NOT recover the pre-#41 shape, which was peaked. That is why
rd_tracks_vcmax_ is a separate flag and not a parameter value.

Extending the generated Leaf constructor to take R_d_25, instead of assigning
the field after construction. Not taken: its argument list is positional, seventeen
long, and pinned by the raw-versus-friendly comparison in test-surface.R. The cost
is recorded — R_d_25 is now listed in the constructor-partition test as a trait the
constructor does not take, with a test that the assignment is really there.

Traps, each of which cost real time

⚠️ NaN == NaN is false. The temperature cache key is compared with ==, so the
raw sentinel must never go in it — it keys on rd_reference(), the resolved value.
Putting R_d_25 in directly silently disables the cache for every default caller, and
nothing notices.

⚠️ The parameter enumeration deliberately does not follow set_traits' argument
order.
R_d_25 is set_traits' fourteenth argument and the enumeration's
sixteenth, appended after the two non-traits, because slotting it in at 13 would
displace par_kmax and par_resistance — the reorder gradient.hpp warns is unsafe.
Appending is safe; reordering differentiates the wrong parameter and returns plausible
numbers. test-gradient-batch.R asserts the divergence in both directions.

⚠️ .gradient_setter's positional trait call is where this broke. Its own comment
predicted that "if a trait is ever added, this call is the one place that will not fail
to compile" — and that is exactly how it went, at run time, with argument R_d_25 is missing raised inside the generated binding, naming neither the call nor the count.

⚠️ The batch path read R_d_25 as a missing trait, and the message was worse than
useless
: `traits` is missing: NA, where the NA was the name of a trait that
does not exist. .gradient_theta_matrix() assumed the traits were the leading block
of par_names and sliced off "the last two", which now takes
leaf_specific_conductance_max. Both ends are placed by match() now.

⚠️ A theta column cannot hold the sentinel — and not only because of the validator.
A parameter whose value is "derive it" is not differentiable: perturbing NA gives NA.
Resolving it where theta is built is what makes R_d_25 differentiable at the
default
, which is what a calibration starting from the defaults needs.

⚠️ The CI consumer is a fourth C++ program, in a heredoc in cpp-tests.yml, that no
local build covers.
Its theta is brace-initialised, so appending to the enumeration
silently zero-filled the new element — a leaf with no respiration — and its arbitrated
dA/dvcmax_25 still carried the pre-#41 total derivative. Both fixed and verified by
extracting the two heredocs and building it locally: dA/dvcmax = 0.020934892290554417,
profit unchanged, exit 0, ctest 2/2.

⚠️ R_LIBS falls back to the site library in silence. It happened once here,
mid-install, and produced a plausible file describing the wrong package. Both new
harnesses hard-fail on the wrong library rather than warning, because every number
they print is plausible for either arm.

⚠️ use_energy_balance lives in plant's strategy$pars. Assigning it at the
strategy's top level is accepted and silently ignored, and the run comes back exactly
equal
to the default arm — which reads as "PM changes nothing" rather than as a broken
switch. Asserted after setting.

⚠️ Doxygen 1.9 on CI rejects an indented block inside a function body, so the Q10
comparison table lives in the doc block above
update_temperature_dependent_params, not inline.

Corrections to the record

The golden grid, and why it needed the axis

⚠️ The golden file was blind to every temperature response in the model, and had
been all along.
288 points, all at 25 °C — where the reference values of Vcmax,
Jmax and R_d are defined, so a change to any response curve is inert there by
construction. #41 changed R_d's shape at every temperature except 25 °C and the file
did not move a bit. The same blindness covered the thirteen temperature-response
parameters bound in the first commit.

Now 15 / 25 / 35 / 40 °C, 1152 rows. 40 °C is deliberate: it is where the response
bites hardest, and the shut-down rows at the hot end pin the temperature at which the
model stops having an operating point, which is a limit that moved here.

The regeneration is checkable as an addition rather than taken on trust.
Temperature is the outermost loop, so the file is four contiguous copies of the
original grid and the 25 °C block is byte-identical to the pre-#41 file in all nine
output columns. 864 rows added, none moved.

The classification is counted per temperature now, because it moves:

T interior pinned wet pinned dry shutdown
15 °C 198 24 18 48
25 °C 198 24 18 48
35 °C 194 43 3 48
40 °C 160 80 0 48

A single total would have let points move between branches and still add up. The
direction is physical: a hot leaf assimilates less and respires more, so it has less
to gain from water and its optimum presses against the wet bound instead of the
dry one. The 48 shut-down rows are temperature-independent, which is also right —
those are the ψ_soil = 6 MPa rows, where hydraulics forbid transpiration whatever the
temperature.

Two things the hot end does not reach, so the zeros are not read as coverage:
shade-death (reached by light, not by heat) and the compensation-point exit, which
needs about 45 °C at the defaults. test_rd_temperature_response covers the latter.

Still not addressed, and arguably the bigger question

R_d now rises with temperature, which was the ask. The calibration finding that
motivated it is not explained: leaf_calibration_test's ManyPeaksRange misfit is
reducible (a within-site random forest gets R² 0.45 on A where phylloptim gets
−0.27), and the carbon-shape defaults were refuted as the cause by a control.
R_d was the last untested carbon-side candidate. That study can free it now —
sabot-2022/R/ LEAF_OPTIONAL_PARS is where it would go — and that is the test of
whether any of this actually helps.

Verification

  • C++ suite 496 checks, 0 failures; golden 1152 points, all bit-identical
  • R suite 940 checks, 0 failures, 1 skip
  • R CMD check: 1 NOTE, pre-existing (tibble not declared for a vignette)
  • make -C tests/cpp bench builds (not in all, but CI builds it)
  • CMake no-R build + ctest 2/2, and the installed-package consumer exit 0

Linux CI: `dA/dR_d_25` at interior-1layer disagrees by 1.3e-03 relative,
against 1.3e-04 for every gradient column that was there before, so the
tolerance borrowed from the solved-output baseline (1e-03) failed.

Not a regression, and the mechanism is exact. A gradient here is a finite
difference, so it carries the solve's ~1e-9 floor DIVIDED BY THE STEP,
and the step is relative -- so the smallest-magnitude parameter sets the
tolerance for the whole file. R_d_25 is 1.44 where vcmax_25 is 96: a 67x
smaller absolute step. The arithmetic closes at 1.3e-03 x 0.2487 x 2h =
9.3e-10, the floor itself, and a step sweep agrees (at step 1e-7 the
R_d_25 column has moved 1.3% and vcmax_25 0.2%).

gradient_golden_tolerance() is 5e-03, 3.8x the one observation there is.
Verified by forcing the tolerant branch that macOS never takes: the CI
disagreement passes, the old 1e-03 fails it, and a real 5% regression
still fails.

Also corrects three stale cross-platform figures found while reading the
CI summary line: test_golden.cpp's header carried the pre-11a pair with a
compiler split 11a removed, and the guide's profit row said 1.82e-07
where CI now reports 2.14e-09.

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

Copy link
Copy Markdown
Member Author

Follow-up: the recorded gradients needed their own tolerance (e714703)

The first CI run caught something worth writing down, on Linux only:

gradient_golden.tsv: worst relative difference 0.0013 (compared with tolerance)
── Failure: the recorded gradients have not moved ──
Expected interior-1layer R_d_25 A to equal `expected`.
  `actual`: -0.2484      `expected`: -0.2487

Not a regression — the tolerance was borrowed and the borrowing was wrong. This
file used golden_tolerance() (1e-03 for the argmax class), which is the
solved-output baseline's policy. But a value in gradient_golden.tsv is a finite
difference of the solve
, so cross-platform it carries the solve's ~1e-9 floor
divided by the step — one amplification more than the solved outputs it is built
from. The step is relative, so the amplification is set by the differentiated
parameter's own magnitude, and the smallest parameter in the file sets the tolerance
for all of it
. That used to be stem_b at 3.9; since this PR it is R_d_25 at
1.44, against vcmax_25 at 96 — a 67× smaller absolute step.

The arithmetic closes on the floor exactly: 1.3e-03 × 0.2487 × 2h = 9.3e-10 with
h = 1e-6 × 1.44. A step sweep agrees independently — at step = 1e-7 the R_d_25
column has already moved 1.3% while vcmax_25 has moved 0.2%, so it is the column
that goes noise-dominated first:

step dA/dvcmax_25 dA/dstem_b dA/dR_d_25
1e-04 0.020939751 2.8465363 −0.24873735
1e-05 0.020939195 2.8465389 −0.24873711
1e-06 (default) 0.020934892 2.8465358 −0.24873740
1e-07 0.020894746 2.8474104 −0.25197702

gradient_golden_tolerance() is 5e-03, 3.8× the one observation there is, and it
cannot hide a real change: this PR's own reallocation moved these cells by 16–250%,
two orders above it. The values stay recorded at the default step, so the file
still pins what a default caller gets — recording them at a coarser step would have
been more portable and would have stopped pinning the default.

⚠️ macOS never takes the tolerant branch, so the fix was verified by forcing it:
the CI disagreement passes at 5e-03, fails under the old 1e-03, and a real 5%
regression still fails.

Three stale figures found while reading the CI summary line

The guide says "if you need a magnitude, read the summary line, not the FAIL lines".
Doing that turned up three comments that nothing asserts, so all three had rotted:

  • test_golden.cpp's own header carried profit 1.85e-06 / 5.87e-07 and the
    argmax class 5.53e-04 / 2.73e-04 — the pre-11a figures, two generations old,
    still showing a gcc-versus-clang split that 11a removed.
  • The developer guide's profit row said 1.82e-07; CI now reports 2.14e-09,
    both compilers identical, over 1152 points with 7702 of 10368 values differing.
    ⚠️ Deliberately not attributed: a bigger grid can only move a maximum upward
    and the 25 °C block is byte-identical, so the improvement is a change on master
    since the figure was recorded (Bound the root vulnerability curves past the grid #77 and Solve for leaf temperature inside the collar FOC #84 are the candidates) — and confirming
    that needs a cross-platform run of master, which no macOS box can give you.
  • The guide's gradient figure said 1.3e-4, which is the solved-output class, not
    this file's.

The sqrt(worst profit) ≈ worst argmax sanity check no longer holds numerically
(sqrt(2.14e-09) ≈ 4.6e-05 against 1.4e-04 observed) and the guide now says why that
is expected rather than alarming: the two column maxima fall at different operating
points, and the argmax column now has 864 hot rows the profit column's worst case is
not among. It was never a global identity.

@dfalster dfalster left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good implementation, but we have put in too much detail ensuring and documenting back compatibility. back compatibility is ensured by using the same values as was previously used. I don't think we need extra infrastructure. We should treat R_d_25 as a first class trait with no special fallbacks.

Also too much text in the comments, particularly related to past history. We want to simplify for the future.

Comment thread .claude/CLAUDE.md Outdated
Comment thread .claude/CLAUDE.md
Comment thread .claude/CLAUDE.md Outdated
Comment thread .github/workflows/cpp-tests.yml Outdated
// compared for the same reason profit is below.
if (std::fabs(g[0].grad[0] - 0.0172086) > 1e-5) {
//
// ⚠️ This read 0.0172086 until #41. That was the TOTAL derivative, with

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplify

Comment thread inst/include/phylloptim/gradient.hpp Outdated
inline constexpr int par_stem_b = 2;
inline constexpr int par_kmax = 13;
inline constexpr int par_resistance = 14;
// ⚠️ `R_d_25` IS A TRAIT AND IS APPENDED AT THE END RATHER THAN PUT IN

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's fix it properly with reorder

Comment thread R/gradient.R Outdated
# own finite-difference round-off: it falls to 2.5e-06 at `step = 1e-4`).
# `dA/dvcmax_25` at a shut-down point is the cleanest case -- exactly -0.015
# before, exactly 0 now, with the -1 moved to `dA/dR_d_25`.
.resolve_rd_25 <- function(R_d_25, vcmax_25, rd_to_vcmax_ratio) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove. Always require respiration to be set

Comment thread R/leaf-model.R Outdated
curv_fact_colim = 0.99,
cost_scale_TF24 = 7.5) {
cost_scale_TF24 = 7.5,
R_d_25 = NA_real_) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why no default?

Comment thread tests/validate/rd_shape_arms.R Outdated
@@ -0,0 +1,138 @@
# The #41 respiration-shape revalidation, ON THE PLANT SIDE.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed?

Comment thread tests/validate/rd_shape_grid.cpp Outdated
@@ -0,0 +1,112 @@
// The #41 respiration-shape A/B, on this package's own grid.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this needed?

Comment thread vignettes/phylloptim.Rmd Outdated
Review: back compatibility comes from using the same VALUES, not from
infrastructure, so the special-casing goes.

R_d_25 now defaults to 1.44 -- 0.015 * 96, bit-for-bit the double the old
vcmax-derived form gave -- with no NaN sentinel, no rd_to_vcmax_ratio_
(deleted, C++ and R bindings), and no rd_tracks_vcmax_ escape hatch. An
unset or negative value fails instead of deriving something.

The enumeration is reordered properly: R_d_25 is the fourteenth trait, in
set_traits' own argument order, with kmax and resistance after it. Every
index in gradient.hpp has a name, so nothing indexes theta with a bare
integer, and the R side needs no set difference to reconcile.

⚠️ Respiration no longer follows vcmax_25 -- the point of a trait, and
the one exception to "same defaults, same results". plant's TF24 SCM
offspring production is BIT-IDENTICAL at its defaults
(81.857087216483691), and moves when vcmax_25 varies: 0.40692 -> 0.34336
at 60, 73.703 -> 92.026 at 150. That coupling belongs in the caller's
parameterisation, where plant already derives its other parameters.

Golden grid cut to two temperatures, 25 and 40 C, so 1152 rows become
576; the 25 C block stays byte-identical. Comments cut throughout, the
two single-use validate harnesses removed, and their measurements kept
in PLAN 41.

486 C++ checks, golden bit-identical, 933 R checks, ctest 2/2, consumer
exit 0, R CMD check 1 pre-existing NOTE.

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

Copy link
Copy Markdown
Member Author

Done — all of it. c074647.

R_d_25 is a plain trait with no fallbacks. Default 1.44, which is
0.015 * 96 and — checked — the same double (0x1.70a3d70a3d70ap+0), so
back-compatibility comes from the value as you said. Deleted: the NaN sentinel,
rd_to_vcmax_ratio_ (C++ member and R binding; plant never used it), and
rd_tracks_vcmax_. An unset or negative R_d_25 now fails rather than deriving
anything. The special validator in leaf_traits() is gone too — .check_scalars()
covers it like every other trait.

Enumeration reordered properly. R_d_25 is the fourteenth trait, in set_traits'
own argument order, with leaf_specific_conductance_max and resistance after it.
par_kmax → 14, par_resistance → 15. All indices are named now
(par_vcmax_25par_resistance), so apply() no longer mixes theta[0] with
theta[par_R_d_25], and the R side is back to c(unlist(traits), kmax, resistance)
with no set difference to reconcile. The recorded gradients came out bit-identical
across the reorder
— it is purely positional.

Golden grid: two temperatures, 25 and 40 °C. 1152 rows → 576. The 25 °C block is
still byte-identical, so the earlier regeneration is still checkable as an addition.

Both validate harnesses removed, and their numbers kept in PLAN 41. rd_shape_grid.cpp
had to go anyway — it existed to drive the escape hatch.

Comments cut throughout: −1965 lines against +372. The history is out of
leaf_model.hpp, gradient.hpp, the YAML, the workflow, the tests, CLAUDE.md and the
vignettes; NEWS is five #41 sections down to three. Two stale comments turned up while
cutting: the Doxygen block above update_temperature_dependent_params still said R_d
"falls above the thermal optimum … recorded rather than changed", and the cache-key
comment still cited rd_to_vcmax_ratio_ as its example. Both fixed.


One consequence to flag, measured rather than argued

⚠️ Respiration no longer follows vcmax_25. That is the point of a trait, and it
is the one place "same values ⇒ back compatible" has an exception — a run that
varies vcmax_25 used to get proportionally more or less respiration. plant's TF24
SCM offspring production, rebuilt both sides:

phylloptim master this branch
default (vcmax_25 = 96) 81.857087216483691 81.857087216483691
vcmax_25 = 60 0.40692 0.34336 (−15.6%)
vcmax_25 = 150 73.703 92.026 (+24.9%)

The default arm is bit-identical to all 17 digits, so your premise holds exactly where
it matters: plant's vcmax_25 is 96 and it pins leaf_temp at 25 °C. The direction of
the other two is right — a high-Vcmax strategy no longer pays proportionally more
respiration. If a study wants that coupling, TF24_hyperpar is where plant already
derives its other parameters from traits, and that seems the right home for it. Flagged
rather than acted on.

And a correction to my own earlier justification

My argument for the escape hatch was that "no published plant result under the old
shape could be regenerated". The revalidation I then ran undercut it: plant pins
leaf_temp at a constant 25 °C with PM off, and the two shapes agree exactly there
— so those results are regenerable without any hatch. Removing it is right, and my
reason for adding it was wrong.

Verification

486 C++ checks, golden 576 points bit-identical, 933 R checks, ctest 2/2, the
installed-package consumer exit 0, R CMD check 1 pre-existing NOTE (tibble).
⚠️ make bench caught one thing make all could not: bench_gradient.cpp was
passing the old NaN sentinel and now aborts on the validation — fixed.

dfalster and others added 2 commits August 11, 2026 06:57
PLAN 41 quotes offspring-production numbers from a two-build comparison
with no harness behind it, so the recipe goes next to them: two
phylloptim libraries, two plant installs from clean sources, and a hard
find.package() check because R_LIBS falls back to the site build in
silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three edits from the previous round never landed -- they ran from a
drifted working directory -- so the developer guide still described a
1152-point golden grid at four temperatures, and both vignettes still
described R_d_25 as an appended parameter with a NaN sentinel. Redone,
and this time swept for every count rather than the ones I remembered:

  1152 / 10368            -> 576 / 5184, from CI's own summary line
  "4 leaf temperatures"   -> 25 and 40 C
  thirteen traits         -> fourteen, in nine files
  "A character vector of fifteen names" -> sixteen (src/gradient.cpp)
  "all 13 traits" in bench_gradient -> "13 of the 14", which is what it
                             measures; R_d_25 needs no rebuild and adds
                             nothing to the cheap/rebuild split
  the temperature-response block binds fifteen fields now, not thirteen

Also cuts the history the review flagged in the golden-file section: the
"it was 288 points until #41" paragraph, the 1.82e-07 retraction, and the
sqrt-story archaeology are gone, leaving the mechanism and the current
figures.

486 C++ checks, golden bit-identical, 933 R checks, R CMD check 1
pre-existing NOTE, bench builds.

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

Copy link
Copy Markdown
Member Author

No — you caught a real miss. 22d412b fixes it.

Three edits from the last round never landed. They ran from a drifted working
directory, so the file was written but somewhere else, and I reported success from the
tool's "ok" rather than from the file. The developer guide still described a
1152-point grid at four temperatures, and both vignettes still described R_d_25
as an appended parameter with a NaN sentinel — including a whole ⚠️ paragraph telling
readers to resolve a sentinel that no longer exists.

So this time I swept for every count instead of the ones I remembered:

was now
golden grid 1152 points / 10368 cells / "4 leaf temperatures" 576 / 5184 / 25 and 40 °C
"thirteen traits" in nine places fourteen
gradient_par_names() roxygen "a character vector of fifteen names" sixteen
bench_gradient's total line "all 13 traits" "13 of the 14" — which is what it measures
the YAML's temperature-response block "these thirteen" fifteen bound fields

The cross-platform figures in the table were not stale — CI reports the same
profit 2.14e-09 / argmax 1.4e-04 on the smaller grid — but the prose around them
quoted the old cell counts, so those came from CI's current summary line.

Two judgement calls in there:

  • bench_gradient stays at 13 traits. R_d_25 needs no spline rebuild, so it lands
    in the cheap bucket the split already has twelve examples of — adding it would change
    the recorded totals tools/bench_history.sh compares without measuring anything new.
    Labelled accurately rather than grown.
  • test-surface.R's "13 traits and 4 tolerances onto 17 positional slots" is
    correct
    and left alone: that is about the constructor, which still takes 13.

And the history you flagged is out of the golden-file section: the "it was 288
points until #41" paragraph, the 1.82e-07 retraction and the sqrt-story archaeology
are gone. What is left is the mechanism (flat maximum, sqrt(dp/k), why the two
classes sit five orders apart), the current figures, and the one instruction that
matters — read the summary line, not this table, because nothing asserts it.

486 C++ checks, golden bit-identical, 933 R checks, R CMD check 1 pre-existing NOTE,
bench builds, all six CI jobs green.

@dfalster
dfalster merged commit 26574f4 into master Aug 10, 2026
7 checks passed
@dfalster
dfalster deleted the feat/rd-to-vcmax-trait branch August 10, 2026 21:20
dfalster added a commit that referenced this pull request Aug 11, 2026
- Rebased onto master. `gradient_golden.tsv` regenerated in full on macOS/arm64;
  the four pre-existing columns are bit-identical to MASTER, not merely to the
  branch point, and the envelope measurement was re-taken after #84 and #86.
- Dropped `golden_tolerance(kind =)` for master's `gradient_golden_tolerance()`,
  which is the same fix with the better-reasoned number: it is the smallest
  differentiated parameter's step that sets it, not the argmax amplification.
- `gradient_output_names()` is exported and R reads it, so the output list has
  one definition rather than two that can disagree.
- profit's column is ASSIGNED from the direct term rather than reached by
  multiplying a zeroed dY/dpsi -- the treatment `collar` already gets, and immune
  to a non-finite dpsi/dtheta where `0 * x` would be NaN in one column alone.
- The shut-down profit column is asserted against its closed form: `A = -R_d`
  gives `dprofit/dR_d_25 = -1`, and the pinned collar gives `dcollar/dpsi_crit = 1`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant