fix: LogGaussianPrior declares its own (0, inf) support - #1527
Merged
Conversation
`LogGaussianPrior`'s support is `(0, inf)` -- `log_prior_from_value` returns `-inf` for `value <= 0` -- but the prior reported `(-inf, inf)`, because `Prior.__getattr__` delegated to a `TransformedMessage` whose limits default to `+/-inf` and were never set. `ClipperPriorBox` worked around it with an `isinstance` switch (PyAutoFit#1477, follow-up 3); every other consumer of `lower_limit` was simply told the wrong thing. Declare the support on the prior itself, and give `Prior` a general strictness contract so a consumer can tell an exclusive bound from an inclusive one without a type switch: - `Prior.lower_limit_strict` / `upper_limit_strict`, class attributes defaulting to `False`; `True` for `LogGaussianPrior`'s lower bound. - `LogGaussianPrior` sets `lower_limit = 0.0` / `upper_limit = inf` in `__init__`. - `Prior.limits` derives from `lower_limit`/`upper_limit` instead of returning a hardcoded `(-inf, inf)`, so the two notions cannot disagree by construction. - `ClipperPriorBox._limits_from_model` reads the strictness flags; its `isinstance` block, import and workaround docstrings are retired. The limits go on the prior rather than on the `TransformedMessage` deliberately. On the message they would be dropped by `with_base`, `copy`, `project` and `__call__`, and would change what `MeanField.lower_limit` and `LaplaceOptimiser(check_limits=True)` feed to `OptimisationState.valid` -- a live EP behaviour change well outside this fix. Verified before/after over 37 behavioural probes: identifiers (prior and search-with-clipper) byte-identical, so no output directory re-keys; `log_prior_from_value` pointwise identical; the nested-sampler unit-cube mapping identical; clipper bounds, projections and masks identical across 3 clipper configurations x 3 input vectors. One downstream-visible change: prior passing on a LogGaussian parameter with no config `Limits` entry previously produced `TruncatedGaussian(-inf, inf)`, whose `value_for(0.001)` was `-0.545` and whose `log_prior_from_value(-1.0)` was a finite `-8.0`. It is now lower-bounded at `0.0`. Tests: the reported support, the strictness flags, regression pins for `log_prior_from_value` / the unit-cube mapping / the identifier, and a parametrised property over every prior family asserting that a prior's reported support matches its actual support -- the general form of this bug, which fails on the parent commit for `LogGaussianPrior` alone. Closes #1526 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXEqAUkXK7UeXw5jGh2i4q
This was referenced Aug 27, 2026
Jammy2211
pushed a commit
to trexfr-ops/PyAutoFit
that referenced
this pull request
Aug 27, 2026
…ts overrides Three items left behind by PyAutoFit#1527, plus one live bug found while doing the first. VariableData.any dispatched through var_all ------------------------------------------- `VariableData.any` reduced via `var_all`, making it "is there a variable whose elements are ALL True" rather than "is ANY element True". For an array like `[True, False]` it answered False. That is a correctness bug in `OptimisationState.valid`, which asks `(parameters < lower_limit).any()`: a parameter vector with SOME components outside their limits was reported valid, and only a variable violating on EVERY component was caught. `MeanField`'s `valid.any()` under-reported the same way. Both call sites want a real `any`; the other four `.any()` call sites in the library are on numpy arrays and are untouched. OptimisationState.valid guards on truthiness -------------------------------------------- `if self.lower_limit and ...` now `is not None`. `lower_limit`/`upper_limit` are VariableData (a dict keyed by free variable) or None, so the old form worked only by accident of dict truthiness being non-emptiness. PyAutoLabs#1527's follow-up list read it as a scalar test and recorded a `0.0` bug that does not exist — but the guard was one type change away from making it real. Behaviour-preserving: the only case whose guard differs is the empty VariableData, where the comparison is empty and `.any()` is False either way. Redundant limits overrides removed ---------------------------------- PyAutoLabs#1527 made `Prior.limits` derive from `lower_limit`/`upper_limit`, leaving the `UniformPrior`, `LogUniformPrior` and `TruncatedGaussianPrior` overrides exact duplicates. The base coerces with `float()` and the overrides did not, so this was a possible type change — measured, and it is not one: all three store Python floats already, and under `jax.jit` a prior never reaches `limits` anyway (`tree_unflatten` -> `__init__` calls `float()` on the tracer and raises first, with or without this change). Now-unused `Tuple` imports dropped. Tests ----- `OptimisationState.valid` and `VariableData.any` had NO test coverage, so the suite would have stayed green through any change to either — the process lesson from PyAutoLabs#1477. New test_autofit/graphical/test_optimisation_state_valid.py covers both, verified by inversion: reverting `var_any` to `var_all` fails 3 of the 8. Full suite: 2186 passed, 36 skipped (baseline 2178/36, +8 new). Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
LogGaussianPrior's support is(0, inf)—log_prior_from_valuereturns-inffor
value <= 0— but the prior reported(-inf, inf).Prior.__getattr__delegates to a
TransformedMessagewhose limits default to±infand were neverset, so the one prior with a non-trivial support was the one prior that lied about it.
ClipperPriorBoxworked around this with anisinstanceswitch (#1477, follow-up 3),declaring the real bound in the clipper rather than on the prior. That workaround was
correct but misplaced: every other consumer of
lower_limitwas still being told astrictly positive parameter could go negative.
This declares the support on the prior itself, adds a general strictness contract so
consumers can tell an exclusive bound from an inclusive one without a type switch, and
retires the clipper's special case.
Closes #1526.
API Changes
Priorgains two class attributes,lower_limit_strict/upper_limit_strict, bothFalseby default;LogGaussianPriorsets the lower oneTrue.LogGaussianPriornow reports
lower_limit == 0.0andlimits == (0.0, inf)instead of±inf.Prior.limitsis now derived fromlower_limit/upper_limitrather thanreturning a hardcoded
(-inf, inf), so the two can no longer disagree — the rootcause of this bug class.
GaussianPrioris unaffected and still reports(-inf, inf).One downstream-visible consequence: prior passing on a LogGaussian parameter with no
config
Limitsentry now produces a lower-bounded prior, changing its unit-cubemapping. See full details below.
Test Plan
NUMBA_CACHE_DIR=… MPLCONFIGDIR=… python -m pytest test_autofit/—2178 passed, 36 skipped (baseline on
main: 2124 passed, 36 skipped).LogGaussianPrioralone and passes for the other five prior families — i.e. it is the general
form of this bug, not a restatement of the fix.
log_prior_from_value, unit-cube mapping, identifier) pass onboth sides of the change, which is what makes them meaningful.
Verified unchanged (measured, not argued)
Before/after over 37 behavioural probes against a running 3.12 install:
Identifier(prior)+ fulldescriptionIdentifier(LBFGS(clipper=ClipperPriorBox()))log_prior_from_valueat 11 points either side of 0value_for,unit_value_forround-trip,Model.vector_from_unit_vectorClipperPriorBoxbounds, projections, clipped-masksmessage.lower_limit(what EP/Laplace read)±infGaussianPrior/UniformPrior/LogUniformPrior/TruncatedGaussianPriorlimits34 of 37 probes identical; the 3 that moved are
lower_limitundercopy,pickleand
project— the fix itself.Full API Changes (for automation & release notes)
Added
Prior.lower_limit_strict(bool, defaultFalse) — whether the supportexcludes
lower_limititself, i.e.value > limitrather thanvalue >= limit.Prior.upper_limit_strict(bool, defaultFalse) — the same forupper_limit.LogGaussianPrior.lower_limit_strict = True.Both are class attributes: they are a property of the prior family, never of an
instance, so they stay out of
__dict__and out of the identifier.Changed Behaviour
LogGaussianPrior.lower_limit— was-inf, now0.0.LogGaussianPrior.upper_limit— wasinf, unchanged in value but now declared onthe prior rather than delegated.
LogGaussianPrior.limits— was(-inf, inf), now(0.0, inf).Prior.limits— was a hardcoded(float("-inf"), float("inf")), now(float(self.lower_limit), float(self.upper_limit)). Priors that already overridelimits(UniformPrior,LogUniformPrior,TruncatedGaussianPrior) areunaffected; their overrides are now exact duplicates of the base and can be removed
in a later tidy-up.
GaussianPriorstill resolves to(-inf, inf).ClipperPriorBox._limits_from_model— readsprior.lower_limit_strict/prior.upper_limit_strictinstead ofisinstance(prior, LogGaussianPrior). Outputis bit-identical; the type switch and its import are gone.
Migration
No migration is required for the prior API itself — the declaration is additive and
the density, unit-cube mapping and identifiers are unchanged.
Prior passing does change. Where a model's config supplies no
Limitsentry for aLogGaussian parameter,
AbstractPriorModelfalls back toprior.limits:TruncatedGaussianPrior(mean, sigma, -inf, inf)—value_for(0.001)was-0.545andlog_prior_from_value(-1.0)a finite-8.0, i.e. a strictlypositive parameter was passed a prior that samples negative values.
TruncatedGaussianPrior(mean, sigma, 0.0, inf)—value_for(0.001)is0.0089andlog_prior_from_value(-1.0)is-inf.This is a correctness fix, but it changes the unit-cube mapping of the passed prior.
Downstream inter-phase prior passing in PyAutoGalaxy / PyAutoLens is worth a
spot-check.
Deliberately not done (follow-ups)
TransformedMessageinstead of the prior. They would bedropped by
with_base/copy/project/__call__, and would change whatMeanField.lower_limitandLaplaceOptimiser(check_limits=True)feed toOptimisationState.valid— a live EP behaviour change outside the scope of this fix.limitsoverrides.line_search.OptimisationState.validusesif self.lower_limit and …, which isfalsy at
0.0. Pre-existing; untouched here.Generated by the PyAutoLabs agent workflow.
Generated by Claude Code