Skip to content

fix: compose ClipperPriorBoxJoint with a bijector/scaler instead of refusing it - #1540

Merged
Jammy2211 merged 1 commit into
mainfrom
feature/joint-clipper-compose-with-bijector
Aug 28, 2026
Merged

fix: compose ClipperPriorBoxJoint with a bijector/scaler instead of refusing it#1540
Jammy2211 merged 1 commit into
mainfrom
feature/joint-clipper-compose-with-bijector

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Closes #1539.

Summary

ClipperPriorBoxJoint was refused outright whenever a scaler or bijector was set — at construction in MultiStartGradient.__init__ and again at projection time in ClipperPriorBoxJoint.project. The refusal was stronger than the geometry requires.

A ball is a statement about physical coordinates, but it survives a common linear rescale of both its members: theta_i**2 + theta_j**2 <= R**2 is exactly phi_i**2 + phi_j**2 <= (R / s)**2, so a disk of radius R is a disk of radius R / s in the stepped coordinates, and the radial shrink — a pure multiply — projects onto it correctly. Only a genuinely non-linear pair has no closed form: a log/logit kind, or two identity coordinates with different scales (an ellipse, whose nearest-point projection is the root of a quartic, not a radial shrink).

Because the refusal was blanket, any gradient arm wanting a reparameterisation anywhere in the model had to drop the joint clipper entirely, and its ell_comps lanes then settled at the box corner |e| = 1.414 — inside every prior box, outside the disk, in the flat saturated region the clipper exists to escape (autolens_profiling#182: 20.1% of recorded lane best points, and 0 of the 246 lanes that reach the target basin).

What changed

  • autofit/non_linear/bijector.py — new AbstractBijector.identity_scales property: the per-coordinate linear scale where the kind is identity, None where it is not. None rather than 1.0 for log/logit deliberately — a log coordinate is not a coordinate scaled by one, and reporting it as such would let a caller compose with it silently and wrongly.
  • autofit/non_linear/clipper.py — new ClipperPriorBoxJoint.pairs_in_stepped_coordinates(pairs, scale, bijector) resolves each declared ball pair against the map, returning (i, j, radius / s) for a pair sharing one common positive linear scale and raising the existing ValueError — now naming the offending index pair and its kinds — otherwise. project calls it in place of the old blanket raise. A "Composition with a scaler/bijector" docstring section carries the R → R/s argument and the per-pair reasoning.
  • autofit/non_linear/search/mle/multi_start_gradient/search.py — the construction-time refusal is deleted; the check runs once in _fit, immediately after self.bijector.from_model(model=model) and before _vmapped is built. Whether the two compose is a question about the model — which pairs carry a ball, and how the map treats each — and no model exists at construction; making it here is still before any likelihood evaluation, so a bad combination does not die a minute into a multi-hour fit. bfgs/search.py is untouched: LBFGS still refuses the joint clipper wholesale, because scipy has no ball.

The projection is deliberately not round-tripped through the bijector. That would be correct for every kind, but it would drag the logit epsilon clamps across coordinates the ball has nothing to do with — breaking the bit-identity the interior-point path promises, saturating gradients at the clamped edges, and adding traced ops to every step. Dividing the radius costs nothing and is exact.

API Changes

Additive only. No existing call signature, default, or result changes.

  • New: autofit.AbstractBijector.identity_scales (property) — List[Optional[float]], requires from_model to have been called.
  • New: ClipperPriorBoxJoint.pairs_in_stepped_coordinates(pairs, scale=None, bijector=None).
  • Relaxed: af.MultiStartAdam(clipper=af.ClipperPriorBoxJoint(), bijector=...) (and scaler=...) no longer raises at construction. Combinations that previously raised there now either work (the ball pair is identity-mapped under one common scale) or raise the same ValueError class at model resolution inside _fit, before the first likelihood evaluation. A previously-raising combination cannot start silently doing something different — the map is resolved and either accepted or refused, never applied approximately.
  • Unchanged: ClipperPriorBox.__identifier_fields__ remains ("margin", "strict_epsilon"). The composition takes no new constructor argument, so no stored result is re-keyed — pinned by a test.
  • Downstream: nothing in PyAutoGalaxy or PyAutoLens calls the changed methods; both continue to declare geometry via __model_ball_constraints__ and are unaffected.

Validation

Suite Result
test_autofit/ (full, worktree PyAutoFit first on PYTHONPATH) 2301 passed, 3 skipped in 80s (base f466dce1a: 2288 passed, 3 skipped — +13 new tests)
test_autofit/non_linear 717 passed, 2 skipped in 34s
test_autogalaxy/profiles (canonical checkout, worktree autofit) 716 passed in 33s
test_autolens/analysis (canonical checkout, worktree autofit) 62 passed in 2s
black (25.1.0, repo defaults) changed regions clean; base files were not black-clean, and the resulting unrelated re-wrap churn was reverted, so the diff is change-only

New tests (NumPy-only, as the suite requires):

  • test_clipper.py::TestJointBallComposesWithAMapBijectorNone composes bit-identically; a log on a non-ball path composes and still projects the corner onto the disk; BijectorLogit on the ball pair raises and the message names (0, 1); a common scale=[2,2,1] projects onto 0.999/2; scale=[2,3,1] raises; BijectorDiagonal(ScalerPriorWidth()) with equal pair widths is accepted; a model declaring no ball is unaffected.
  • test_clipper.py::TestJointBallSearchWiring — restated from "refused at construction" to accepted at construction and refused at model resolution; plus the identifier pin.
  • test_bijector.py::test__round_tripping_a_per_path_map_is_bit_exact_where_it_is_identity — the F5 pin: identity coordinates round-trip with == (bit-exact), the log one to rel=1e-12. This is what the radius-division shortcut rests on.
  • test_multi_start_gradient.py — the combination constructs, an identity-mapped ball pair is accepted at model resolution and still pulls a corner lane in, and a logit-on-ell_comps combination raises.

Traced (JAX/CPU) integration, run outside the unit suite: a MultiStartAdam(clipper=ClipperPriorBoxJoint(), bijector=BijectorPerPath({"einstein_radius": "log"})) fit on a toy EllProfile-bearing model whose optimum sits at |e| = 2.0 (outside the disk, so the gradient pushes outwards every step and only the clipper can hold a lane):

bijector kinds: ['identity', 'identity', 'log']
model resolution: [(0, 1, 0.999)]
final |e| per lane: [0.999 0.999 0.999 0.999 0.999 0.999 0.999 0.999]
n_grad_nan_lane_steps: 0
n_value_nan_lane_steps: 0
n_clipped_lane_steps: 874

The 874 clipped lane-steps matter: without them the |e| <= 0.999 assertion would be vacuous. The counterfactual was checked too — the same fit with bijector=BijectorLogit() raises before the first step, naming the pair:

ClipperPriorBoxJoint cannot project onto the ball declared on parameter
indices (0, 1) ... that pair maps as (logit, logit) with linear scales
(None, None), which is not a disk.

Campaign implication (not in this PR)

To actually use the disk, the Phase 8B logit arm must be restated as a BijectorPerPath carrying logit on every path except ell_comps.*. That is a config change in autolens_profiling, not a library change, and is deliberately out of scope here.

Heart

Shipped under the human-authorised RED override: the sole red_reasons entry is "release validation FAILED (stage integrate)". The two YELLOW reasons (workspace validation rectangular_mge*, session-start-hook manifest drift) are unrelated to this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_011joies4k5TdRqezPUK8YET

The joint clipper was refused outright whenever a `scaler` or `bijector` was
set -- at construction in `MultiStartGradient.__init__` and again at
projection time in `ClipperPriorBoxJoint.project`. The refusal was stronger
than the geometry requires.

A ball is a statement about physical coordinates, but it survives a COMMON
linear rescale of both its members: `theta_i**2 + theta_j**2 <= R**2` is
exactly `phi_i**2 + phi_j**2 <= (R / s)**2`, so a disk of radius `R` IS a
disk of radius `R / s` in the stepped coordinates, and the radial shrink --
a pure multiply -- projects onto it correctly. Only a genuinely non-linear
pair has no closed form: a `log`/`logit` kind, or two identity coordinates
with different scales (an ellipse, whose nearest-point projection is the
root of a quartic).

Because the refusal was blanket, any gradient arm wanting a reparameterisation
anywhere in the model had to drop the joint clipper entirely, and its
`ell_comps` lanes then settled at the box corner `|e| = 1.414` -- inside every
prior box, outside the disk, in the flat region the clipper exists to escape.

- `AbstractBijector.identity_scales` reports the per-coordinate linear scale
  where the kind is `identity` and `None` where it is not. `None` rather than
  `1.0` for `log`/`logit`, deliberately: a log coordinate is not a coordinate
  scaled by one, and reporting it as such would let a caller compose with it
  silently and wrongly.
- `ClipperPriorBoxJoint.pairs_in_stepped_coordinates` resolves each declared
  ball pair against the map, returning the radius divided by the pair's common
  scale or raising -- now naming the offending index pair and its kinds.
- The construction-time refusal is deleted and the check moved into `_fit`,
  immediately after the bijector is resolved against the model and before
  `_vmapped` is built. Whether the two compose is a question about the MODEL
  and no model exists at construction; making it there is still before any
  likelihood evaluation, so a bad combination does not die a minute into a
  multi-hour fit.

The projection is NOT round-tripped through the bijector. That would be
correct for every kind but would drag the `logit` epsilon clamps across
coordinates the ball has nothing to do with, breaking the bit-identity the
interior-point path promises, saturating gradients at the clamped edges, and
adding traced ops to every step. Dividing the radius costs nothing and is
exact.

No new constructor arguments, so `__identifier_fields__` is untouched and no
stored result is re-keyed.

Tests are NumPy-only, as the suite requires: the composition cases and the
refusals in `test_clipper.py`, an F5 bit-exactness pin on a per-path round
trip in `test_bijector.py` (the pin the radius-division shortcut rests on),
and the search wiring in `test_multi_start_gradient.py`. 2301 passed, 3
skipped (base 2288/3). Traced behaviour verified separately on CPU: 8/8
lanes end on the disk under jit/vmap with 874 clipped lane-steps and zero
NaN gradients.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011joies4k5TdRqezPUK8YET
@Jammy2211 Jammy2211 added the pending-release PR queued for the next release build label Aug 28, 2026
@Jammy2211
Jammy2211 merged commit 54aa087 into main Aug 28, 2026
4 checks passed
@Jammy2211
Jammy2211 deleted the feature/joint-clipper-compose-with-bijector branch August 28, 2026 16:35
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.

fix: compose ClipperPriorBoxJoint with a bijector/scaler instead of refusing it

1 participant