Skip to content

feat(leakance): temperature-annealed gate replaces the continuous factor - #49

Merged
taddyb merged 3 commits into
masterfrom
leakance-gate
Sep 30, 2026
Merged

taddyb merged 3 commits into
masterfrom
leakance-gate

Conversation

@taddyb

@taddyb taddyb commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Why

zeta = leakance_factor · area_z · K_D · (depth − d_gw) multiplies leakance_factor and K_D, so only their product reaches the physics: an exact scaling degeneracy. Measured over 346,321 CONUS reaches on a trained arm, rho(leakance_factor, K_D) = +0.9986, and the four learned outputs of that head hold 97 % of their variance in one direction. Physically a reach either sits above a losing aquifer or it does not, so leakance_factor should be a GATE selecting where leakance acts, not a continuous multiplier. A gate and a conductance are not degenerate with each other. On the same arm the model had repurposed d_gw as the switch (learned to +0.43 m against a median channel depth of 0.26 m so the losing-only clamp zeroed most reaches); with a real gate d_gw can go back to being a head difference.

This does not re-open the leakance verdict (research-status.md); it removes one degeneracy so a future arm can be judged on the gate rather than on the product.

What

A temperature-annealed sharpening sigmoid on the head's normalized leakance_factor output u ∈ (0, 1):

g = sigmoid( logit(clamp(u, 1e-6, 1 − 1e-6)) / tau ),   logit(x) = ln(x / (1 − x))
  • src/training/gate.rs::leakance_gate — the transform. tau == 1.0 returns u untouched (bit-exact identity, not round-tripped through ln/exp). Generic over the backend, so training (Autodiff<I>), eval, probe and dump all call the same function. Exact autograd gradient through clamp → log → div → sigmoid; no straight-through estimator.
  • params.leakance_gate: { temperature: {epoch: tau, ...} } (src/config.rs::LeakanceGate), absent by default. Epoch-keyed schedule resolved exactly like experiment.learning_rate (LeakanceGate::resolve: largest key <= epoch, first value before it). validate_leakance_gate rejects the block without use_leakance: true, an empty schedule, and any temperature that is not positive and finite.
  • Applied in all three hand-written head readers, replicated rather than unified (WET): forward takes the epoch's temperature as a new gate_tau: Option<f32> argument threaded from the driver (asserted to be Some exactly when the block is configured); forward_eval_core and probe_forward apply the FINAL scheduled temperature; both dump_parameters copies dump the gated factor at the final temperature. The run log states the temperature in force (epoch E lr=… leakance_gate_tau=… in training; leakance gate: final temperature tau=… from eval, probe and dump).
  • src/routing/, TimestepLeakanceOp, and every backward operator are untouched.

Epsilon

GATE_EPS = 1e-6. 1 − 1e-6 sits ~17 ulps below 1.0_f32 (ulp there is 2^-24 ≈ 6e-8), so 1 − u at the clamp keeps ~3 % relative accuracy; 1e-7 would be only 2 ulps away with ~20 % error in 1 − u. logit(1e-6) ≈ −13.8, so the clamp only alters values the head has already saturated past |pre-activation| > 13.8. Without the clamp a head sigmoid that has saturated to exactly 0.0 or 1.0 in f32 gives logit = ±inf and a NaN gradient. Everything stays f32.

Tests

  • tests/leakance_gate.rs (new, 11 tests): tau = 1 bit-exact identity on the bare function and end to end through forward, forward_eval, probe_forward against the same config with the block absent; a sharp final temperature demonstrably changes all three readers and they still agree; gradient vs central finite differences at tau ∈ {1, 0.5, 0.2, 0.05, 0.01} (f64 formula at the repo tolerance, plus the f32 implementation at its ulp noise floor); monotone in u, 0.5 a fixed point at every temperature, tau → 0 limits; tau down to f32::MIN_POSITIVE with u exactly 0 and 1 gives finite values and finite gradients; forward rejects a mismatched gate_tau.
  • tests/gamma_eval_parity.rs: two new rows (leakance + gate, everything + gate) at final tau 0.05 across all three readers.
  • src/config.rs unit tests: block rejected without use_leakance, empty/non-positive temperatures rejected, schedule resolves at epoch boundaries and final_temperature.
  • Docs: ddrs-dev/references/config.md §Leakance gate and validator rows; testing.md gate list.

Gates (Tier C; src/routing/ untouched)

cargo build                                   Finished
cargo test --lib                              377 passed; 0 failed; 3 ignored
cargo test --features fixtures --test gamma_eval_parity --test sp8_gradcheck
                                              4 passed / 20 passed
cargo test --test leakance_gradcheck --test leakance_off_parity --test zeta_accum --test leakance_reference_match
                                              16 / 3 / 11 / 5 passed
cargo test --test ddr_sandbox_match           1 passed
cargo test --features fixtures --test leakance_gate   11 passed
mkdir -p output && cargo run --release --example compare_ddr_sandbox
  overall max abs diff: 1.525879e-4 m³/s
  overall max rel diff: 2.412143e-6
  verdict: ABSOLUTE MATCH (max abs < 1e-3 m³/s)
python3 scripts/verify_doc_paths.py           0 unresolved in agent context, 75 in prose docs (pre-existing)

Note: the commit message lists the test counts of leakance_gate and gamma_eval_parity swapped (it is 11 and 4, respectively).

🤖 Generated with Claude Code

https://claude.ai/code/session_0143bx7gVz8ug2oc6DTy5jtz

zeta multiplies leakance_factor by K_D, so only their product reaches the
physics: an exact scaling degeneracy. Measured over 346,321 CONUS reaches on a
trained arm, rho(leakance_factor, K_D) = +0.9986 and all four learned outputs of
that head hold 97 percent of their variance in one direction. Physically a reach
either sits above a losing aquifer or it does not, so the factor should select
where leakance acts rather than scale how much. A gate and a conductance are not
degenerate with each other.

The gate is a sharpening sigmoid on the head's normalized output,
g = sigmoid(logit(clamp(u, eps, 1-eps)) / tau), annealed by an epoch-keyed
schedule that mirrors the learning rate. tau = 1 is special-cased to return u
untouched, so every config without params.leakance_gate is bit-exact unchanged.
Differentiable at every positive tau, so autograd gives the exact gradient and no
straight-through estimator is introduced. Applied to the head output before
setup_inputs, so no routing operator or backward changes.

Replicated across all three hand-written head readers rather than unified, per
the WET preference, with the eval reader and dump_parameters using the FINAL
temperature so the scored model is the one training ended on. A previous bug
shipped because an eval reader silently dropped a learned head output.

Gates: lib 377 passed; leakance_gate 11; gamma_eval_parity 4; sp8_gradcheck 20;
leakance_gradcheck 16; leakance_off_parity 3; zeta_accum 11;
leakance_reference_match 5; ddr_sandbox_match 1. compare_ddr_sandbox ABSOLUTE
MATCH at 1.525879e-4 m3/s, unchanged. verify_doc_paths 0 unresolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143bx7gVz8ug2oc6DTy5jtz
taddyb and others added 2 commits September 16, 2026 22:43
104 numbered equations over 13 sections, each derived from the code rather than
from prior docs, with symbol tables giving units and whether each quantity is
learned per reach, set by config, read from data, or derived. Covers the head and
its normalization, the new gate, the geometry chain with a catalogue of every
clamp and what it does to the gradient, stage-dependent roughness, the Muskingum
coefficients, the sparse adjoint, the leakance flux with all six partials, the
gradient-path labels, all five losses, and the diagnostics.

It found ten code-versus-documentation discrepancies. The load-bearing one: the
leakance term computes its width as (p*d)^q while the routing geometry computes
the same reach's width as p*d^q, so the area multiplying K_D is smaller by the
depth-independent factor p^(q-1) = 0.3445 at the prescribed p = 21, q = 0.65, and
a learned K_D absorbs it. The flux and skill results are unaffected because a
constant rescaling is absorbable, but K_D is not directly comparable to a measured
streambed leakance without dividing it out. Inherited from DDR, so the port is
faithful; leakance_reference_match pins that.

validate_params.py now divides the factor out and records the caveat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143bx7gVz8ug2oc6DTy5jtz
…gradient-sink check

The equations reference flags that nothing in the repo measures how often the
geometry clamps saturate, which matters because a saturated clamp zeroes the
gradient to the parameter behind it, so training cannot identify that parameter
at that reach.

Measured on the 2026-09-16T04-44-15Z arm over 64,892 eval reaches: the depth
floor takes 2.1 percent, and the side-slope bounds, the bottom-width floor and
both roughness range bounds take none. The side-slope clamp cannot fire in this
configuration: with q fixed at 0.65 its lower bound needs a depth of 1,751 m and
its upper bound a depth of 3.2 mm, which the 10 mm depth floor already forbids.

So clamps are not the reason the learned outputs collapse onto one direction.
That is consistent with the head-topology screen, which found capacity is not the
constraint either, leaving the gradient and the objective.

Figures are a lower bound: computed from each reach's mean eval depth, so a clamp
that fires only at low flow is invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143bx7gVz8ug2oc6DTy5jtz
@taddyb
taddyb merged commit a567f36 into master Sep 30, 2026
2 checks passed
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