Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions active/16_transformed_message_factor_gradient_unpack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# `@PyAutoFit` `TransformedMessage.factor_gradient` crashes on first call

Type: bug
Target: priors
Difficulty: small
Autonomy: supervised
Priority: normal
Status: issued 2026-08-19 as PyAutoFit#1501 — awaiting external verification
(fix-or-delete hangs off the #1498 contract decision); do not start dev

Same shape as census finding A1 (#1331-01): a code path that has never run
end-to-end, dead on arrival.

## The finding

`TransformedMessage.factor_gradient` (`composed_transform.py:360-378`, main @
`21288bb`) unpacks four values from `self._transform_det_jac(x)`:

```python
x, logd, logd_grad, jacs = self._transform_det_jac(x)
```

but `_transform_det_jac` (`composed_transform.py:281-289`) returns three:
`(x, logd, logd_jacs)` where `logd_jacs` is a list of
`(logd_grad, jac)` tuples. Any call raises
`ValueError: not enough values to unpack (expected 4, got 3)`.

Reproducer on main:

```python
import autofit as af
af.UniformPrior(0.0, 2.0).message.factor_gradient(1.3)
# ValueError: not enough values to unpack (expected 4, got 3)
```

Even past the unpack, the body is wrong for the actual return structure: the
`for jac in reversed(jacs): grad = grad * jac` loop and the final
`grad + logd_grad` assume flat lists that `_transform_det_jac` does not
produce.

## Exposure

Zero production callers in `autofit/` — the `factor_gradient` in
`graphical/laplace/line_search.py` is an unrelated
FactorApproximation-level interface, and the only `exp_factor` use is one
test. So this is dead code that crashes if ever exercised; no live search
or EP path is affected.

## The fix (two options, adjudicate with #1498)

1. Repair it to be the physical-density gradient companion of `factor`
(value `base.logpdf(T(x)) + log_det`, gradient chain-ruled **plus** the
`log_det` gradient) and add it to the #1497 property sweep so the
gradient is checked against a numerical derivative of `factor`.
2. Delete it — if the #1498/#1500 adjudication lands a single-source
`log_density` contract, a hand-rolled second gradient path is exactly
the kind of duplicate this cleanup exists to remove.

Do not fix silently ahead of the #1498 contract decision; whichever
convention wins decides whether this method should exist at all.
89 changes: 88 additions & 1 deletion draft/bug/priors/15_transformed_message_logpdf_jacobian.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ Target: priors
Difficulty: medium
Autonomy: supervised
Priority: normal
Status: formalised — issue filed (PyAutoFit#1498), awaiting adjudication
Status: formalised — issue filed (PyAutoFit#1498); caller analysis complete
(2026-08-19, below), awaiting human adjudication of the contract choice

Found 2026-08-18 while implementing the #1497 property sweep (prompt
`bug/priors/09`). Same failure *shape* as census finding A4 (#1331-04): a
Expand Down Expand Up @@ -61,6 +62,92 @@ The #1497 property tests assert the physical density via `factor` and cite
`test_autofit/mapper/prior/test_prior_properties.py`); tighten them to
`logpdf` once resolved.

## Caller analysis (2026-08-19, main @ `21288bb`)

The inventory #1498 adjudication point 1 asked for, run over the full
`autofit/` production tree (tests excluded). Verified numerically:
`UniformPrior(0,2)` gives `logpdf(1.3) = −0.993174` vs
`factor(1.3) = −0.693147 = log 0.5`; `∫exp(logpdf)` over the support is
`0.564190 = 2/(2√π)` while `∫exp(factor)` is `1.000000`.

**Callers expecting a physical density (exposed to the bug):**

- `Prior.logpdf` — the public prior API. `UniformPrior.logpdf`
(`uniform.py:124`) calls `message.logpdf` explicitly, and its
boundary-epsilon nudge operates in *physical* coordinates, so the method
unambiguously intends physical semantics; every other prior reaches
`message.logpdf` via `Prior.__getattr__`. Uniform / LogUniform /
LogGaussian users are silently handed base-space values.
- `MessageInterface.pdf` (`interface.py:45`) — **zero production callers**
anywhere in `autofit/`; public-API surface only.
- The non-linear search stack never touches message `logpdf` — sampling and
MCMC/MLE go through `value_for` / `log_prior_from_value` / `factor`, all
verified correct. Exposure is API-level, not search-level.

**EP-internal callers — all base-space, and mutually consistent:**

- `MeanField.logpdf` / `logpdf_gradient` (`mean_field.py:294,300`), reached
from `FactorApproximation.__call__` / `func_gradient`
(`mean_field.py:631-633,647`) by the Laplace optimiser; `MeanField` is
itself a `Factor` wrapping `_logpdf`, so `projection(mode)` in the
`from_mode_covariance` `log_norm` calibration (`mean_field.py:404`) and
`AbstractMessage.__call__`/`factor_jacobian` (`abstract.py:392,398`) route
the same way.
- This loop is *coherent*, not buggy: a likelihood factor is a density over
data (measure-free in x), so `fval(x) + Σ log q_base(T(x))` is exactly the
**base-space tilted log density** at `z = T(x)`. The Laplace mode it finds
is the base-space tilted mode, and `from_mode` (`transform_jac` +
`jac.quad`) projects mode and covariance into base space consistently.
The missing `log_det` is the EP loop's working convention, and base-space
Laplace is arguably the better-conditioned choice (unbounded support).

**The one genuine cross-convention seam — `PriorFactor`:**

- `PriorFactor` (`declarative/factor/prior.py:23,62`) wraps `prior.factor` —
the *physical* density — as both its factor callable and its
`log_likelihood_function`. Its tilted objective under the numerical
optimiser is therefore
`log π_base(T(x)) + log_det(x) + Σ log q_base(T(x))` — one x-dependent
`log_det(x)` *more* than the coherent base-space tilted density (and one
short of the coherent physical one). Because the #1337 seam strips the
exact-update hooks (`graphical/README.md` roster row), declarative
PriorFactors really do take this numerical path: prior-factor EP updates
optimise a hybrid objective whose mode is neither the base-space nor the
physical tilted mode.

**Correction to adjudication point 2:** `logpdf_gradient` is *not* a third
convention. Its analytic gradient equals the central-difference derivative
of `logpdf` exactly (−0.520142 both, at the probe point) — the jacobian
multiplication is just the chain rule for the base-space composition
`base.logpdf(T(x))`. So (`logpdf`, `logpdf_gradient`,
`numerical_logpdf_gradient_hessian`) form one self-consistent base-space
family and `factor` is the physical one: **two conventions, not three**.

**New finding:** `TransformedMessage.factor_gradient`
(`composed_transform.py:360-378`) crashes on first call — it unpacks four
values from `_transform_det_jac`, which returns three. Zero production
callers (the `line_search.py` `factor_gradient` is an unrelated
FactorApproximation-level interface). Filed as `bug/priors/16`.

**Recommendation (for the human + #1500 to ratify — not actioned):** the fix
is a two-sided contract repair, not a one-line `logpdf` patch:

1. *EP-internal:* declare base-space `logpdf` the message contract (fix the
`composed_transform.py` module-docstring claim that `logpdf` accumulates
the Jacobian) and make `PriorFactor` consume the base-space density
(`message.logpdf`) instead of `prior.factor`, making the EP loop fully
base-space coherent. This changes prior-factor updates (removes the
spurious `log_det`) but preserves the deliberate-looking base-space
Laplace everywhere else. Belongs with #1500 Q2/Q3.
2. *Public API:* `Prior.logpdf` and `pdf` promise a physical density and
should route through `factor` (or be renamed/documented). Small,
standalone, user-facing correctness fix.

The other coherent option — adding `log_det` to `logpdf` itself so
everything is physical — touches every EP path at once and shifts EP Laplace
modes to physical-space MAPs; that is a design change that should only be
taken inside the #1500 single-source-density decision.

## Sequencing

Adjudicate alongside the parked single-source-density design (census C1/C4,
Expand Down
8 changes: 6 additions & 2 deletions draft/bug/priors/z_features.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Target: priors
Difficulty: too-large
Autonomy: supervised
Priority: normal
Status: phases 1-3 SHIPPED; phase 4 = design (#1500) + parked 14 + finding 15
Status: phases 1-3 SHIPPED; phase 4 = design (#1500) + parked 14 + findings 15-16

> **2026-08-18 wrap-up sweep.** All nine confirmed bugs (01-08, 10) are fixed
> and merged on PyAutoFit main — Phase 1 batch via #1344/PR#1345 (merged
Expand All @@ -18,6 +18,9 @@ Status: phases 1-3 SHIPPED; phase 4 = design (#1500) + parked 14 + finding 15
> 2026-08-18 update: 09+11 SHIPPED (PR#1499 merged `21288bb`); 12+13 filed as
> design issue #1500 (decisions pending); 14 parked behind #1500; new finding
> 15 (#1498) awaiting adjudication.
> 2026-08-19 update: the 15 caller analysis is done (adjudication brief in
> the prompt file) and turned up finding 16 (`factor_gradient` dead-on-call),
> drafted but not yet issued.

## Why this folder exists

Expand Down Expand Up @@ -111,7 +114,8 @@ over them.
| 12 | single_source_density_refactor (→ active/) | Each density is encoded in three places (`value_for` / `logpdf` / `log_prior_from_value`) | **design issue FILED 2026-08-18** (bundled with 13) | #1500 | — |
| 13 | collapse_prior_and_message (→ active/) | `Prior` and `Message` carry duplicated responsibility | **design issue FILED 2026-08-18** (bundled with 12) | #1500 | — |
| 14 | [replace_transform_stack_with_bijectors](14_replace_transform_stack_with_bijectors.md) | Replace hand-rolled `AbstractDensityTransform` with `tfp.bijectors` / `numpyro.transforms` | parked — go/no-go hangs off the #1500 design decision | — | — |
| 15 | [transformed_message_logpdf_jacobian](15_transformed_message_logpdf_jacobian.md) | `TransformedMessage.logpdf`/`pdf` omit the transform Jacobian (new finding from the 09 sweep) | **issue filed 2026-08-18** — awaiting contract adjudication (standalone or inside #1500) | #1498 | — |
| 15 | [transformed_message_logpdf_jacobian](15_transformed_message_logpdf_jacobian.md) | `TransformedMessage.logpdf`/`pdf` omit the transform Jacobian (new finding from the 09 sweep) | **caller analysis complete + posted to #1498 2026-08-19** (EP loop is coherently base-space; `PriorFactor` is the one hybrid seam; public `Prior.logpdf` mis-promises physical) — awaiting external verification + contract adjudication with #1500 | #1498 | — |
| 16 | transformed_message_factor_gradient_unpack (→ active/) | `TransformedMessage.factor_gradient` crashes on first call (unpacks 4 from a 3-tuple; dead code, found by the 15 caller analysis) | **issue filed 2026-08-19** — awaiting external verification; fix-or-delete hangs off the #1498 contract decision | #1501 | — |

---

Expand Down
Loading