Skip to content

docs: pin Fitness's NaN guard contract — value-only, never gradient#1392

Merged
Jammy2211 merged 1 commit into
mainfrom
feature/fitness-nan-guard-contract
Jul 17, 2026
Merged

docs: pin Fitness's NaN guard contract — value-only, never gradient#1392
Jammy2211 merged 1 commit into
mainfrom
feature/fitness-nan-guard-contract

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Closes #1391.

Documents what Fitness's NaN/inf resample guard actually promises. Docstring and comment only — no behaviour change.

The problem

Fitness.call guards a bad likelihood with xp.where(xp.isnan(ll), resample_figure_of_merit, ll). It repairs the value: searches that read only the figure of merit get the resample sentinel and never select the point. Gradient consumers get nothing — under jax.grad, reverse-mode differentiates both branches of a where and multiplies the unselected one by zero, so 0 * NaN = NaN. The value looks handled; the gradient is poison.

This was diagnosed in autolens_workspace_developer#104 (the pixelized likelihood's NaN walls) and retroactively explains two #100/#101 mysteries: trajectories died with a non-finite objective rather than a NaN one, and optax.apply_if_finite latched at the cliff rather than stepping past it.

Why this is a contract, not a fix

The obvious remedy does not work, and that is the point of this PR. Measured:

current (fitness.py:239-240)     x=-1.0  value=inf  grad=nan
output-side double-where         x=-1.0  value=inf  grad=nan   <-- does NOT help
input-side safe-x                x=-1.0  value=inf  grad=0.0   <-- works

By the time call receives log_likelihood, the non-finite derivative is already on the autodiff tape; no transformation of the output removes it. Only refusing to evaluate the offending operation at the invalid input works — and that requires knowing which input is invalid, which is knowable at the NaN's source, not at the Fitness boundary where the likelihood is an opaque already-computed scalar.

So Fitness.call is structurally incapable of fixing this. Rather than add a guard that cannot deliver, this PR documents the true contract and points at where gradient-safety must be established. The real fix for the pixelized case is the autoarray Cholesky (a separate issue).

The trap is narrower than it first looked

It fires only when the masked branch's derivative is non-finite, not merely its value:

primal at a bad point value derivative trap fires?
log(x), x<0 NaN 1/x = finite no0 * -1 = 0
sqrt(x), x<0 NaN NaN yes
cholesky(A), non-PD NaN NaN yes

An earlier write-up of this claimed the guard "does not protect gradient consumers at all". That was overreach and is corrected here and in the #104 findings doc.

API Changes

None. Docstring and comment only; no signature, behaviour or public-surface change. Downstream workspaces need no migration.

Detail
  • Removed: none
  • Added: none
  • Renamed: none
  • Changed Signature: none
  • Changed Behaviour: none — Fitness.call is byte-for-byte equivalent at runtime
  • Migration: n/a

Testing

  • pytest test_autofit/1499 passed, 1 skipped (no delta, as expected for a docstring-only change).
  • The contract is pinned by autofit_workspace_test/scripts/jax_assertions/fitness_nan_gradient_contract.py (companion PR), which asserts: the value is guarded; jax.grad is NaN; the output-side double-where does not fix it; input-side safe-x does. JAX-only, so it lives there rather than in test_autofit/ (unit tests are numpy-only per repo policy). Runs on CPU in seconds.

Gate

  • Tests — 1499 passed / 1 skipped, in the task worktree.
  • Smoke — n/a: docstring-only, no runtime surface.
  • HeartRED, acknowledged contemporaneously by the human on 5 pre-existing unrelated reasons; RED reason verbatim: PyAutoLens: 1 uncommitted source change(s) (a stray paper_jax/paper.md edit in the main checkout, untouched by this branch).

The xp.where NaN/inf guard in Fitness.call repairs the VALUE but not the
gradient: under jax.grad, reverse-mode differentiates the masked branch and
computes 0 * NaN = NaN whenever the likelihood's derivative is also non-finite.
Searches reading only the figure of merit are protected; gradient consumers are
not.

A guard here cannot fix this — by the time call() receives log_likelihood the
non-finite derivative is already on the tape, so an output-side double-where is
measurably ineffective. Gradient-safety belongs at the site that creates the NaN.

Docstring + comment only; no behaviour change. Diagnosed in
autolens_workspace_developer#104; pinned by
autofit_workspace_test/scripts/jax_assertions/fitness_nan_gradient_contract.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jammy2211 Jammy2211 added the pending-release PR queued for the next release build label Jul 17, 2026
@Jammy2211
Jammy2211 merged commit 08708a5 into main Jul 17, 2026
5 checks passed
@Jammy2211
Jammy2211 deleted the feature/fitness-nan-guard-contract branch July 17, 2026 13:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pending-release PR queued for the next release build

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs: pin Fitness's NaN guard contract — value-only, never gradient

1 participant