Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Differentiability gradient audit

San Diego State University

progenax’s value proposition is that a differentiable forward model turns cluster inference into information geometry: because every initial condition is jax.grad-able, the Fisher matrix is computable and forecasts what a survey can and cannot measure Cartwright & Whitworth, 2004 (see the science-demos throughline).

That guarantee has a sharp failure mode. The Fisher information is F=JTJF = J^\mathsf{T} J with J=(statistic)/(params)J = \partial(\text{statistic})/\partial(\text{params}). A silently zeroed column of JJ makes that parameter look infinitely well-constrained, or makes FF rank-deficient — a confident wrong uncertainty, with no error raised. A NaN at least crashes loudly; a zeroed gradient does not. Gradient integrity is Fisher integrity.

This page is the living status of a systematic audit that, for every public sampling and physics entry point, measures the autodiff gradient against a central finite difference and classifies it. Test gate: tests/validation/test_grad_audit.py (parametrised over tests/validation/grad_audit/registry.py); numbers regenerated by scripts/audit_gradients.pyvalidation/data/grad_audit_results.json (this page cites that file — every ratio below is a real measured number).

How a case is classified

Each registry case declares an expect class and the engine computes the status — it is never hand-set:

expect

Passes when

Meaning

consistent

finite and AD/FD1<tol|\mathrm{AD}/\mathrm{FD}-1| < \text{tol} and AD>ε|\mathrm{AD}| > \varepsilon

the gradient flows and matches finite differences (the silent-zero guard AD>ε|\mathrm{AD}|>\varepsilon catches a blocked gradient)

known_zero

AD<ε|\mathrm{AD}| < \varepsilon and FD<ε|\mathrm{FD}| < \varepsilon

the quantity genuinely does not depend on the parameter (pinned: a re-appeared gradient fails)

known_blocked

finite

a deliberately non-differentiable / branch-limited site; AD need not match FD

Tolerances are per class: 10-5 for closed-form paths (Plummer inverse-CDF, IMF ppf/cdf, Kepler), 10-3 for ODE/quadrature paths (King/Michie/EFF/multicomponent), and 102\sim10^{-2} for the linear-interpolation rtr_t derivative. The central FD uses a relative step h=hrelmax(θ,1)h = h_{\rm rel}\max(|\theta|,1), hrel=104h_{\rm rel}=10^{-4}. A confirmed-but-unfixed hazard is encoded as xfail(strict=True) so that fixing it later forces the marker’s removal (a self-cleaning ratchet).

Status — spatial profiles & velocity DFs (params → IC)

Generic and edge/boundary parameters; every ratio measured this run.

Entry point

Param

AD/FD ratio

Status

PlummerProfile.sample_positions

rhr_h

1.000000

✅ clean

PlummerVelocityDF.sample_velocities

rhr_h

1.000000

✅ clean

PlummerVelocityDF + Osipkov–Merritt

rar_a (+ edge at 0.75a0.75a)

1.000000 / 0.999574

✅ clean

KingProfile.sample_positions

W0W_0 (+ edge W0=12W_0{=}12)

1.000076 / 0.999989

✅ clean

KingProfile.sample_positions

rcr_c

1.000000

✅ clean

KingVelocityDF.sample_velocities

W0W_0

1.000140

✅ clean

KingVelocityDF.sample_velocities

rcr_c

1.000000

✅ clean (Tier 4)

KingProfile.r_t

W0W_0

0.999494

🔧 fixed (was silent-zero)

MichieProfile.sample_positions

W0W_0

0.999631

✅ clean

MichieProfile.r_t

W0W_0

0.995328

🔧 fixed (shared _find_tidal_radius)

MichieVelocityDF.sample_velocities

W0W_0

0.999839

✅ clean

MichieProfile.density (logρ(r)\log\rho(r) observable)

rcr_c / W0W_0

1.000000 / 1.000000

✅ clean (Tier 4)

EFFProfile.sample_positions

γ\gamma (+ edge γ=2.01\gamma{=}2.01)

0.999982 / 0.999998

✅ clean

EFFProfile.sample_positions

rtr_t

1.000006

✅ clean

EFFProfile.sample_positions

aa (scale radius)

0.999985

✅ clean (Tier 4)

EFFVelocityDF.sample_velocities

γ\gamma

1.000013

✅ clean

EFFVelocityDF.sample_velocities

aa (scale radius)

0.999997

✅ clean (Tier 4)

build_spatial_ic (Plummer, e2e: virial + COM)

rhr_h (positions / velocities)

1.000000 / 1.000000

✅ clean

Status — initial mass functions (params → IC and → summary)

Entry point

Param

Direction

AD/FD ratio

Status

PowerLawIMF.ppf (Salpeter)

α\alpha (+ edge α=0.999\alpha{=}0.999)

IC

1.000000

✅ clean

PowerLawIMF.ppf (Salpeter)

α=1.0\alpha = 1.0 exactly

IC

0 (branch)

⚠ known-limitation

PowerLawIMF.sample

α\alpha

IC

1.000000

✅ clean

PowerLawIMF.cdf (H4 clip probe)

mminm_{\min}

summary

1.000000

✅ clean (clip benign)

PowerLawIMF.ppf

mminm_{\min}

IC

1.000000

✅ clean (Tier 4)

ChabrierIMF.ppf

mcm_c

IC

1.000000

✅ clean (clamp benign)

ChabrierIMF.ppf (H6 boundary, umminu\to m_{\min})

mcm_c

IC

0.999669

✅ clean (Tier 4 — real u=1012u{=}10^{-12} probe)

ChabrierIMF.ppf

σ\sigma, α\alpha

IC

1.000000

✅ clean

Maschberger.ppf

μ\mu, α\alpha, β\beta

IC

1.000000

✅ clean

Schechter.ppf

α\alpha

IC

0.999880

✅ clean

PowerLawIMF.mean_mass

α\alpha (+ edge α=0.999\alpha{=}0.999)

summary

1.000000

✅ clean

PowerLawIMF.mean_mass

α=1.0\alpha = 1.0 exactly

summary

1.4679 (branch)

⚠ known-limitation

IMFParams 4-segment log_prob NLL

α3\alpha_3 (+ edge α3=1.0\alpha_3{=}1.0)

summary

1.000000 / branch

✅ clean / ⚠ at α3=1\alpha_3{=}1

Status — differentiable diagnostics (params → summary)

Entry point

Param (through sampled positions)

AD/FD ratio

Status

q_approx (CW04 substructure-QQ surrogate)

γ\gamma (EFF; QQ is scale-invariant in rhr_h)

1.000183

✅ clean

lambda_msr_approx (Allison ΛMSR\Lambda_{\rm MSR} surrogate)

geometry, β=0.1\beta=0.1

1.000368

🔧 fixed (stop_gradient removed)

Status — multi-component clusters, binaries & rotation (params → IC)

The Tier-2 layer: the two multi-component cluster engines, the binary orbital-mechanics layer, and the rotation kinematic overlays. Every ratio measured this run; all clean, no new hazards.

Entry point

Param

AD/FD ratio

Status

MultiComponentCluster.sample_cluster (Engine A)

W0W_0 (+ edge W0=3W_0{=}3, the H2 ψ=0\psi{=}0 boundary probe)

0.999970 / 1.000258

✅ clean

MultiComponentCluster.sample_cluster (Engine A)

gg (truncation sharpness)

0.999889

✅ clean

MultiComponentCluster.sample_cluster (Engine A)

δ\delta (Gieles–Zocchi segregation exponent)

0.999992

✅ clean

MultiComponentCluster.sample_cluster (Engine B)

rhr_h (halo Plummer scale, prescribed density)

0.999936

✅ clean

MultiComponentCluster.sample_cluster (Engine B)

γ\gamma (core EFF slope, prescribed density)

0.999704

✅ clean

MultiComponentCluster.sample_cluster (Engine B)

rar_a (halo Osipkov–Merritt anisotropy → velocities)

1.000000

✅ clean

KeplerElements.to_state

ee (+ edge e=0.999e{=}0.999, near-parabolic)

1.000000 / 1.000000

✅ clean

KeplerElements.to_state

aa (semi-major axis)

1.000000

✅ clean (Tier 4)

KeplerElements.to_state

M0M_0 (mean anomaly)

1.000000

✅ clean (Tier 4)

KeplerElements.from_state (inverse map)

velocity scale

1.000000

✅ clean (Tier 4)

resolve_binary_components (all binaries)

aa

1.000000

✅ clean

resolve_binary_components (mixed is_binary, sanitization active)

aa

1.000000

✅ clean (NaN-safe)

MoeCompanions.sample

m1m_1 scale (→ e\langle e\rangle)

1.000000

✅ clean

apply_solid_body_rotation

ω\omega

1.000000

✅ clean

apply_differential_rotation

vpeakv_{\rm peak}

1.000000

✅ clean

apply_differential_rotation

RpeakR_{\rm peak}

1.0000001

✅ clean

The two linear overlays (ω\omega, vpeakv_{\rm peak}) are machine-exact (ratio11013|\text{ratio}-1|\sim10^{-13}); the RpeakR_{\rm peak} case is genuinely nonlinear (it enters both the (R/Rpeak)(R/R_{\rm peak}) prefactor and the e1R/Rpeake^{1-R/R_{\rm peak}} rotation curve) and so carries the real teeth, FD-consistent to ratio1=5.5×108|\text{ratio}-1|=5.5\times10^{-8}.

Tier-2 findings (all suspects cleared with a measured number)

Status — binned-kinematic Fisher path (params → summary)

The Tier-3 layer: the binned summary statistics a Fisher matrix F=JTJF = J^\mathsf{T}J is actually built from, with J=(binned summary)/θJ = \partial(\text{binned summary})/\partial\theta. The radial bin edges are frozen (the observer fixes the bins; gradients flow through the model that fills them). The dispersion σ(r)\sigma(r) and anisotropy β(r)\beta(r) bin values move smoothly with the parameters, so their gradients are genuinely live; the N(r)N(r) count is the illustration that the Fisher gradient lives in the model pk(θ)p_k(\theta), not the frozen data.

Entry point

Param

AD/FD ratio

Status

binned_sigma1d (Plummer, e2e through build_spatial_ic)

rhr_h

1.000000

✅ clean (teeth-checked)

binned_sigma_beta (Plummer + Osipkov–Merritt, anisotropy headline)

rar_a

1.000005

✅ clean

PlummerProfile.enclosed_mass_fractionN(r)N(r) model pk(θ)p_k(\theta)

rhr_h

1.000000

✅ clean

binned_number_densityN(r)N(r) data count (frozen)

rhr_h

0 (AD =0=0 by design; FD =5×103=-5{\times}10^{3} bin-crossing)

⚠ known-limitation (pinned non-diff)

The σ(r)\sigma(r) case carries the mutation-check teeth: wrapping the sampled (pos,vel)(\text{pos}, \text{vel}) in jax.lax.stop_gradient before the binner collapses AD to 0 exactly while the finite difference stays live (3.35\sim -3.35), proving the case differentiates the real params → summary channel and not a constant (test_grad_audit.py::test_binned_sigma_mutation_has_teeth).

The N(r)N(r) split is the arc’s headline principle made concrete. A Poisson number-density likelihood writes the expected per-shell count μk(θ)=Ntotalpk(θ)\mu_k(\theta) = N_{\rm total}\,p_k(\theta), where pk=F(rk+1)F(rk)p_k = F(r_{k+1}) - F(r_k) and F(r)=M(<r)/M=r3/(r2+a2)3/2F(r) = M(<r)/M = r^3/(r^2+a^2)^{3/2} is the new public PlummerProfile.enclosed_mass_fraction CDF. The gradient flows through μk\mu_k — i.e. through the model pkp_k — and is closed-form machine-exact (ratio 1.0). The observed binned count NkN_k, by contrast, is a sum of indicator functions on frozen edges: its autodiff gradient is identically 0 (a finite, correct-by-design answer), while a finite difference registers a nonzero discrete step as stars cross the frozen edges. That FD step is the data-side bin-crossing the audit treats as frozen and out-of-scope; AD =0=0 is the right answer, so the case is pinned known_blocked (it passes because AD is finite — not known_zero, which would mis-flag the nonzero FD as a hazard). Frozen data is out of scope; the Fisher gradient lives in the model.

Tier 4 — the registry as the release gradient-gate

Tiers 0–3 built the harness and a 57-case coverage map. Tier 4 makes the registry the single source of truth for gradient correctness — the release gradient-gate — and consolidates the scattered ad-hoc gradient tests the codebase had accumulated.

A read-only inventory catalogued every released-core test whose assertion is about a gradient (autodiff finiteness, AD-vs-FD consistency, gradient sign/value, differentiability-through-a-path). Each was classified migrate / keep / delete:

The migration surfaced 9 new entry-point × parameter Cases the duplicates exercised but the registry did not yet own — added in Tier 4 and visible in the tables above tagged (Tier 4): KingVelocityDF.sample_velocities(rc)(r_c), EFFProfile.sample_positions(a)(a), EFFVelocityDF.sample_velocities(a)(a), PowerLawIMF.ppf(mmin)(m_{\min}), the three Kepler Jacobian columns (KeplerElements.to_state in aa and M0M_0, and KeplerElements.from_state), the Michie density observable (rcr_c, W0W_0), and the standalone H6 boundary probe. (The Michie density observable’s two parameter channels count as one Case here, matching its single dual-param table row — so the gate’s full row count is 66, of which these are the nine additions.) The H6 case was promoted: it had been a folded edge on the Chabrier mcm_c Case (which never actually drove uu to the clamp); it is now a standalone Case with the real u=1012u = 10^{-12} boundary draw, measured FD-consistent at ratio 0.999669.

A safety interlock guarded the consolidation: a fast per-file gate after each edit and a full released-core gate per logical batch (new-cases, migrates, deletes). The interlock earned its keep — the migrate batch surfaced two real coverage gaps the duplicates had quietly covered (the Michie density-W0W_0 channel and the EFF positions-aa channel), which were then added as Cases rather than lost. Tier 4 grew the gate from 57 to 66 cases and found 0 new hazards.

Per-case AD-vs-FD residual |\mathrm{AD}/\mathrm{FD}-1| on a log axis, one point per registry
Case (98 total), generated by scripts/audit_gradients.py --plots directly from
validation/data/grad_audit_results.json — the same file this page’s tables cite. Every
clean (consistent) case sits below its per-class tolerance band (the loosest gate, the
r_t linear-interpolation derivative at 10^{-2}, is dashed; the tightest closed-form gate at
10^{-5} is dotted). The known-limitation cases (orange \times) have an undefined or
blocked ratio and are annotated rather than plotted on the residual axis. 0 hazards.

Figure 1:Per-case AD-vs-FD residual AD/FD1|\mathrm{AD}/\mathrm{FD}-1| on a log axis, one point per registry Case (98 total), generated by scripts/audit_gradients.py --plots directly from validation/data/grad_audit_results.json — the same file this page’s tables cite. Every clean (consistent) case sits below its per-class tolerance band (the loosest gate, the rtr_t linear-interpolation derivative at 10-2, is dashed; the tightest closed-form gate at 10-5 is dotted). The known-limitation cases (orange ×\times) have an undefined or blocked ratio and are annotated rather than plotted on the residual axis. 0 hazards.

One-glance summary of the 98-case release gradient-gate, by protected direction (params
\rightarrow IC vs params \rightarrow summary) and status. 72 clean in the IC
direction, 25 clean / 1 known-limitation in the summary direction — 97 clean,
1 pinned known-limitation, 0 hazards overall.

Figure 2:One-glance summary of the 98-case release gradient-gate, by protected direction (params \rightarrow IC vs params \rightarrow summary) and status. 72 clean in the IC direction, 25 clean / 1 known-limitation in the summary direction — 97 clean, 1 pinned known-limitation, 0 hazards overall.

Tier 5 — the self-policing release gate

Tiers 0–4 measured gradient integrity and made the registry the gate; nothing yet enforced it. A future commit could reintroduce a silent-zero, drift the registry away from its committed numbers, or add a new public sampler with no gradient case — and CI would not notice. Tier 5 makes the gate permanent, self-policing, and complete: a manifest pins what the registry must cover, three CI layers fail loudly on any drift, and the D4 completion closes the last deferred Fisher-coverage holes.

The manifest — the gradient-coverage source of truth

tests/validation/grad_audit/manifest.py holds three hand-curated, independently-frozen structures (deliberately not derived from the registry or __all__ at runtime — a derived manifest could not catch a deleted case or a new symbol):

The three CI enforcement layers

A dedicated gradient-gate CI job runs all three layers as pytest tests, so local pytest tests/validation enforces them identically to CI (one engine, no CI-only path):

  1. Run-gate (Layer 1). test_grad_audit.py asserts every registry case is clean/known-limitation, and scripts/audit_gradients.py exits 0 iff 0 hazards. A reintroduced silent-zero reds this layer.

  2. Staleness comparator (Layer 2). test_json_fresh.py regenerates the audit into a temp path and semantic-diffs it against the committed validation/data/grad_audit_results.json — exact on the discrete/structural projection (id,direction,param,θ,expect,tol,finite,status)(\text{id}, \text{direction}, \text{param}, \theta, \text{expect}, \text{tol}, \text{finite}, \text{status}), rtol-tolerant on the floats (AD,FD,ratio)(\text{AD}, \text{FD}, \text{ratio}) (rtol measured cross-arch). The registry and its committed measured numbers can never silently drift apart.

  3. Coverage ratchet + __all__ cross-check (Layer 3). test_manifest_coverage.py asserts every MUST_AUDIT (id, param) is in the live registry and that set(SYMBOL_CATEGORY) == set(progenax.__all__) with every AUDITED symbol resolving to ≥1 case. A deleted case, a removed coverage unit, or a new/ungated public entry point reds CI.

Plainly: CI fails on a reintroduced silent-zero, a registry↔JSON drift, or a manifest / __all__ entry point without a case.

D4 — closing the deferred Fisher-coverage holes

Tier 5 also completes the gate. Eleven cases close the holes the earlier tiers had deferred, each landed measured-first (≥3 seeds, ±3σ band, no weakened tolerance), growing the gate 66 → 77 with 0 new hazards:

The closed-form anchors landed exactly: LogNormalPeriod gives dlog10P/dμ=1\mathrm{d}\langle\log_{10}P\rangle/\mathrm{d}\mu = 1; the Uniform/LogUniform channels recover u=12\langle u\rangle = \tfrac12 (0.4939); and ThermalEccentricity recovers u=23\langle\sqrt{u}\rangle = \tfrac23 (0.6620).

The 11 Tier-5 (D4) cases — all params → IC, all FD-consistent, 0 new hazards

Entry point

Param

AD/FD ratio

Status

MultiComponentCluster.from_components (Engine A)

wjw_j (velocity-scale ratio)

0.999965

✅ clean

MultiComponentCluster.from_mass_segregation (Engine A)

rar_a (equipartition OM radius)

1.001542

✅ clean

build_binary_cluster (IMF→companion→spatial)

rhr_h

1.000000

✅ clean (machine-exact)

SanaOBPeriod.sample

power (OB period index)

1.000000

✅ clean

LogNormalPeriod.sample

μlogP\mu_{\log P}

1.000000

✅ clean (dlog10P/dμ=1\mathrm{d}\langle\log_{10}P\rangle/\mathrm{d}\mu = 1)

LogUniformPeriod.sample

logPmax\log P_{\max}

1.000000

✅ clean (u=12\langle u\rangle = \tfrac12)

ThermalEccentricity.sample

emaxe_{\max}

1.000000

✅ clean (u=23\langle\sqrt{u}\rangle = \tfrac23)

UniformEccentricity.sample

emaxe_{\max}

1.000000

✅ clean (u=12\langle u\rangle = \tfrac12)

MoeEccentricity.sample

emaxe_{\max} (Roche cap at long PP)

1.000000

✅ clean

LogisticThermalEccentricity.sample

emaxe_{\max} (DM91 circular→thermal blend)

1.000000

✅ clean

IndependentCompanions.sample

emaxe_{\max} (through the full companion assembly)

1.000000

✅ clean

The gate is currently enforced via local pytest; the dedicated gradient-gate CI job is wired into the tests aggregator and fires when CI runs.

Intentional non-differentiable sites

These are documented, deliberate limitations — not bugs. The audit pins them so an unannounced change fails loudly.

Changelog of fixes

Finding

Symptom (measured)

Fix

Commit

King r_t silent zero (shared _find_tidal_radius)

AD =0=0; true drt/dW010\mathrm{d}r_t/\mathrm{d}W_0 \sim 1048

unclamped-ψ\psi dual-return → interpolated crossing; differentiable across King/Michie/LIMEPY/multicomponent A+B; forward rtr_t now the (more accurate) interpolated crossing

9d3365a

Segregation surrogate stop_gradient(median)

AD/FD =0.727=0.727 at default β=0.1\beta=0.1 (~27% of the gradient omitted)

dropped the stop_gradient; forward ΛMSR\Lambda_{\rm MSR} unchanged (size-invariance is a forward property); now FD-consistent at all β\beta

6fa665c

PlummerProfile.enclosed_mass_fraction (Tier-3 addition, not a fix)

the N(r)N(r) Fisher gradient had no model-side entry point; the binned data count is non-diff (AD =0=0)

new public CDF M(<r)/M=r3/(r2+a2)3/2M(<r)/M = r^3/(r^2+a^2)^{3/2} supplies the differentiable per-shell occupancy pk(θ)p_k(\theta) — the model-side N(r)N(r) Fisher channel (ratio 1.0); pins the data-count limitation

7afda9c

Tier 4 — registry consolidated into the release gradient-gate (no new hazards)

the codebase had ~24 duplicate AD-vs-FD assertions and ~15 finite-only isfinite(grad) smoke tests (T6 false confidence) scattered across the suite

migrated the 24 duplicates into the registry (scattered tests now carry pointer comments), deleted the 15 smoke tests, added 9 new Cases (King/EFF DF scale params, PowerLawIMF.ppf(mmin)(m_{\min}), the 3 Kepler Jacobian columns, the Michie density observable) growing the gate 576657 \to 66; the H6 boundary probe was fixed (folded edge → real u=1012u{=}10^{-12} standalone Case)

288fce7

Tier 5 — self-policing release gate + D4 completion (no new hazards)

the measured gate had no enforcement (a future commit could silently reintroduce a zero, drift the JSON, or ship an ungated public sampler); three Fisher-coverage holes remained deferred (Engine-A w_j/r_a, build_binary_cluster end-to-end, the binary period/eccentricity distributions)

added the manifest.py gradient-coverage source of truth (MUST_AUDIT + a complete SYMBOL_CATEGORY keyed to all of progenax.__all__ + PARAM_ALLOWLIST) and a dedicated gradient-gate CI job with three pytest layers (run-gate, JSON staleness comparator, coverage ratchet + __all__ cross-check); added 11 new D4 Cases growing the gate 667766 \to 77 (all FD-consistent; the closed-form anchors land exactly; build_binary_cluster machine-exact)

(this arc)

What this proves, and what it does not

The audit proves that across the released spatial/kinematic/IMF samplers and the packaged differentiable diagnostics, the parameter gradients used to build a Fisher matrix are finite and finite-difference-consistent — or are explicitly pinned limitations. It is a gradient coverage map: the entries that say “clean” are the ones a forecasting study can trust, and the two fixed rows are entries that would previously have produced a confidently wrong Fisher element. The audit does not cover non-public internals. (The formerly-pinned known_blocked α=1\alpha=1 points were eliminated by the audit-S4 expm1-stable kernels and now audit consistent — see the fixed-site note above.)

References
  1. Cartwright, A., & Whitworth, A. P. (2004). The statistical analysis of star clusters. Monthly Notices of the Royal Astronomical Society, 348, 589–598. 10.1111/j.1365-2966.2004.07360.x