Skip to content

fix(compute_pk): integrate to full bg.conformal_age + recover dtau_end gradient via Taylor correction - #16

Draft
MinhMPA wants to merge 8 commits into
smsharma:mainfrom
MinhMPA:fix/pk_tau_end
Draft

fix(compute_pk): integrate to full bg.conformal_age + recover dtau_end gradient via Taylor correction#16
MinhMPA wants to merge 8 commits into
smsharma:mainfrom
MinhMPA:fix/pk_tau_end

Conversation

@MinhMPA

@MinhMPA MinhMPA commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two coupled fixes to compute_pk / compute_pk_table, both rooted in the integration endpoint tau_end = bg.conformal_age:

  1. Primal accuracy. Integrate the matter-power paths to the full bg.conformal_age (1.0×) instead of the historical 0.999× safety margin. Closes a ~0.33% bias in P(k) from the missing 14 Mpc of linear growth near today.
  2. Reverse-mode AD correctness. Recover the chain-rule term sacrificed by the necessary stop_gradient(bg.conformal_age) via a first-order Taylor expansion around the frozen endpoint. Closes a 65–84% AD-vs-FD gradient error on h, omega_b, omega_cdm (parameters that flow through conformal_age via H(z)).

The two changes are coupled: routing the matter-power paths to tau_max_factor=1.0 makes tau_end parameter-dependent (it was a fixed 0.999 × age before), which exposes the latent gradient bug. Both must land together.

Independent of #15 — they touch different files. No longer stacked on #14; rebased onto main after #14 was redone.

Root cause — primal (1.0× vs 0.999×)

Bottom-up diagnostic at planck_fast precision (60 k/decade, l_max=50, q=5, rtol=1e-6):

  • Background scalars (H₀, Ω_m, conformal_age) match CLASS to <0.0001%.
  • δ_cdm and δ_b match CLASS synchronous-gauge perturbations to <0.05% when compared at the same τ. (The +1.7% I first measured was an np.interp clamping artifact past CLASS's saved tau_max=13998 vs clax's tau_end=14139.)
  • P(k) showed -0.5% bias because tau_end = 0.999 × conformal_age = 14139 Mpc while CLASS evaluates pk(z=0) at the full conformal_age (14153 Mpc).

Linear growth from τ=14139 to τ=14153: ΔD/D ≈ ½ × Δa/a ≈ ½ × aH × Δτ ≈ 0.16%, doubling in P ∝ D² to 0.33%.

Root cause — gradient (Taylor correction)

After the primal fix, the single-mode path needs tau_end to be parameter-dependent (any θ that changes H(z) changes conformal_age). But tau_end = bg.conformal_age cannot flow as a traced t1 into diffrax's ODE solve: RecursiveCheckpointAdjoint + PIDController raise RuntimeError: Unexpected tangent (the controller's accepted-step factor is marked eqxi.nondifferentiable). The fix uses stop_gradient(bg.conformal_age) as t1 and reintroduces the chain-rule term analytically.

Chain rule for δ_m(τ_end(θ), y(τ_end(θ); θ)) decomposes into:

Term Captured by frozen-t1 AD? Magnitude vs total
∂δ_m/∂y · dy(τ_end; θ)/dθ yes (RecursiveCheckpointAdjoint) ~30%
dδ_m/dτ · dτ_end/dθ no (stop_gradient drops it) ~70%

This perfectly explains the diagnostic pattern from scripts/benchmark_gradients.py:

Param AD (before) FD |AD/FD-1|
h -4894 -13912 64.8% ❌
omega_b -17064 -105290 83.8% ❌
omega_cdm +213990 +122410 74.8% ❌
ln10A_s +10526 +10526 0.0% ✅
n_s +7296 +7296 0.0% ✅

ln10A_s and n_s enter P(k) as pure scalar prefactors of the primordial spectrum — they don't change τ_end — so the missing chain-rule term is zero for them.

Fix. First-order Taylor expansion around the frozen endpoint:

tau_traced = bg.conformal_age
tau_end    = jax.lax.stop_gradient(tau_traced)
sol = diffrax.diffeqsolve(... t1=tau_end ...)

dy_dtau = _perturbation_rhs(tau_end, sol.ys[-1], ode_args)
delta_m_frozen, ddelta_m_dtau = jax.jvp(
    _extract_delta_m_partial,
    (sol.ys[-1], tau_end),
    (dy_dtau, jnp.ones_like(tau_end)),
)
return delta_m_frozen + ddelta_m_dtau * (tau_traced - tau_end)

Two properties make this exact at fiducial:

  • (tau_traced − tau_end) = 0 identically → primal value unchanged.
  • jax.jvp with tangent (dy/dτ, 1) captures both the implicit (state) and explicit (background-density-at-τ) contributions to dδ_m/dτ, so AD picks up the correct linear coefficient.

Changes

clax/perturbations.py

Primal fix (existing in PR):

  • _perturbation_solve_setup gains a tau_max_factor kwarg, default 0.999 (preserves the C_l / source-function pipeline).
  • _perturbations_solve_mpk_impl (used by compute_pk_table) and _matter_delta_m_single_k_impl (used by compute_pk) both now pass tau_max_factor=1.0 to integrate to the full bg.conformal_age.
  • The C_l-driving path (perturbations_solve) is unchanged.

Gradient fix (new):

  • _matter_delta_m_single_k_impl now applies the Taylor correction to recover dτ_end/dθ. Inline comment documents the diffrax structural constraint forcing the stop_gradient.

tests/test_pk_gradients.py

  • New TestPkScalarDensityGradients regression test: asserts dP/d{h, omega_b, omega_cdm} matches centered FD to <5% at PK_CONTRACT_PREC (rtol=1e-6). Fails loudly on the original stop_gradient-only code (where the same params were 65-84% off); the 5% threshold accommodates the AD/FD precision floor at rtol=1e-6 (empirically ~3% on omega_b at k=3e-4).

Measured improvement

Primal (P(k) value)

k [Mpc⁻¹] before after
3e-3 -0.51% -0.17%
1e-2 -0.08% +0.25%
5e-2 -0.29% +0.04%
1e-1 -0.27% +0.07%
3e-1 -0.36% -0.03%
1.0 -0.53% -0.20%

Worst-case |err| drops from 0.53% to 0.25%; median drops from ~0.30% to ~0.07%.

Gradient (dP/dθ vs centered FD), trajectory across pt_ode_rtol

Param rtol=1e-3 (was) rtol=1e-5 rtol=1e-7
h 6.41% 1.08% 0.32%
omega_b 8.91% 3.18% 1.23%
omega_cdm 4.83% 0.85% 0.61%
ln10A_s, n_s 0% 0% 0%

Clean precision-floor convergence (~3-4× per rtol decade), consistent with the FD oracle's own step-noise floor at each rtol (FD step-spread on h is 2.2% at rtol=1e-7). Tightening rtol further would be exercise without information gain — both AD and FD converge to the same value.

Test plan

  • pytest tests/test_pk_gradients.py::TestPkScalarDensityGradients -v3/3 pass (h: 0.6%, omega_b: 3.1%, omega_cdm: 0.7%; all under 5% threshold).
  • pytest tests/test_thermodynamics.py -v --fast9/9 pass.
  • pytest tests/test_cl_pp.py tests/test_lensing.py tests/test_clpp_halofit_ratio.py tests/test_halofit_sigma_guard.py tests/test_nonlinear.py -v → unchanged from prior PR fix(compute_pk): integrate to full bg.conformal_age + recover dtau_end gradient via Taylor correction #16 state (44 passed in 296s).
  • Existing TestPkScalarGradients (primordial-only direct-path coverage) and TestPkPublicTableGradients (table-API density coverage) unchanged.

Notes

  • Forward-mode AD (jax.jvp) is not yet enabled end-to-end. Three structural blockers remain: RecursiveCheckpointAdjoint's internal custom_vjp (the checkpointed_while_loop), clax/shooting.py's shoot_fn custom_vjp, and clax/thermodynamics.py's _find_z_reio custom_vjp. None affect reverse-mode (HMC, optimization). The Taylor correction in this PR is mode-agnostic — once those blockers are converted, jax.jvp(compute_pk) will pick up the chain-rule term automatically. This is a follow-up workstream; tracked separately.
  • Residual sub-percent biases at moderate rtol come from the AD/FD precision floor (both adjoint and FD oracle inherit ODE solver tolerance), not from a missing term. At pt_ode_rtol=1e-7 all five params pass <1.3%.

MinhMPA added 6 commits May 3, 2026 12:54
…nlinear=...) — redo of smsharma#11 with bug fixes

Supersedes PR smsharma#11 (which was reverted via PR smsharma#13 due to post-merge test
failures). This PR ships the same API consolidation with the three fixes
that smsharma#11 was missing folded in from the start.

Public API:

    compute_cl_pp(pt, params, bg, th, l_max, *, nonlinear="none")
    # nonlinear in {"none", "halofit"}; ValueError for anything else.
    # "ept" is reserved for a follow-up clax-pt PR.

Removed (BREAKING):
- compute_cl_pp           (Siddharth's original; superseded by source-Limber)
- compute_cl_pp_fast      (inaccurate at l >= 300 per its own docstring)
- compute_cl_pp_vmap      (Hermite Bessel-table vmap; superseded)
- compute_cl_pp_limber    (Poisson-reconstruction; ~20% over at l=2500)

Renamed:
- compute_cl_pp_source_limber -> compute_cl_pp (sole public entry)
- compute_cl_pp_transfer      -> _compute_cl_pp_full_bessel (private oracle)

Halofit injection (new private _halofit_modulator):
- vmap(compute_pk_nonlinear) over a 100-point z-grid (CLASS-aligned density).
- Inline `sigma_R(R_min, ...) >= 1` check + jnp.where to force R=1 where
  Halofit isn't applicable — replicates CLASS fourier.c:1706-1716 in a
  vmap-safe way (the Python-level check inside compute_pk_nonlinear gets
  bypassed under vmap-over-z, which is why smsharma#11 over-corrected ~25% at
  high l).
- k_max_extend = 0 default (no power-law extension; matches CLASS's
  no-extrapolation behavior). Pass a positive value to enable log-log
  extension for narrow k-grids.
- 2D-interpolates R(k, z) onto (pt.k_grid, pt.tau_grid) via bg.loga_of_tau.
- Multiplies sqrt(R) directly into S_transfer (CLASS source-multiplication).

Tests:
- tests/test_cl_pp.py (renamed from test_cl_pp_source_limber.py): contract
  + linear-accuracy + cross-impl-vs-_compute_cl_pp_full_bessel + Halofit
  smoke + JIT/AD.
- tests/test_clpp_halofit_ratio.py: rewritten for new API; <7% NL/lin
  ratio agreement at l<=500, <10% at l>=1000 vs CLASS Halofit reference.
- tests/test_lensing.py: callers updated; uses local `pipeline` fixture
  matching upstream-main convention.
- Deleted: tests/test_cl_pp_implementations.py, test_cl_pp_limber.py.

Verified locally: 32/32 lensing+halofit tests pass, 36/36 EPT tests pass,
104/104 remaining tests pass.
…ute_cl_pp

Documents both paths in the new unified API:
- nonlinear="none" (linear source-Limber): <1% vs CLASS at all l<=2500
- nonlinear="halofit" (z-aware Halofit injection via source multiplication):
  <7% at l=500 and <10% at l>=1000 vs CLASS Halofit reference

Existing tables (unlensed C_l, lensed C_l with CLASS-generated cl_pp,
matter P(k), pipeline, performance, multi-cosmology) are unaffected by
PR smsharma#14 — they don't depend on compute_cl_pp.

Note on lensed C_l accuracy table: it tests lens_cls in isolation by
feeding CLASS-generated C_l^pp; the reported <0.2% is about lens_cls's
correlation-function method, not about our compute_cl_pp.
…<=2500

The previous table understated linear accuracy. test_clpp_halofit_ratio.py
::TestClppLinear asserts <1% at l in {100, 500, 1000, 2000, 2500} with
pt_k_max_cl=5.0. Updated the linear column accordingly and added the
prerequisite (pt_k_max_cl >= 5) to the section preamble.
…ratio test thresholds

Sweep across modulator settings on default cosmology (l_max=2500):

| l    | n_z=100, no extension | n_z=100, extend to k=10 |
| 100  | 0.01%                 | 0.01%                   |
| 200  | 0.04%                 | 0.04%                   |
| 500  | 0.42%                 | 0.21%                   |
| 1000 | 1.47%                 | 0.63%                   |
| 1500 | 2.30%                 | 0.79%                   |
| 2000 | 3.74%                 | 1.40%                   |
| 2500 | 3.97%                 | 0.96%                   |

The k-extension restores accuracy at high l. CLASS itself uses a dedicated
nonlinear k-grid that extends past the perturbation k-range; our log-log
power-law extension provides equivalent coverage with σ(R) bisection
well-resolved.

Doubling n_z to 200 didn't help (slight degradation at l=1000, l=2500
from interpolation noise), so 100 is the right z-density.

Changes:
- _halofit_modulator default k_max_extend: 0 → 10 Mpc^-1
- test_ratio_at_low_l threshold: 7% → 1% (measured: 0.21% at l=500)
- test_ratio_at_high_l threshold: 10% → 2% (measured: 1.40% worst case)
- README accuracy table: replace bounds with measured residuals
- Docstrings updated with rationale and measured numbers

User asked 'who set the 10% threshold' — it was carried over from the
deleted Poisson-Limber test, never measured against the actual source-
multiplication recipe. Now it reflects what the code actually achieves.
…=2500); fair absolute-accuracy table

k_max_extend sweep on default cosmology, n_z=100 fixed:

| l    | k=10  | k=20  | k=30  |
| 500  | 0.21% | 0.09% | 0.09% |
| 1000 | 0.63% | 0.04% | 0.17% |
| 1500 | 0.79% | 0.11% | 0.11% |
| 2000 | 1.40% | 0.05% | 0.28% |
| 2500 | 0.96% | 0.76% | 0.76% |

(metric: |R_clax / R_CLASS - 1|, R = NL/lin)

k=20 is the sweet spot — far enough for sigma(R) bisection to fully
converge, not so far that the local power-law slope drifts from the
true asymptote. Going to k=30+ degrades because power-law extrapolation
diverges from the true transfer-function shape.

Timing benchmark (5-run mean): k=10 110ms, k=20 110ms, k=30 111ms.
Accuracy boost is essentially free.

Test thresholds:
- low_l (l<=500): 1% -> 0.5%
- high_l (l>=1000): 2% -> 1%

README accuracy table replaced with apples-to-apples absolute residuals
for both 'none' and 'halofit' (was mixing absolute-vs-ratio metrics).
Both modes share the source-Limber kernel and track each other within
~0.1% on absolute scale; the ratio (third column) isolates the R(k,z)
quality from the common linear-kernel systematic.
clax/perturbations.py
  - _perturbation_solve_setup gains a `tau_max_factor` kwarg (default 0.999
    for backward compatibility with the C_l / source-function pipeline).
  - The matter-power paths route `tau_max_factor=1.0`:
      * `_perturbations_solve_mpk_impl` (used by `compute_pk_table`)
      * `_matter_delta_m_single_k_impl` (used by `compute_pk`)
  - The 0.999 safety margin removed from these paths missed ~14 Mpc near
    today, costing ~0.33% in P(k) via linear growth (D ∝ a, ΔD/D ≈ 0.16%
    over 14 Mpc near z=0, P ∝ D² so doubles to ~0.33%).
  - The C_l / `perturbations_solve` path is unchanged (still uses 0.999).

Measured P(k) accuracy at planck_fast preset, single-k `compute_pk` vs CLASS:

| k [Mpc^-1] | before  | after    |
|------------|---------|----------|
|   3e-3     | -0.51%  | -0.17%   |
|   1e-2     | -0.08%  | +0.25%   |
|   5e-2     | -0.29%  | +0.04%   |
|   1e-1     | -0.27%  | +0.07%   |
|   3e-1     | -0.36%  | -0.03%   |
|   1.0      | -0.53%  | -0.20%   |

Worst-case |err| drops from 0.53% to 0.25%; median |err| drops from
~0.30% to ~0.07%. Five of six k values improve.

Background, perturbation evolution, and δ_cdm at the synchronous-gauge
boundary all match CLASS to <0.05% — the residual was almost entirely
the hardcoded 14-Mpc growth deficit.

Tests verified locally:
  - tests/test_cl_pp.py + tests/test_lensing.py: 24 passed in 207s
  - tests/test_clpp_halofit_ratio.py + test_halofit_sigma_guard.py +
    test_nonlinear.py: 20 passed in 89s
  - C_l-pipeline-touching tests unchanged because `perturbations_solve`
    keeps the default 0.999 factor.
The previous table (1.3–3.5% errors) was measured at an earlier version
and has been stale since multiple subsequent improvements to the
perturbation pipeline. Replace with planck_fast measurements at default
Planck 2018 LCDM, post-`tau_max_factor=1.0` fix, vs CLASS v3.3.4.

Median |error| now ~0.07%, worst-case 0.25% (k=0.01).
The single-mode `_matter_delta_m_single_k_impl` (used by `compute_pk`)
wraps `bg.conformal_age` in `stop_gradient` before passing it as `t1` to
the diffrax ODE solve. That stop_gradient is structurally required:
RecursiveCheckpointAdjoint + PIDController cannot reverse-mode AD through
a traced `t1` (the controller's accepted-step factor is marked
`eqxi.nondifferentiable`, so any tangent flowing through `t1` raises
`RuntimeError: Unexpected tangent`). Removing it crashes the adjoint.

But stop_gradient sacrifices a chain-rule term. The full derivative of
`delta_m(tau_end(theta), y(tau_end(theta); theta))` has two parts:

  (1) d_y delta_m * dy(tau_end; theta)/dtheta          (frozen-t1 AD captures)
  (2) ddelta_m/dtau * dtau_end/dtheta                  (stop_gradient drops)

For h/omega_b/omega_cdm — all of which change `conformal_age` through H(z)
— part (2) is ~70% of the gradient, making `jax.grad(compute_pk)` 65-84%
off vs centered FD on this branch. ln10A_s/n_s pass at 0% off because
they don't enter the ODE and don't change tau_end.

Recovery: first-order Taylor expansion around the frozen endpoint,

  delta_m(tau_traced) ~ delta_m(tau_frozen)
                       + (ddelta_m/dtau)|_{tau_end} * (tau_traced - tau_frozen)

Two properties make this exact at fiducial:

  - `(tau_traced - tau_frozen) = 0` identically -> primal value unchanged.
  - `jax.jvp` on `_extract_delta_m` with tangent `(dy/dtau, 1)` captures
    both the implicit (state) and explicit (background-density-at-tau)
    contributions to `ddelta_m/dtau`, so AD picks up the correct linear
    coefficient.

Verified at PK_CONTRACT_PREC (rtol=1e-6) and at rtol=1e-7:

  rtol=1e-3:  h 6.41% / omega_b 8.91% / omega_cdm 4.83%
  rtol=1e-5:  h 1.08% / omega_b 3.18% / omega_cdm 0.85%
  rtol=1e-7:  h 0.32% / omega_b 1.23% / omega_cdm 0.61%

Clean precision-floor convergence (~3-4x per rtol decade), consistent with
the FD oracle's own step-noise floor at each rtol. ln10A_s/n_s remain
exact at 0% off across all rtol values.

Adds `TestPkScalarDensityGradients` regression test on the contract path.
The 5% threshold fails loudly on the original stop_gradient-only code
(>50% off) while accommodating the FD precision floor at rtol=1e-6
(empirically ~3% on omega_b at k=3e-4).
@MinhMPA MinhMPA changed the title fix: integrate matter-power paths to full bg.conformal_age (was 0.999×) fix(compute_pk): integrate to full bg.conformal_age + recover dtau_end gradient via Taylor correction May 6, 2026
MinhMPA added a commit to MinhMPA/clax-pt that referenced this pull request May 6, 2026
Task-by-task TDD plan for completing reverse-mode AD (PR smsharma#16) and enabling
forward-mode AD (jax.jvp / jax.jacfwd) end-to-end through compute_pk for
Fisher-style sensitivity analysis. Includes a self-contained iGPU agent
prompt for V100 execution.
MinhMPA added a commit to MinhMPA/clax-pt that referenced this pull request May 6, 2026
10 standalone diagnostics from the PR smsharma#16 investigation. Documents the trail
that localized the reverse-mode AD bug (stop_gradient(bg.conformal_age)
sacrificing the dδ_m/dτ · dτ_end/dθ chain-rule term) and the precision-floor
analysis that validated the Taylor correction.

Catalog appended to diags/README.md.
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