Skip to content

Design pass: the soil cascade needs no change - #608

Merged
dfalster merged 3 commits into
developfrom
notes/soil-redistribution-design
Aug 26, 2026
Merged

Design pass: the soil cascade needs no change#608
dfalster merged 3 commits into
developfrom
notes/soil-redistribution-design

Conversation

@dfalster

@dfalster dfalster commented Aug 5, 2026

Copy link
Copy Markdown
Member

theta_sat is already a barrier in the exact vector field, and error
control already rejects the overshooting steps. The excursions to
theta = 1.7 are explicit overshoot triggered by the step size a leg
inherits, not a water-balance defect.

Two faults instead: odelia accepts a NaN error estimate (odelia#52),
and a throw inside a stage evaluation preempts rejection entirely.
Capacity limiting is measured to cost stiffness for no benefit.

Design doc plus standalone scripts; no package code changes.
Refs #522, #599, #565.

🤖 Generated with Claude Code

theta_sat is already a barrier in the exact vector field, and error
control already rejects the overshooting steps. The excursions to
theta = 1.7 are explicit overshoot triggered by the step size a leg
inherits, not a water-balance defect.

Two faults instead: odelia accepts a NaN error estimate (odelia#52),
and a throw inside a stage evaluation preempts rejection entirely.
Capacity limiting is measured to cost stiffness for no benefit.

Design doc plus standalone scripts; no package code changes.
Refs #522, #599, #565.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dfalster

dfalster commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

What this was convened to do, and why it concluded the opposite

Scoping #522 (rainfall pulses) produced a plan whose first stage was a redistribution fix in compute_rates: the cascade sets water_input[i] = K(θ_{i-1}) and never checks whether layer i can accept it, so a saturated layer appeared able to overfill the one below — and pulses looked unsafe until that was fixed. The gate before implementing was to design the scheme properly. It does not survive measurement.

Everything below is from notes/scripts/soil_cascade/ — a standalone R replica of compute_rates/soil_K_from_soil_theta plus odelia's exact OdeControl with plant's defaults. No plant build needed; Rscript exp1.R, exp2.R, exp4.R.

1. θ_sat is already a barrier

Inflow K(θ_{i-1}) and outflow K(θ_i) saturate at the same K_sat, so the rate is exactly zero at θ_sat, and the [0, θ_sat] clamp inside K stops it going positive above:

θ₁ (of θ_sat) inflow outflow rate₁ (yr⁻¹)
0.050 (0.117) 163.041 0.000 +543.47
0.385 (0.900) 163.041 29.769 +444.24
0.424 (0.990) 163.041 138.627 +81.38
0.428 (1.000) 163.041 163.041 0.00

So θ cannot exceed saturation in exact arithmetic. One Euler step from θ₁ = 0.05: h = 1e-3 → 0.593; h = 0.08 → 43.5, reproducing the recorded θ = [-51.4, 52.0, …]. Pure explicit overshoot.

2. Error control already rejects those steps

h = 1e-3, 1e-2 and 0.08 all reject. From a cold start the worst case integrates in 15 steps with max θ = 0.428 exactly; ode_step_size_max barely matters (15 vs 21 at 0.05).

3. The trigger is the inherited step size

step_size_last persists across set_state_from_system() (stochastic_patch_runner.h:146-159), so a leg beginning after a discrete change starts at whatever the previous quiet leg grew to. Varying only that:

h at leg start leaf throws leaf returns NaN
1e-6 (fresh) completes, 16 steps completes, 16 steps
≥ 1e-3 dies at step 0 dies, NaN accepted

Two independent faults:

  • odelia accepts a NaN error estimatestd::max(NaN, rmax) is NaN and NaN > 1.1 is false, so the accept branch runs. Filed as adjust_step_size accepts a NaN error estimate instead of rejecting the step odelia#52. Fixing it makes that path recoverable (14–15 steps, 9–12 rejections).
  • A throw inside a stage evaluation preempts rejectionset_ode_statecheck_finite_ode_state() runs at every RK stage, and phylloptim's collar root-find throws. The run dies having taken zero steps, and fixing the NaN does not help it at all.

4. Capacity limiting: measured, and rejected

scheme max |upper-tri| lower-tri? max |λ|
baseline (donor-only) 0 yes 1.546e4
receiver rejects excess 0 yes 1.546e4
donor throttles 4.67e3 no 1.518e4

Donor-throttling — the literal reading of "a layer can't overfill the one below" — breaks lower-triangularity, which vindicates the original donor-only choice. Receiver-side rejection keeps it but costs 1.20× stiffness at 0.9 θ_sat and 1.45× at 0.99, and at 0.99 turns a +81 yr⁻¹ rate into −420 yr⁻¹ by discarding inflow the baseline retains — a real hydrological change needing a scientific_version bump and a scenario re-bless, to enforce a bound that already holds.

5. Relation to #565

Same criterion, independently: #565 uses donor control to triangularise the cross-patch Jacobian under the "design out stiffness rather than reach for implicit solvers" philosophy. This pass supplies measured backing for two things it asserts — the stiff diagonal (≈1.5e4 yr⁻¹, structure exactly triangular to machine precision) and the need for a spill term on large events. It also flags that its N = 15 bucket makes the §3 trigger worse: measured max |λ| at 0.99 θ_sat goes 1.77e4 (N=5, dz=0.3) → 5.30e4 (N=15, dz=0.1).

6. Consequence for #522

Pulses are no longer gated on a physics change. They need the event queue, the pulse action, and the R generator. The one physics-adjacent piece that is required is capping the jump at layer 0's free capacity, because a jump is applied outside the integrator where nothing can reject it — and a ~13 mm event already exceeds capacity from θ₀ = 0.40.

Caveat

These experiments isolate the soil column with a stub for physiology, so they establish the mechanism, not the 43%-of-seeds figure. The 40-seed harness should be re-run against real fixes before #599 closes.

Retractions from my own earlier notes on this

R's max() propagates NaN and C++'s std::max does not, so the replica
overstated the controller fault as "any NaN is accepted". It is narrower
and positional: the reduction wipes a NaN on the next finite element, so
the step is accepted either with rmax still NaN or with rmax finite and
the state carrying a NaN. The second is the mode coupled runs hit.

Conclusions are unchanged -- the barrier result, the rejected overshoot,
the inherited-step trigger and the capacity-limiting cost all stand.
Also deduplicates the controller that exp2.R had copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dfalster added a commit that referenced this pull request Aug 26, 2026
Adopts odelia 0.3.1's opt-in domain checks. A non-finite environment state and
an infeasible leaf probe both used to kill a run outright; both are now handed
to the stepper to shrink and retry. If the minimum step still cannot escape,
odelia stops and reports the original message, so nothing is lost.

phylloptim's infeasible_error is a sibling of odelia's DomainError, not a
subclass, so the stepper cannot see it -- solve_leaf() translates it. Only that
one type, so a bug stays a bug rather than becoming step-shrinking.

A runaway cohort density stays fatal: that divergence is in the equations, not
the stepper. And ode_state_valid() checks only the environment block, because a
node's log_density is legitimately -Inf.

Inert where nothing is wrong: FF16, K93 and TF24 stay identical().

Refs #628, #608, #599.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfalster
dfalster merged commit b3472d4 into develop Aug 26, 2026
3 checks passed
@dfalster
dfalster deleted the notes/soil-redistribution-design branch August 26, 2026 10:09
dfalster added a commit that referenced this pull request Aug 26, 2026
Adopts odelia 0.3.1's opt-in domain checks. A non-finite environment state and
an infeasible leaf probe both used to kill a run outright; both are now handed
to the stepper to shrink and retry. If the minimum step still cannot escape,
odelia stops and reports the original message, so nothing is lost.

phylloptim's infeasible_error is a sibling of odelia's DomainError, not a
subclass, so the stepper cannot see it -- solve_leaf() translates it. Only that
one type, so a bug stays a bug rather than becoming step-shrinking.

A runaway cohort density stays fatal: that divergence is in the equations, not
the stepper. And ode_state_valid() checks only the environment block, because a
node's log_density is legitimately -Inf.

Inert where nothing is wrong: FF16, K93 and TF24 stay identical().

Refs #628, #608, #599.

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