Reach the vulnerability domain the splines claim - #99
Conversation
The knot grid accumulated `psi += step`, so it produced `resolution` or `resolution - 1` knots depending on b and c, and its upper bound was neither reproducible nor equal to vulnerability_psi_max. Both splines built from it disable extrapolation, so that edge decides whether a dry-end lookup throws. At the package defaults it fell one full step (1.0%) short. Indexed now (#92). Moves results: 3496 of 5184 golden cells, median 2.0e-15, largest absolute move 1.97e-10; recorded gradients 63 of 100, worst 5.1e-04. Nothing reaches the ~1e-4 band. Full split in the first PR comment. With the edge well defined, psi_crit is checked against it by name rather than failing inside the interpolator (#38) -- it is P95 of the stem curve, not a free trait, and that was documented nowhere. The thirteen traits are also readable from R now, read-only (#95). Closes the stale note on the temperature cache key (#55). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The defect, reproduced
The pair that does drop a knot is this package's own default: Worst interior knot displacement at the defaults is 1.22e-15 relative, at i = 95 — so the results move below comes almost entirely from the added knot, not from the reindexing. Blast radius:
|
| relative move | cells |
|---|---|
| < 1e-15 | 483 |
| 1e-15 – 1e-13 | 2177 |
| 1e-13 – 1e-11 | 427 |
| 1e-11 – 1e-9 | 284 |
| 1e-9 – 1e-7 | 54 |
| 1e-7 – 1e-5 | 71 |
| > 1e-5 | 0 |
The 71 cells above 1e-7 are the ones worth explaining, and they are a small-denominator artefact rather than a model change: all 71 sit at psi_soil = 3 with 5 layers, where the quantities themselves run from 1e-11 to 1e-3. The largest absolute move anywhere in the file is 1.97e-10; the median absolute move among those 71 is 1.0e-15. Worst relative is 5.65e-06, on assim = 1.53e-06.
Per field:
| field | moved | worst rel | median rel (moved) |
|---|---|---|---|
psi_stem |
421 | 3.19e-09 | 2.70e-15 |
opt_root_psi |
372 | 5.02e-10 | 2.25e-15 |
ci |
352 | 1.08e-09 | 3.37e-15 |
assim |
342 | 5.65e-06 | 1.46e-14 |
transpiration |
363 | 3.26e-06 | 1.39e-14 |
gc |
363 | 3.26e-06 | 1.39e-14 |
profit |
408 | 1.82e-07 | 2.01e-15 |
e_up |
437 | 3.01e-06 | 1.37e-14 |
uptake |
438 | 3.01e-06 | 1.32e-14 |
Point classification is unchanged: 198 interior / 42 pinned / 48 shutdown at 25 °C, 160 / 80 / 48 at 40 °C. No point changed branch.
Blast radius: tests/testthat/gradient_golden.tsv
63 of 100 cells, median 1.1e-08, worst 5.15e-04. Larger than the solved outputs, and it has to be: these are finite differences of the same solve, so they carry its floor divided by a ~1e-6 relative step. The file's own cross-platform tolerance is 5e-3, so the worst move sits an order inside the noise band it is already compared with elsewhere.
By case: interior-1layer 5.15e-04, pinned-dry-3layer 1.20e-04, single-potential 9.0e-08, interior-5layer 5.3e-08, shutdown-1layer 0 (it never reaches the collar solve).
The largest single cell, in absolute terms: interior-1layer / vcmax_25 / profit, 0.01052672793 → 0.01053214776, i.e. 5.42e-06 absolute.
The four R-side hex rows in test-golden.R moved 27 of 36 values, scaling with how dry the point is — 1e-15 at psi_soil 0.5 and 2, 1e-11 to 3e-11 at 4, and the shut-down row's nine unchanged.
⚠️ Two things this went wrong on first, recorded so they are not repeated
1. tools/gradient_golden.R regenerated the file from the SITE build. It does library(phylloptim), which resolves against the library path and not the tree you are standing in. Run from this worktree with uninstalled changes, it reproduced the old values exactly and reported "0 of 100 cells moved" against a suite that was failing on 63 of them. Same trap tools/bench_history.sh guards with --expect-version, and the same one the guide records for plant.
The script now refuses to run unless the loaded build's headers match inst/include/ byte-for-byte — R CMD INSTALL copies them into the package directory, so that is a direct answer to "is the loaded build this tree?", where comparing paths cannot work (an installed package is never in the tree) and comparing versions cannot either (0.2.1 both sides). Verified in both directions: refuses against the site build, runs against the installed tree and regenerates the committed file byte-identically.
2. A magnitude I asserted in a comment before measuring it. The test-golden.R header note first said the 27 moved values were "all at or below 3.5e-13 relative except assim/gc/transpiration at the 4-layer row". They are not — the split is by row, not by column. Measured and corrected before commit; flagging it because a number in a comment beside a regenerated file is exactly the kind of claim that is never re-checked.
What #38's check found inside this repo
The check rejects only combinations that already threw, so nothing that worked stops working — but two places in the tree held such a combination and had never solved through it:
tests/testthat/test-surface.Rbuiltleaf_model(leaf_traits(stem_b = 2.0)), leaving the defaultpsi_critof 5.870283 against a P99 of 3.5359. It passed because that test only callsproportion_of_conductivity, a closed form that never reads the spline.psi_critnow moves withstem_bthere.?leaf_traits's example wasleaf_traits(vcmax_25 = 120, stem_b = 2.5)— P99 = 4.42, so a reader copying it intoleaf_model()got the error the issue is about. Fixed, and?leaf_traitsnow states the P95 relationship.
test-gradient.R already worked around this at line 653 ("stem_b is raised, not lowered. #38: …"), which is corroboration that the constraint was real and being managed by hand.
The one test threshold that moved, and why it is not a loosening
test-gradient.R's expect_lt(abs(exact), 1e-12) now reads 1e-9. exact went from 2.4e-15 to 1.2e-10 on macOS/arm64 — the knot grid moved where the collar root-find lands inside its own tolerance, which is precisely the mechanism the comment above that assertion already documents for Linux (it records exact = 6.6e-11 there). 1e-12 was pinning a landing point; the guide names ~1e-9 as the solver floor, which is what the bound should have been.
What carries the test is the gap, and it is intact: exact 1.2e-10 against fd 2.1e-04 is a factor of 1.7e+06, with both bounds three or more orders inside it. Removing the assignment the test guards still fails it.
What #38 assumed and I did not do
#38 says "MultiLayerRoots::setup_vulnerability uses the same builder, so root_psi_crit presumably has the same latent constraint."
It does not, and #77 is why. root_vuln_from_psi has extrapolation disabled like the stem pair, but root_vuln_at clamps its argument to the last knot rather than letting the read fail. So a root_psi_crit past the root P99 silently reports the floor conductivity instead of throwing — a different defect, and the conductivity half of it is #85's open question. Adding a hard error there would reject configurations that work today, so the check is stem-only and says so at the declaration.
Verification
make -C tests/cpp— 503 checks, 0 failures; golden bit-identical after the deliberate regenerationmake -C tests/cpp bench_solve bench_gradient— both build (the targets CI builds andmake alldoes not)make test-cmake— 2/2 ctest, the no-R route a C++ consumer takesdevtools::test()— 1096 pass, 0 fail, 0 skip;gradient_golden.tsv: worst relative difference 0- The new tests were run against the old builder to confirm they have teeth: 712 of 1800 grids wrong count, 1577 ending short (worst 0.100000 relative, at
resolution10 where one dropped knot is 10%). - CI's inline consumer program constructs a default
phylloptim::Leaf, so psi_crit above the spline domain fails with an error naming neither psi_crit nor the curve #38's check cannot break it.
The knot-count sweep asserts x.back() == vulnerability_psi_max(b, c) bit-exactly, not with a tolerance. That is deliberate: the whole defect was an endpoint that was close and not equal, and with extrapolation disabled one ULP short is the difference between a lookup at vulnerability_psi_max working and throwing. A tolerance there would pass on the code the test exists to reject.
0.2.1 was never tagged and accumulated #41 (which moved results), #84, #86, #89, #90 and #91 on top of the change in this PR, so a consumer caching computed results had no signal that any of it had happened. That is #58's first ask, and this is the PR it applies to. The rule from here: a PR that moves results moves the minor version, in the same PR that regenerates the golden file. plant's `LinkingTo: phylloptim (>= 0.2.0)` floor should become `>= 0.3.0` when this merges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test-surface.R's `outputs_one_at_a_time` derived `uptake` with R's `sum()` and demanded bit-equality with `Leaf::operating_point_values()`. R's `sum()` accumulates in LDOUBLE -- 80-bit on x86-64, 64-bit on arm64 -- and the C++ loop accumulates in double, so the assertion held on arm64 and was luck on x86-64 for any sum of more than one term. #92 moved the values and the three-layer case came apart on ubuntu only: element [10] of twelve, 1 ULP. `Reduce("+", finite, 0)` accumulates left to right in double from the same zero the C++ starts at, so the comparison is bit-exact on every platform rather than relaxed on some. No shipped code is affected: `operating_point()` already reads the C++ value rather than recomputing the sum. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI found one thing, and it was not the changeAll seven jobs are green now, on both compilers and all three platforms. Getting there took one fix worth recording, because it looked like a results problem and was a measurement problem.
Element 10 is R's So the assertion had always been platform-dependent and had happened to hold. This PR moved the values and it stopped holding. Nothing about the code under test changed platform behaviour. Fixed by making the comparison exact everywhere rather than relaxing it: No shipped code is affected. Recorded in the guide next to the existing note on R's decimal parser, since it is the same shape: |
Stacked on #99 — base it there, not on `master`, because the fingerprint is a digest of the golden files #99 regenerates. Results moved on #15, #36, #41 and four gradient PRs without the version moving, so a consumer caching computed results had no signal its cache was stale. The calibration study's workaround was to hash `inst/include`, `R/` and `src/` — which #47's rename would have moved without moving a single number. `leaf_behaviour_fingerprint()` is a 12-character digest of the two recorded baselines together. They already *are* this package's definition of the numbers and are regenerated deliberately, so it inherits that discipline; `test-fingerprint.R` recomputes it from the files on disk, so regenerating a golden file and forgetting `Rscript tools/fingerprint.R` fails. File names are not in the digest and contents are hashed by line with the trailing `\r` stripped, so neither a rename nor a CRLF checkout can move it. Both asserted. #58's first ask — bump the version when behaviour changes — is in #99, which is the PR that moves results. Closes #58. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…angco
Four conflicts, all in leaf_model.hpp, all where this branch and master changed the
same expression for different reasons:
* three conductance conversions -- this branch replaced atm_vpd_ with vpd_leaf_,
master made H2O_CO2_stom_diff_ratio a settable member. Both changes kept.
* dprofit's early-return dE_dpsi -- this branch rewrote the derivation to state it
directly, because the coefficient master's version cancels is no longer a single
number once the deficit moves with Tleaf. This branch's version kept; it does not
reference the ratio.
R/RcppR6.R was regenerated rather than hand-resolved, and man/ + NAMESPACE were
stale after the merge and regenerated too.
Verified: 566 C++ checks / 0 failures, golden bit-identical, gradient golden
unchanged, fingerprint unchanged at 3e36d9b94fd0, 1137 R tests / 0 failures,
R CMD check --as-cran Status: OK, ctest 2/2, bench targets build, and the CI
consumer program built against an installed prefix exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The knot grid accumulated
psi += step, so it yieldedresolutionorresolution - 1knots depending on b and c, and its upper bound was neither reproducible nor equal tovulnerability_psi_max. Both splines built from it disable extrapolation, so that edge decides whether a dry-end lookup throws. At the package defaults it fell one full step (1.0%) short. Indexed now (#92).Moves results: 3496 of 5184 golden cells, median 2.0e-15, largest absolute move 1.97e-10; recorded gradients 63 of 100, worst 5.1e-04. Nothing reaches the ~1e-4 band. Split by cause in the first comment.
With the edge well defined,
psi_critis checked against it by name rather than failing inside the interpolator (#38) — it is P95 of the stem curve, not a free trait, and that was written down nowhere. The thirteen traits are readable from R now, read-only (#95). Closes the stale note on the temperature cache key (#55).Closes #92, closes #38, closes #95, closes #55.