Forward-model correctness fixes in TF24's leaf, soil and demographic paths - #585
Forward-model correctness fixes in TF24's leaf, soil and demographic paths#585aornugent wants to merge 27 commits into
Conversation
soil_moist_from_psi omitted the factor its forward direction applies, so the moisture it returned was scaled by 1e6^(1/n_psi) and could exceed saturation. Adds a round-trip test over the invertible moisture range.
The member is initialised alongside the other precomputed constants and is not yet read, so no forward number changes.
set_physiology resized soil_consumption_ without clearing it, and the solve writes only the layers a plant has roots in, so a shallow-rooted cohort billed the previously-solved cohort's deep-layer uptake to the patch water balance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
Environment gains n_resources(), which the individual and patch use in place of the environment's ODE width when allocating and accumulating consumption rates. Previously the trailing diagnostic state slots left entries that no strategy wrote, so the patch accumulated NA_REAL.
Both compute_competition overloads and the crown integral now use the eta-specialised multiply chains rather than a general pow, which moves the forward value in the last bits and shifts the accepted step grid.
The early exits that hold the stem at psi_crit left soil_consumption_ and E_up_ at whatever the previous solve or the last root-finder trial point wrote, so a plant that transpires nothing still drew water from the patch water balance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
q and Qp had no callers left once the crown integral read CanopyShape. Q stays: its remaining caller is the root mass distribution over soil depth, at a different shape exponent.
CanopyShape already computes the same expression, bit for bit, so the strategies read it rather than repeating it.
dprofit_droot_collar_psi read psi_soil_inverted_, which only the root-collar solve wrote, so calling it after a change of soil differentiated against the previous soil state. It now derives that vector from psi_soil_ itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
The size distribution starts at the boundary node, so it is the bottom point of the trapezium in Species::consumption_rate, as it already is in compute_competition. A species with one node then has two points and draws water rather than none, and light and water are reduced over the same heights. Offspring production moves +0.79% on TF24. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
…lity A node is born at height_0 and never stepped, so compute_rates has already written the net mass production rate that establishment probability is formed from, and recomputing it solved the leaf a second time at the same arguments. compute_initial_conditions now hands that rate over: one fewer leaf solve per species per stage in TF24 and FF16, values unchanged. The single-argument establishment_probability still evaluates at height_0 for R callers, and K93 gains the matching overload so the shared node path stays uniform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
introduce_new_nodes widened the state and rebuilt the light field without recomputing rates, and the solver then seeded its first RKCK stage from the rates of the previous state and field. A pre-existing cohort's rate was wrong by more than its own magnitude at 51 of 141 introductions on a production TF24 run; offspring production moves from 42.140173575095666 to 42.263060914614329 and accepted steps from 5055 to 5060. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
The crown base was a 0/0 division by z, so the leaf area density was NaN at z = 0 for every height, and the light field's lowest knot is exactly there. The u -> 0 limit is 0 for eta > 1 and 2/height at eta = 1. One division fewer per call, and the value moves in the last bits for z > 0.
Two sentences had collided on one line, taking it past 150 characters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
One multiplication chain now serves both exponents the profile needs, because u^eta is u^(eta-1) times u. That halves the chain functions, leaves one function pointer instead of two, and puts a single place where the exponent is applied. Each chain is named for the exponent it computes, so eta = 12 selects u_pow_11 rather than a name that states neither the exponent nor which of two families it belonged to. The crown-centre coordinate is one static formula both strategies call, replacing an instance accessor whose value was copied back into a member of the same name, and a second copy of the same expression inside the class. Establishment probability now reads in order: work the birth-size carbon out, or read the carbon compute_rates already left, then the equation both share. The individual's entry point says who it is for rather than how it works, so the comment at the call site is unnecessary. Deriving u^eta through the shared chain rounds differently, so the forward value moves again -- recorded with the rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
The density divides by z, which is zero at the crown base, and the light field's lowest knot is exactly there. Branching to the limit fixes that in the one place it arises: zero for every exponent above one, and twice the reciprocal height at one. Measured bitwise equal to that at five heights, and zero above. This replaces a reformulation over u^(exponent - 1), which reached the same finite value but needed a second family of multiplication chains to supply the extra exponent, and moved every model that shares the class. The chains are a value optimisation; they carry no exponent term at all, so they are not what makes a gradient valid, and building more of them was solving a problem this work does not have. Develop's chains, density and profile are untouched, so the two other models return to matching develop through this class. Restores the density's second argument at both call sites in the other strategy. The reformulation had changed its meaning from a height to a reciprocal height, both doubles, so the revert compiled silently and sent that model's offspring to zero. Its whole-lifetime check is what caught it. The crown-centre coordinate stays shared, since three copies of one formula was worth removing and the value is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
|
Flagging an alternative: The structural fix is to move No arithmetic moves and no reordering: the forward path calls It also separates loading a state from evaluating at it, which is what a reverse pass wants: re-establish a stage's state and environment without paying for a rate evaluation the recordings will redo. See aornugent#65 as an example. It is blocked in odelia, which is why it isn't proposed as part of this PR.
Happy to raise a paired PR if this is preferred. Generated by Claude Code |
TF24_Environment::clear_environment() cleared the light spline and left vars.states untouched, so Patch::reset() returned the patch to t = 0 with the previous run's soil moisture and cumulative flux accumulators still in place. A second run() on one SCM then integrated from depleted soil, which refine_schedule() does on every step after the first. Record the soil state whenever set_soil_water_state sets it and restore it in clear_environment. The starting moisture is not derivable at reset time: the R interface lets a caller set it, so it is whatever was last set rather than a function of the soil parameters. FF16_Environment and K93_Environment have ode_size() 0, so they have no environment state to carry and are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01926obUhLZVkxPWm3XNkZ48
|
One more in The mechanism. It is reachable, and by the one caller that matters. Measured, on the branch before the fix, one species, five layers, default driver, full lifetime:
and the state after The forward run is unmoved — The starting state is snapshotted, not recomputed, and that is the one design choice here. The constructor's last line is the only initialiser, so recomputing it would reproduce the default — but Generated by Claude Code |
dfalster
left a comment
There was a problem hiding this comment.
Excellent work catching these @aornugent . Have flagged on for @itowers1 to comment on
| soil_parameter_value(soil_moist_sat_layers, soil_moist_sat, layer); | ||
| return pow((psi_soil_ / a_psi_layer), (-1 / n_psi_layer)) * soil_moist_sat_layer; | ||
| // psi_soil_ is in MPa; a_psi is in Pa. | ||
| return pow((psi_soil_ * 1e6 / a_psi_layer), (-1 / n_psi_layer)) * soil_moist_sat_layer; |
There was a problem hiding this comment.
Yep, technically true if psi_soil was passed in as MPa, which it emerges from the soil moisture model as. The soil_moist_from_psi functions are not actually in use at the moment from what I can see. Might be worth raising a seperate issue that we need to swap all potential values and parameters to MPa for consistency.
There was a problem hiding this comment.
Thanks @itowers1 — and confirmed on "not in use": the only references anywhere are the RcppR6 binding and the new round-trip test, with no C++ caller, so the fix corrects an exported utility without moving any simulated number. The forward direction on the line above already divides by 1e6 with an explicit Pa -> MPa comment, so this is just its exact inverse.
Agreed the real fix is the consistency pass — that's #587.
Drafted with Claude Code
|
Approving on the substance, but three things before this merges — the first is blocking. CI is red and needs the re-blessing committed. The three FF16 "offspring arrival" assertions fail identically on macOS and Windows. I reproduced them locally and bisected: deleting the The FF16 figure in the description has the wrong sign. It says +0.0034%. Offspring actually moves down: 0.00074% and 0.00122% in the two-species run, and up 0.00024% in the one-species run. The 3.4e-5 looks like the
Drafted with Claude Code |
|
Agreed on the direction — It won't hold by construction as written. Please demonstrate the bit-identity rather than assert it. Under the refactor There is a cheaper option in odelia alone, and the reason to reject it is the argument you should be leading with. Have Cost isn't an objection, for the record: there are five Two small things while you're in there. Drafted with Claude Code |
|
Follow-up opened: #589, the structural version of change 3, plus traitecoevo/odelia#46 for the two signatures it needs. Both drafted against the questions raised above. The bit-identity is demonstrated, not asserted. #589 is bitwise equal to this branch on FF16 (one and two species), K93, TF24 at full patch lifetime and FF16 schedule refinement — same offspring production to all 17 digits, same accepted step counts. So the re-blessing you land here stays valid through the refactor, which was the thing worth checking. Runtime is unchanged too: 44.26 s vs 44.36 s on TF24 full lifetime over six alternating reps, against a 1.5 s spread within each build. One thing worth knowing before this merges. Moving the compute into Also filed #588: Drafted with Claude Code |
Species::consumption_rate uses the node list as its quadrature grid, the assumption traitecoevo#574 had to abandon for the competition profile, where inverted heights are sorted in compute_competition_unordered. Without the equivalent here an inverted grid gives partially cancelling trapezia. An already-ascending grid takes the same arithmetic as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUd8i4rmr8BMTF1rx78Xon
Its one remaining caller is the root mass distribution, at pars.root_depth_shape_eta; the canopy reads CanopyShape::Q at pars.eta. The comment still described the canopy's cumulative form, and the extent argument was still called height. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUd8i4rmr8BMTF1rx78Xon
set_ode_state did the whole right-hand side and ode_rates only copied out what it had stored, so a caller that reached a state by another route read the rates of a different one. introduce_new_nodes was that route: it widens the state and rebuilds the light field, and the solver then seeded dydt_in from the pre-introduction rates and marked them clean, which under first-same-as-last became k1. A pre-existing cohort's rate was wrong by more than its own magnitude at 51 of 141 introductions on a production TF24 run. compute_rates() moves out of both set_ode_state overloads into ode_rates(), which is therefore no longer const, so the rates are always those of the state currently loaded. This replaces the compute_rates() call added to introduce_new_nodes and is bitwise equal to it: TF24, FF16 at one and two species, and K93 all reproduce to every digit of their offspring production and accepted step counts. Needs odelia to take the System by mutable reference at the two sites that assert otherwise. Patch::r_compute_rates() goes with it -- reading $ode_rates from R now computes. NEWS carries the migration line. The environment the rates are computed against is now derived rather than stored. Patch held a raw pointer to its own environment member, so every copy -- and RcppR6 hands R a copy, as does SCM's sync_patch -- pointed into the patch it was copied from. Nothing dereferenced it there while ode_rates only read stored values; with this change it segfaults on the first patch$ode_rates. rate_environment() reads the patch's own environment, or on a mutant step the recorded one at the index that step set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUd8i4rmr8BMTF1rx78Xon
Recomputing the rates at a cohort introduction moves FF16: offspring production down 0.00072% and 0.00122% in the two-species run and up 0.00025% in the one-species run, ode_times[100] down 0.0037%, and four fewer accepted steps. The one-species offspring assertion was passing on its 1e-4 tolerance throughout; it is recorded at its current value so the next real move shows. Nothing under tests/testthat/FF16_reference/ is regenerated -- its own tolerance absorbs the shift, so these hard-coded assertions are the tripwire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUd8i4rmr8BMTF1rx78Xon
Ten fixes to TF24's forward model, plus one that reaches FF16 and K93. Six are bit-identical corrections of latent defects; four move simulated numbers and need baselines re-blessed.
The common thread is state leaking between plants that should be independent. Every cohort of a species shares one
Strategy, and so oneLeaf. Several of these are the same defect at different depths: a buffer sized but not cleared, a member refreshed by one caller and read by another, a rate computed once and read after the state it described changed. Individually small; together they were enough that re-running one plant's rates from its own inputs did not reproduce what the solver had just computed. Solving this now helps with reverse mode where we want to compute from rates backwards.The changes
Leaf::set_physiologysized its per-layer uptake buffer withresize, whose fill reaches only new elements, and the solve writes only as deep as the plant has roots — so layers below rooting depth kept the previously solved plant's values, billed to the water balance. (assign, matching the sibling buffers two lines above.) 33.8% of plant-time records affected, 7.3% holding a stale non-zero value; the gap is the early run, separable only by solving the same states in different orders. The shut-down exits leave the same fields stale (zero incidence at the default driver, margin 27x tolerance); and the profit derivative reads a soil vector only the solve refreshes, differing by 100% when called stale. Addresses [TF24]soil_consumption_carries over between cohorts #577 and [TF24] Shut-down exits leave the leaf's uptake untouched #578.CanopyShapeFF16 and K93 already do, and the crown-centre coordinate (three copies) becomes one static function. Compared before switching over 15 087 real pairs: every difference bounded by 2 units in the last place, so the forward move is the step controller, not the profile. develop's exponent-specialised multiplication chains are untouched — an earlier draft rewrote them for readability and moved two models for nothing; they supply values, carry no exponent term, and were never the route to a derivative. Onepowstays because the root-mass distribution calls the cumulative form at a different exponent.0/0at the base for every height — the field's lowest sample point. It now branches to the limit there: zero above exponent 1,2/heightat 1. The density and cumulative form are an exact derivative pair, verified to hold to 4e-12–6e-11 wherever a difference can check it.NA, so fourNAs per plant per stage were integrated toNaNand discarded — 1 979 of 2 000 calls, none after. Now an explicitn_resources()on the environment, one source of truth.Measurements, for the whole change
One species, five soil layers, default driver, full patch lifetime,
refine_schedule = FALSE, built-O2 -DNDEBUG -g0. Both numbers from one session on one machine — a value gate that does not name its flags measures the compiler, since this tree at-O0differs by 0.145% in offspring from the step controller alone.develop42.14017357509566642.176246845059751+0.086% in offspring, +50 accepted steps. The four value-moving fixes measured individually shift offspring by +0.14% to +0.79%; composed, +0.086%. They do not add — most of each individual figure is the adaptive controller landing on a different accepted sequence, not a systematic change. Treat the composite as the number, and distrust any per-fix figure below about 0.15%.
FF16 and K93 move only through change 3: FF16 +0.0034% (two more steps), K93 +0.000038% (same step count), both at full lifetime.
Runtime unchanged. Change 3 adds 141 rate evaluations against ~30 000; change 8 removes one solve per stage per species.
Tests: 1 007 assertions pass across 15 files. Three fail, and they are the re-blessing this asks for — all in FF16's "offspring arrival", on offspring production, an ODE time and a step count, against
testthat's default ~1.5e-08 tolerance given a 3.4e-05 movement. Nothing undertests/testthat/FF16_reference/was regenerated, and FF16's reference-comparison file still passes because its own tolerance absorbs the shift — so the hard-coded assertions are the tripwire, not the reference files. Two pre-existing failures (mutant-fixture tests) are unrelated and identical ondevelop.