Skip to content

Particle filter - #310

Open
thorek1 wants to merge 25 commits into
mainfrom
particle-filter
Open

Particle filter#310
thorek1 wants to merge 25 commits into
mainfrom
particle-filter

Conversation

@thorek1

@thorek1 thorek1 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

No description provided.

thorek1 and others added 4 commits July 22, 2026 10:06
… error

Add `filter = :particle` to `get_loglikelihood` for all perturbation orders
(:first_order through :pruned_third_order), integrating out the structural
shocks by Monte Carlo. Three variants selectable via `particle_filter_algorithm`:
:bootstrap (Dynare-style sequential-importance-resampling), :auxiliary
(Pitt-Shephard), and :tempered (Herbst-Schorfheide). Includes a resampling
suite (systematic/stratified/multinomial/residual), adaptive ESS resampling,
Lyapunov-based initial cloud, and a seeded `rng` for reproducibility.

Generalize measurement error (`measurement_error_std`) to the filter-based
likelihood path, including the Kalman filter (forward pass in src and the
ForwardDiff extension); it reduces exactly to the previous behaviour when zero.
The particle filter is a stochastic, non-differentiable estimator and errors
clearly under reverse-mode AD (Zygote/Mooncake) and ForwardDiff, steering users
to gradient-free samplers (Pigeons, nested sampling).

Validated that all three variants reproduce the exact Kalman log-likelihood on
a small RBC model and on the Smets-Wouters (2007) linear model / US data, up to
the expected Var/2 finite-particle bias. New test set `particle_filter`
(test_particle_filter.jl + test_particle_filter_sw07.jl), wired into CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhhiqdZNkzbGZKJ8oSeoTp
Rewrite the first-order (linear) path of all three particle-filter variants
(bootstrap, auxiliary, tempered) for speed. Particles are stored as the columns
of an nVars×N matrix and the whole swarm is propagated with two BLAS gemm calls
(Xₜ = A·Xₜ₋₁ + B·Eₜ) instead of N type-unstable, per-call-allocating closure
invocations. Particle pools are double-buffered so resampling and the tempered
Metropolis mutation run fully in place; the mutation caches Base = A·Anc once per
stage and only recomputes the batched shock term B·Eprop per proposal.
Resampling uses preallocated index/cumulative-weight buffers (in-place
`*_resample_indices!`), following LowLevelParticleFilters.jl; inverse
measurement-error variances and the log-normaliser are cached.

On the Smets-Wouters (2007) linear model (nVars=40, N=10k-20k) this cuts a
tempered-filter likelihood evaluation from ~192.7M allocations / 11.75 GiB to
~630 allocations / ~20 MiB (≈300,000× fewer allocations), and speeds up the
filters by roughly 11× (bootstrap/auxiliary) and 5-6× (tempered). All variants
still reproduce the exact Kalman likelihood. Higher orders continue to use the
generic methods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhhiqdZNkzbGZKJ8oSeoTp
Extend the performance work to the nonlinear orders (:second_order through
:pruned_third_order) for all three variants. Replace the type-unstable,
per-call-allocating `state_update` closure with typed in-place transitions
(`nonpruned_state_update_{2,3}!`, `pf_pruned_{2nd,3rd}!`) that gather the
augmented state with explicit loops and apply the solution matrices via
`kron!`/`mul!` with preallocated scratch. Particle pools are double-buffered and
built with a concretely-typed, `Val`-dispatched initialiser; the per-period loop
runs behind a function barrier so the large kwarg method body does not lose the
pool element type. A `Core.Box` (from a captured-and-reassigned pool variable in
the tempered filter) is avoided by keeping the captured cloud read-only and
swapping separate locals.

On Smets-Wouters (2007), pruned second order, N=4000 this cuts allocations from
~6.25M (bootstrap) / ~55M (tempered) to ~66k / ~153k, with GC time near zero.
All higher-order likelihood values are bit-identical to before. Values, missing
data, resampling schemes and error guards all verified; test_particle_filter
(47) and the SW07 test (7) pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhhiqdZNkzbGZKJ8oSeoTp
Standalone benchmark (benchmark/particle_filter_llpf_comparison.jl) comparing
MacroModelling's particle filters against a bootstrap ParticleFilter from
LowLevelParticleFilters.jl on the same first-order DSGE state space, cross-checked
against the exact Kalman likelihood. LLPF/Distributions are not package deps; the
script header explains running it in a throwaway environment.

Findings (RBC 2-obs / SW07 7-obs, N=20000): on the well-conditioned RBC all three
(MacroModelling bootstrap, MacroModelling tempered, LLPF bootstrap) agree with the
Kalman value; MacroModelling's specialised filter is ~2x faster than LLPF on RBC
and ~15x faster on SW07 (0.25s vs 3.7s per evaluation), and its bootstrap tracks
the Kalman likelihood closely on SW07 where the generic LLPF setup (rank-deficient
DSGE process noise mapped to a jittered MvNormal) drifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhhiqdZNkzbGZKJ8oSeoTp
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

Benchmark Results

main e8ed6c2... main / e8ed6c2...
FS2000/NSSS 4.87 ± 0.055 μs 4.86 ± 0.073 μs 1 ± 0.019
FS2000/covariance 18.2 ± 0.85 μs 18.1 ± 1 μs 1 ± 0.073
FS2000/irf 0.064 ± 0.013 ms 0.0671 ± 0.0044 ms 0.953 ± 0.2
FS2000/jacobian 1.02 ± 0.076 μs 1.03 ± 0.056 μs 0.992 ± 0.091
FS2000/lyapunov/bartels_stewart 0.108 ± 0.0064 ms 0.102 ± 0.0095 ms 1.06 ± 0.12
FS2000/lyapunov/bicgstab 25.5 ± 0.81 μs 25.5 ± 0.76 μs 1 ± 0.044
FS2000/lyapunov/doubling 0.0354 ± 0.00093 ms 0.0353 ± 0.00091 ms 1 ± 0.037
FS2000/lyapunov/gmres 16.5 ± 0.15 μs 16.6 ± 0.21 μs 0.992 ± 0.016
FS2000/qme/doubling 0.0994 ± 0.00085 ms 0.0956 ± 0.00049 ms 1.04 ± 0.01
FS2000/qme/schur 0.0449 ± 0.00098 ms 0.0446 ± 0.00092 ms 1.01 ± 0.03
NAWM_EAUS_2008/NSSS 0.914 ± 0.023 ms 1.14 ± 0.027 ms 0.803 ± 0.028
NAWM_EAUS_2008/covariance 3.27 ± 0.58 ms 3.62 ± 0.6 ms 0.904 ± 0.22
NAWM_EAUS_2008/irf 7.52 ± 0.31 ms 7.69 ± 0.66 ms 0.978 ± 0.093
NAWM_EAUS_2008/jacobian 0.039 ± 0.0038 ms 0.0439 ± 0.0088 ms 0.889 ± 0.2
NAWM_EAUS_2008/lyapunov/bartels_stewart 17.4 ± 0.52 ms 18.3 ± 1.8 ms 0.948 ± 0.1
NAWM_EAUS_2008/lyapunov/bicgstab 0.227 ± 0.00013 s 0.218 ± 0.00061 s 1.04 ± 0.003
NAWM_EAUS_2008/lyapunov/doubling 19.5 ± 0.21 ms 22.4 ± 0.092 ms 0.87 ± 0.0099
NAWM_EAUS_2008/lyapunov/gmres 0.115 ± 0.0006 s 0.133 ± 0.0072 s 0.86 ± 0.047
NAWM_EAUS_2008/qme/doubling 29.4 ± 0.43 ms 0.0354 ± 0.00016 s 0.831 ± 0.013
NAWM_EAUS_2008/qme/schur 19.3 ± 0.12 ms 19.2 ± 0.29 ms 1.01 ± 0.017
Smets_Wouters_2007/NSSS 0.0554 ± 0.00093 ms 0.143 ± 0.00098 ms 0.388 ± 0.0071
Smets_Wouters_2007/covariance 0.208 ± 0.0044 ms 0.295 ± 0.0074 ms 0.705 ± 0.023
Smets_Wouters_2007/irf 0.426 ± 0.028 ms 0.526 ± 0.028 ms 0.809 ± 0.069
Smets_Wouters_2007/jacobian 9.99 ± 29 μs 13.9 ± 30 μs 0.717 ± 2.6
Smets_Wouters_2007/lyapunov/bartels_stewart 1.26 ± 0.0093 ms 1.24 ± 0.012 ms 1.01 ± 0.012
Smets_Wouters_2007/lyapunov/bicgstab 5.72 ± 0.0075 ms 6.05 ± 0.009 ms 0.946 ± 0.0019
Smets_Wouters_2007/lyapunov/doubling 1.03 ± 0.0031 ms 1.02 ± 0.0033 ms 1.01 ± 0.0044
Smets_Wouters_2007/lyapunov/gmres 5.03 ± 0.01 ms 5.02 ± 0.0085 ms 1 ± 0.0026
Smets_Wouters_2007/qme/doubling 1.77 ± 0.0072 ms 1.76 ± 0.0085 ms 1.01 ± 0.0064
Smets_Wouters_2007/qme/schur 1.01 ± 0.0094 ms 0.975 ± 0.0073 ms 1.04 ± 0.012
time_to_load 11.8 ± 0.19 s 11.9 ± 0.058 s 0.992 ± 0.017
main e8ed6c2... main / e8ed6c2...
FS2000/NSSS 21 allocs: 0.855 kB 21 allocs: 0.855 kB 1
FS2000/covariance 0.134 k allocs: 17.2 kB 0.134 k allocs: 17.2 kB 1
FS2000/irf 1.07 k allocs: 0.0944 MB 1.07 k allocs: 0.0944 MB 1
FS2000/jacobian 0.046 k allocs: 1.62 kB 0.046 k allocs: 1.62 kB 1
FS2000/lyapunov/bartels_stewart 0.065 k allocs: 0.0563 MB 0.065 k allocs: 0.0563 MB 1
FS2000/lyapunov/bicgstab 0.038 k allocs: 3.51 kB 0.038 k allocs: 3.51 kB 1
FS2000/lyapunov/doubling 30 allocs: 3.26 kB 30 allocs: 3.26 kB 1
FS2000/lyapunov/gmres 0.034 k allocs: 3.45 kB 0.034 k allocs: 3.45 kB 1
FS2000/qme/doubling 6 allocs: 1.7 kB 6 allocs: 1.7 kB 1
FS2000/qme/schur 9 allocs: 1.95 kB 9 allocs: 1.95 kB 1
NAWM_EAUS_2008/NSSS 0.598 k allocs: 0.595 MB 1.49 k allocs: 0.643 MB 0.925
NAWM_EAUS_2008/covariance 1.14 k allocs: 2.65 MB 2.03 k allocs: 2.7 MB 0.982
NAWM_EAUS_2008/irf 10.2 k allocs: 9.6 MB 11.1 k allocs: 9.65 MB 0.995
NAWM_EAUS_2008/jacobian 0.076 k allocs: 0.714 MB 0.076 k allocs: 0.714 MB 1
NAWM_EAUS_2008/lyapunov/bartels_stewart 0.187 k allocs: 3.5 MB 0.187 k allocs: 3.5 MB 1
NAWM_EAUS_2008/lyapunov/bicgstab 0.298 k allocs: 0.409 MB 0.254 k allocs: 0.408 MB 1
NAWM_EAUS_2008/lyapunov/doubling 0.034 k allocs: 0.404 MB 0.034 k allocs: 0.404 MB 1
NAWM_EAUS_2008/lyapunov/gmres 0.123 k allocs: 0.406 MB 0.123 k allocs: 0.406 MB 1
NAWM_EAUS_2008/qme/doubling 8 allocs: 0.362 MB 8 allocs: 0.362 MB 1
NAWM_EAUS_2008/qme/schur 12 allocs: 0.365 MB 12 allocs: 0.365 MB 1
Smets_Wouters_2007/NSSS 0.071 k allocs: 2.5 kB 0.123 k allocs: 3.67 kB 0.681
Smets_Wouters_2007/covariance 0.288 k allocs: 0.188 MB 0.339 k allocs: 0.189 MB 0.994
Smets_Wouters_2007/irf 3.36 k allocs: 0.918 MB 3.41 k allocs: 0.919 MB 0.999
Smets_Wouters_2007/jacobian 0.059 k allocs: 0.0636 MB 0.059 k allocs: 0.0636 MB 1
Smets_Wouters_2007/lyapunov/bartels_stewart 0.09 k allocs: 0.326 MB 0.09 k allocs: 0.326 MB 1
Smets_Wouters_2007/lyapunov/bicgstab 0.15 k allocs: 0.0359 MB 0.158 k allocs: 0.036 MB 0.997
Smets_Wouters_2007/lyapunov/doubling 0.034 k allocs: 0.034 MB 0.034 k allocs: 0.034 MB 1
Smets_Wouters_2007/lyapunov/gmres 0.098 k allocs: 0.0351 MB 0.098 k allocs: 0.0351 MB 1
Smets_Wouters_2007/qme/doubling 8 allocs: 0.0317 MB 8 allocs: 0.0317 MB 1
Smets_Wouters_2007/qme/schur 11 allocs: 0.0325 MB 11 allocs: 0.0325 MB 1
time_to_load 0.143 k allocs: 10.6 kB 0.143 k allocs: 10.6 kB 1

Benchmark Plots

A plot of the benchmark results have been uploaded as an artifact to the workflow run for this PR.
Go to "Actions"->"Benchmark a pull request"->[the most recent run]->"Artifacts" (at the bottom).

thorek1 and others added 4 commits July 26, 2026 15:40
Collapse `particle_filter_algorithm` into the `filter` argument: the filter is
now fully identified by one symbol — `:bootstrap_particle`, `:auxiliary_particle`
or `:tempered_particle` (with `:particle` kept as an alias for the bootstrap
filter). A filter registry in `default_options.jl` drives validation and the
internal variant dispatch.

Prefix the particle-specific options so they read unambiguously alongside the
other filter options: `particle_resampling`, `particle_resampling_threshold`,
`particle_initial_state_scaling` and `particle_rng`. The filter bodies bind the
historical short names once so the optimised hot loops are untouched.

`measurement_error_std` now defaults to `:auto`, which resolves per filter: no
measurement error for the Kalman and inversion filters (their previous
behaviour), and 10% of each observable's sample standard deviation for the
particle filters, which are degenerate without it. Raise the default particle
count to 10_000, which keeps an SW07-sized problem accurate to a couple of
log-likelihood points in well under a second per evaluation.

Annotate the filter internals: why resampling is needed at all and how the four
schemes trade variance against cost, what each stage and buffer of the bootstrap
recursion does, and an intuitive account of what the auxiliary filter's
look-ahead buys and why dividing the preview back out keeps it unbiased.

The LLPF benchmark now takes its initial-state covariance from the package's own
Lyapunov solver (via `particle_initial_state_covariance`) instead of a
hand-rolled doubling loop, so both filters start from an identical prior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Add a `measurement_error_covariance` keyword to `get_loglikelihood` for
correlated measurement error, superseding `measurement_error_std` when supplied
and validated as square, symmetric and positive definite.

Nothing in a filter requires H to be diagonal — only H⁻¹ and log det H are ever
needed. The Kalman filter forms its innovation covariance as a matrix anyway, so
it now accepts an arbitrary H, in both the dense and the missing-data recursion
(where the observed sub-block H[idx, idx] is used) and in the ForwardDiff
extension. A diagonal covariance reproduces the per-observable standard
deviations exactly.

The particle filters keep the diagonal fast path: their inner loop is an
elementwise quadratic form, and correlated measurement error in a DSGE is more
naturally written into the model itself — measurement-error processes in the
observation equations move the correlation into the state transition and leave H
diagonal. An off-diagonal covariance is therefore rejected with a message
pointing at both alternatives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
New `docs/src/filters.md`, registered in the page tree, covering all filter
options in one place: the state-space setup they share, a comparison table and a
short decision rule, then the maths and assumptions of each filter — the Kalman
recursion and why it is exact and differentiable, what the inversion filter
inverts and why that rules out measurement error and requires as many shocks as
observables, and the predict/weight/resample skeleton of the particle filters.

Explains the properties that matter in practice: why the particle likelihood is
unbiased but its logarithm is biased downward by about Var/2 (so it sits below
the Kalman value and converges from below), why measurement error is required at
all, what each particle variant buys, and how the resampling schemes trade
variance against cost.

Adds a section on how the filters map into one another — the particle filters
converge to the Kalman likelihood on a linear model, the inversion and particle
filters make opposite measurement-error assumptions, and correlated measurement
error can be moved into the model's observation equations so that every filter
can handle it.

Replace the Dynare reference in the estimation tutorial with the primary sources
(Gordon, Salmond & Smith 1993; Fernández-Villaverde & Rubio-Ramírez 2007; Pitt &
Shephard 1999; Herbst & Schorfheide 2019) and update it to the new filter names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
…ints

Add a `filter_data_with_model` method for the particle filters, so
`get_model_estimates`, `get_estimated_variables`, `get_estimated_shocks` and the
estimate plots work with `filter = :bootstrap_particle` and friends. It returns
the filtered moments of the particle cloud: the weighted mean of the states, the
weighted spread as standard deviations, and the weighted mean of the drawn
shocks.

All three particle variants target the same filtering distribution — they differ
only in how efficiently they estimate the likelihood — so the moments come from
the standard predict/weight/resample recursion whichever variant is selected.
Two honest limitations, both signalled: these are filtered rather than smoothed
estimates (a particle smoother is a different algorithm), and a linear shock
decomposition does not exist for a nonlinear filter, so `decomposition` is
returned as zeros with an informational message.

Thread the particle options (`measurement_error_std`, `n_particles`,
`particle_resampling`, `particle_resampling_threshold`,
`particle_initial_state_scaling`, `particle_rng`) through those entry points and
the two plotting functions that take a `filter`.

Tested against the Kalman estimates on a linear model, where the filtered
particle paths track the smoothed Kalman paths closely, plus nonlinear orders and
the combined estimates entry point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
@codecov-commenter

codecov-commenter commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.55482% with 143 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.03%. Comparing base (f2274a0) to head (8ef6618).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/filter/decomposition.jl 73.08% 95 Missing ⚠️
src/occasionally_binding_constraints.jl 25.00% 15 Missing ⚠️
src/MacroModelling.jl 66.66% 9 Missing ⚠️
src/get_functions.jl 92.92% 7 Missing ⚠️
src/perturbation/solution.jl 99.24% 5 Missing ⚠️
src/filter/kalman.jl 82.60% 4 Missing ⚠️
ext/ForwardDiffExt.jl 95.89% 3 Missing ⚠️
src/filter/find_shocks.jl 95.65% 3 Missing ⚠️
ext/StatsPlotsExt.jl 83.33% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #310      +/-   ##
==========================================
- Coverage   88.05%   88.03%   -0.02%     
==========================================
  Files          35       37       +2     
  Lines       30243    32306    +2063     
==========================================
+ Hits        26629    28440    +1811     
- Misses       3614     3866     +252     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

thorek1 and others added 17 commits July 26, 2026 16:55
Implement smoothing for the particle filters. `smooth = true` now returns
E[xₜ | y₁..T] instead of E[xₜ | y₁..ₜ] for `get_model_estimates`,
`get_estimated_variables`, `get_estimated_shocks` and the estimate plots.

The method is fixed-interval smoothing along the filter's genealogy: every
particle surviving at T carries the ancestral line that produced it, and those
lines are draws from p(x₁..T | y₁..T), so averaging them with the terminal
weights gives the smoothed moments. The textbook backward-kernel smoother is not
usable here: it reweights by p(xₜ₊₁ | xₜ), which for a DSGE with fewer shocks
than states is a Dirac on a lower-dimensional manifold and hence undefined. The
known limitation (path degeneracy of the ancestral lines) and the memory cost are
documented. `normalize_filtering_options` now permits smoothing for the Kalman
and particle filters and only disables it for the inversion filter.

Verified on a linear model: smoothing more than halves the distance of the
particle estimates to the Durbin-Koopman smoother (0.020 vs 0.047 filtered).

Finish the argument cleanup: the last `particle_filter_algorithm` mention (a
stale header comment in particle.jl) is gone, so the filter is identified by a
single symbol everywhere. Replace the remaining Dynare references in the filter
sources with the primary works — Gordon, Salmond & Smith (1993) and
Fernández-Villaverde & Rubio-Ramírez (2007) for the bootstrap filter. All six
filter-taking entry points now expose the same particle options, and a
`measurement_error_std` passed to an entry point that cannot use it (the Kalman
smoother path) reports that it is ignored instead of dropping it silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Shock decomposition works for the particle filters after all — it needs a shock
path, and the smoother now supplies one, so the same attribution the inversion
filter uses applies. At first order the contributions are additive and the split
is exact. At pruned second and third order they are not additive, which is what
the Aumann-Shapley (marginal contribution) attribution is for, so those orders
reuse `aumann_shapley_shock_decomposition_pruned_{2nd,3rd}_order!` from
inversion.jl under `marginal_contribution = true`. Non-pruned second/third order
have no decomposition at any filter, matching the existing gating. The previous
blanket "not defined for particle filters" claim was wrong and is gone.

One subtlety this surfaced: the Aumann-Shapley routine checks that the
contributions reproduce the supplied `variables`, but a smoothed *mean* is not a
model trajectory (averaging does not commute with a nonlinear transition), so
passing it in left a closure error the routine tried to remove by refining its
quadrature until it ran past its hand-coded node limit. The pruned decomposition
therefore attributes the trajectory implied by the smoothed shocks — the same
object the inversion filter decomposes — which closes exactly.

Also fixes a duplicated keyword block in `plot_model_estimates` introduced when
the particle options were threaded into the plotting extension, which made the
function uncallable with any arguments. Adds plotting coverage (Kalman, filtered
and smoothed particle filters, and a pruned second-order run with the
Aumann-Shapley decomposition) so the extension is exercised by the particle
filter test set rather than only by the plots jobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Implement the sequential pruned attribution so `marginal_contribution = false`
works too, rather than reporting that Aumann-Shapley is required. It runs one
trajectory per shock with only that shock switched on plus one with all of them:
each single-shock path is that shock's contribution, the all-shock path minus
their sum is the interaction the nonlinearity creates, and the remainder goes to
the residual. This reproduces the inversion filter's column layout, so both
attributions now match it exactly — nExo+3 columns for the sequential split and
nExo+2 for the Aumann-Shapley split, which distributes the interaction across the
shocks instead of isolating it.

Decomposition works off whichever shock path the run produced, so it is available
for the filtered estimates (`smooth = false`) as well as the smoothed ones
(`smooth = true`); the two give different decompositions because the underlying
shock estimates differ. Tests cover both attributions at both pruned orders under
both settings, and check that at first order — where the split is additive — the
shocks explain the bulk of the movement and the residual is left carrying the
initial-state contribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
…tics

Reviewer feedback: the argument is a covariance, not a standard deviation,
and it had three different names across the code (`measurement_error_std`,
`measurement_error_variances`, `measurement_error_covariance`).

- one user-facing kwarg `measurement_error`, subsuming the separate
  covariance argument: scalar = common variance, vector = per-observable
  variances, matrix = full covariance. The pre-existing positional
  `measurement_error_std` on the filter-free `get_loglikelihood` keeps its
  name, since it genuinely is a standard deviation.
- kernels take `measurement_error` too, and `:auto` is resolved once at the
  user-facing layer (`resolve_measurement_error`) so no kernel sees a Symbol.
- the particle filters now accept a correlated covariance: `DenseMeasurementError`
  caches a Cholesky factor of H restricted to each missing-data pattern. A
  diagonal matrix is reduced to the variance vector so the elementwise fast
  path is unchanged.
- `on_failure_loglikelihood` defaults to -1e6 for the particle filters instead
  of -Inf; a stochastic failure should reject a proposal, not kill a chain.
- `get_estimated_variable_standard_deviations` gains `algorithm`/`filter` and
  the particle kwargs, reporting the cloud spread at any perturbation order.
- particle-filter keyword docs move to `common_docstrings.jl` as shared
  constants; `filters.md` and the estimation tutorial follow the new semantics.
- answer the review questions inline: why the Kalman filter needs H at all
  (particle.jl header), why the initial covariance is first order at every
  perturbation order, and why the inversion filter's smoother is a no-op
  rather than unsupported (its filtered estimate is already the smoothed one).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Reviewer feedback: the buffers were allocated per call. Inside a sampler the
likelihood is evaluated thousands of times at identical dimensions, so they
now live in `𝓂.workspaces.particle`, lazily sized by
`ensure_particle_workspace!(nVars, nExo, n_particles)` in the same style as the
Kalman and Lyapunov workspaces.

Six nVars×N and three nExo×N matrices plus the per-particle vectors cover the
simultaneous needs of all three first-order kernels (the tempered one needs the
most: ancestors, states, Metropolis proposals, and a swap partner for each).
Repeat evaluations at N = 20,000 now allocate 18 KiB instead of ~5 MB.

The higher-order kernels keep per-call pools: their particles are
`Vector{Vector}` whose element layout depends on the pruning order, so they do
not share a fixed buffer shape.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Reviewer feedback: the tempering arguments should be reachable from every
function that takes `filter`. They now are — but adding them as inert kwargs
would have been worse than not having them, so `filter_data_with_model` learns
the tempered recursion rather than only the bootstrap one.

`:bootstrap_particle` and `:auxiliary_particle` genuinely need no distinction
here: the auxiliary filter's look-ahead proposal changes the variance of the
*likelihood* estimate, not the cloud it leaves behind. `:tempered_particle`
does change the cloud — within each period it bridges from the prior to the
full measurement density, resampling and rejuvenating the shocks by
random-walk Metropolis at each stage — so the estimates and the smoother both
benefit from the extra distinct support points.

The smoother composes the within-period resampling maps with the
end-of-period ones (`within[t]` then `parent[t-1]`) so the genealogy still
walks correctly. Verified: on the linear model the tempered smoothed path is
closer to the Kalman smoother than the filtered path, and the tempering
controls demonstrably change the result.

Also documents why forward-filtering backward-smoothing is not available:
re-pairing a stored x_{t+1} with a different ancestor needs an ε solving
g(x_t^i, ε) = x_{t+1}, which is overdetermined when there are fewer shocks
than states, so every backward weight is zero. FFBS would need a
kernel-regularised transition; the genealogy smoother is exact as written, and
tempering is the lever against its path degeneracy.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
The first cut looked up the Cholesky factor through a `Dict` keyed on the
observed-row pattern, once per particle per period, and solved against a view
of the scratch buffer. Both allocate: 87 MB per evaluation at N = 20,000 over
40 periods.

The pattern is constant inside a period, so a one-entry memo guarded by an
allocation-free comparison skips the dictionary entirely, and the triangular
solve is a hand-written forward substitution over a plain matrix — for the
handful of observables a DSGE has, faster than dispatching to BLAS and with no
view to heap-allocate. `vᵀH⁻¹v = ‖L⁻¹v‖²`, so the substitution produces the
solve and the quadratic form in one pass. 87 MB → 20 KiB, same likelihood.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
The change of variables y -> eps contributes -log|det Z| (Z = CB) to the
loglikelihood, but `logabsdets` was placed inside the -1/2 alongside the
quadratic form, so it contributed -1/2 log|det Z|.

This is not a harmless constant: log|det Z| carries the shock standard
deviations, so halving it halves the likelihood's penalty against large shocks.
On an AR(1) the profile likelihood peaks at sig^2 = 2Q/T instead of Q/T —
estimated shock standard deviations inflate by exactly sqrt(2), confirmed
numerically at 1.4151 vs sqrt(2) = 1.4142. The inversion filter is the default
for every nonlinear algorithm, so this affected higher-order estimation
throughout.

Verified on an AR(1) observed directly, where the conditional likelihood is
closed form: Kalman and inversion now both return 959.0753, matching exactly;
before, inversion returned 270.6024, short by exactly half the Jacobian.

Fixed in all ten primal branches (first order, pruned 2nd/3rd, missing-data
variants) and the ten copies in the rrules, together with every hand-written
Jacobian cotangent, which simply doubles when the primal term goes from -1/2 L
to -L (sign conventions are preserved by scaling). The gradient cross-checks
(ForwardDiff vs Zygote vs FiniteDifferences, all five orders including the
under-identified case) stay at 264/264, so the primal and the pullbacks moved
together.

Adds test/test_inversion_filter_likelihood.jl, which pins the *level* against
closed-form answers rather than only checking finiteness or cross-path
agreement — the bug was invisible to both, since every path shared it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
The sharp statement of how the filters relate is that the inversion filter *is*
the Kalman filter started at P₁ = BB'. The inversion filter assumes x₀ is known
exactly, so the only uncertainty about x₁ is that period's shocks; given at
least as many observables as shocks the update then drives the posterior
covariance to exactly zero and it stays there, so the two agree period by
period rather than merely asymptotically.

Verified to machine precision: identical on the small RBC, and 1.6e-9 apart on
Smets-Wouters (2007) with 7 shocks, 7 observables and 184 periods. The ergodic
prior is a genuinely different starting point — 489 log points away on SW07 —
so the test also asserts that gap, guarding against passing for the trivial
reason that the initial covariance does not matter.

The particle-filter side of the same statement was already covered: the initial
cloud is drawn with the ergodic covariance, Var(x₁) = AΣA' + BB' = Σ, which is
`initial_covariance = :theoretical`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
The particle filters' `initial_covariance` is Var(x₀) — the cloud is drawn
around the initial state and *then* propagated — while the Kalman filter's
argument of the same name is P₁ = Var(x₁), the first predicted state. They
correspond as P₁ = A·Var(x₀)·A' + BB'.

The distinction is invisible at the `:theoretical` default, because the ergodic
covariance is the fixed point of exactly that map and so is carried to itself.
That is why passing `:theoretical` to both filters lines them up, and why the
difference went unnoticed. It bites as soon as an explicit matrix is supplied:
reproducing a Kalman run with P₁ = BB' (i.e. the inversion filter) needs a
*zero* matrix here, not BB'.

Verified on SW07 at both ends of the correspondence, with measurement error and
20,000 particles: Var(x₀)=0 vs Kalman P₁=BB' agree to 1.55 log points, and
Var(x₀)=BB' vs Kalman P₁=A BB' A'+BB' to 3.34 — both within Monte-Carlo error
and on the expected (downward) side of it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
…ee path

filters.md explained each filter but not the two inputs that cut across all of
them, nor the conditions under which they are the same filter.

Adds:

- "Measurement error and the initial covariance": what each is and, more to the
  point, how they differ — H is noise on the observation and acts every period
  forever, P₁ is a prior on the state and decays. The three jobs H does
  (genuine measurement error, stochastic singularity, misspecification) are
  separated, along with the fourth, purely computational one for the particle
  filters. Includes the timing-convention warning (particle filters take
  Var(x₀), the Kalman filter Var(x₁)) and the interaction: H > 0 forces a
  strictly positive steady-state P, which is why the inversion filter's P ≡ 0
  and measurement error are contradictory rather than merely unimplemented.

- "When are they the same filter?": the equivalences as a table with their
  conditions. The sharp one is that the inversion filter *is* the Kalman filter
  started at P₁ = BB' — exact period by period given n_y ≥ n_ε and H = 0.

- "More shocks than observables": spells out the implicit assumption the
  inversion filter makes there. Minimum norm is the conditional mean, so the
  score stays a proper density; what fails is the clamp P ≡ 0, which needs
  rank(CB) = n_ε. The filter then understates the innovation covariance, by an
  amount governed by how much of the unidentified subspace propagates into the
  next period's observables — measurable, and cheap, from a first-order Kalman
  recursion.

- "The filter-free likelihood": the fourth option, which does not filter at all
  but treats the shocks as parameters. Notes why it has no initial covariance,
  why measurement error is mandatory there, and why its argument keeps the
  `_std` suffix (a matrix means per-period standard deviations, not a
  covariance).

Verified by building the page: 4 tables, the admonition and both cross-
references render, with no pipe collision in the table maths.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
…alence

`get_loglikelihood` accepted `initial_covariance` but the estimate entry points
did not — they always used the ergodic covariance, with no way to override it.
Adds the keyword to `get_shock_decomposition`, `get_estimated_shocks`,
`get_estimated_variables`, `get_estimated_variable_standard_deviations` and the
two estimate plots, threading it through `filter_data_with_model` into
`filter_and_smooth`. It is forwarded only to the Kalman and particle filters;
the inversion filter has no state covariance, and says so rather than silently
ignoring a supplied matrix. The same pass also fixes two kwarg blocks that had
been missed when the tempering controls were threaded through.

With that in place the equivalence can be checked where it actually bites — on
the paths, not on one scalar. With P₁ = BB' the Kalman gain is
BB'C'(CBB'C')⁻¹ = B Z⁺, which *is* the inversion filter's state recursion, so
the two must track the same states and shocks. On SW07 they agree to ~1e-10
across all variables and all 184 periods, against 0.99 relative deviation under
the ergodic prior.

One subtlety, now documented and tested: the states match the Kalman
*smoothed* estimates, not the filtered ones. Seven observations do not pin all
forty model variables contemporaneously, though the full sample does — checkable
directly, since under P₁ = BB' the smoothed dispersion collapses to ~3.6e-5
while the filtered dispersion does not. Exact identification of the state is
the inversion filter's assumption, so the smoothed estimates are what it
reproduces. The shocks match under both.

filters.md gains a "What you get by default" table and the state-equivalence
section, plus a note that `initial_covariance` is expressed in different bases
on the likelihood and estimate paths.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Above first order there is no Kalman filter to compare against, so the
higher-order particle machinery — the pruned and non-pruned second- and
third-order transitions, their Kronecker scratch, the pruned Vector{Vector}
particle layout — had nothing exact to be checked against. Two references
close that gap.

1. A *linear* model has no higher-order solution terms, so every perturbation
   order describes the same system and the exact answer is the Kalman
   likelihood at every order. Smets-Wouters (2007) log-linearised is such a
   model, and the tests assert that premise rather than assume it: the
   inversion filter is deterministic, so it must return an identical value at
   all five orders (it does, to 1e-10). Deviations from the Kalman value are
   then -2.7 / -0.3 / -0.3 at first, pruned second and second order — all
   within Monte-Carlo error and all on the expected downward side. Pruned and
   non-pruned agree exactly, which is itself a check on the pruning code.

2. On a genuinely nonlinear model the reference is the inversion filter. As
   H -> 0 the measurement density collapses onto the change of variables
   y -> eps, so p(y|x) -> N(eps_hat;0,I)/|det Z|, which is the inversion
   filter's per-period term; a degenerate initial cloud matches its other
   assumption, that x0 is known. The test asserts the *direction* rather than a
   tolerance, because the observed gap is non-monotonic (-35.9, -1.6, -3.2 as
   the measurement-error variance falls through 1e-4, 1e-5, 1e-6). That is not
   a defect: shrinking H is exactly what degenerates the importance weights, so
   the approach stalls at a floor set by particle noise.

Third order on forty variables costs minutes per evaluation — the first version
of this took 19 minutes — so the third-order sweep runs on a small linear model
instead, where all five orders finish in seconds. SW07 keeps first and second
order, where it is cheap and the dimensionality is the point.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
* Keep higher-order state updates compressed

* Cache higher-order power contractions

* Fix compressed higher-order CI regressions

* Cache invariant compressed selector maps

* Fix compressed higher-order review paths

* Correct review verification count

* Fix ForwardDiff higher-order gradient paths

* Rewrite the particle filters and add a guided (conditionally optimal) variant

The estimates path was ~500x more allocating than the likelihood path and no
particle filter gave shock estimates that survived a change of RNG seed on a
Smets-Wouters-sized problem. Both are addressed here.

Performance. The filters are rebuilt around a batched particle cloud: nVars x N
matrices, one per pruned state component, so a period costs a handful of BLAS
gemm calls instead of N gemv calls, and the estimates path shares that machinery
instead of duplicating it in a closure that dispatched dynamically per particle.
The block loop is split across Julia's threads. On SW07 at pruned second order
get_estimated_shocks went from 12.06 s / 4900 MiB / 319M allocations to
0.35 s / 19 MiB / 36k for the bootstrap filter, and 92.8 s to 8.4 s for the
tempered one. src/filter/particle.jl is shorter than before.

Correctness. Batched compressed-Kronecker kernels match the reference vector
kernels; the batched transition matches the model's own state_update at all five
perturbation orders; on a linear model every order and every variant reproduces
the Kalman likelihood, ordered by efficiency. Missing data, smoothing and both
shock decompositions verified.

Tempered filter. The Metropolis mutation is now preconditioned by the stage's own
Gaussian covariance and its scale adapts towards 25% acceptance; a fixed isotropic
step cannot suit a target that contracts as phi rises and is anisotropic across
shocks. Its defaults move to tempering_target_ratio 1.5 and 4 mutation steps,
which measurably improve both the estimates and the likelihood per unit of
compute (see the comments in default_options.jl for the numbers).

New filter :guided_particle. With about as many shocks as observables and a small
measurement error, the observation nearly determines the shock given the ancestor,
and that conditional is available in closed form. Drawing from it makes the
importance weight identically constant when the transition is linear in the shock.
The proposal's centre is refined by Gauss-Newton steps against the true residual,
and the filter then anneals from the proposal to the exact conditional along
q^(1-beta) pi^beta, which costs 1.2 stages per period on average and only spends
more where the proposal is poor. On the euro-area data it needs eight to twelve
times less compute than the tempered filter for equal accuracy or better, on both
the estimates and the likelihood, at first and pruned second order.

:particle now aliases :guided_particle rather than :bootstrap_particle, and
tempering_mh_steps defaults to 2 for the guided filter and 4 for the others via a
filter selector. This changes results for existing callers passing :particle.

Also: the filters now warn instead of silently returning noise when the cloud has
degenerated, and both AGENT_PROGRESS.md and tasks/todo.md record the measurements
behind each choice, including the approaches that were tried and rejected (full
adaptation, proposal over-dispersion, deferred within-period resampling).

Verified with test/test_particle_filter.jl (142/142; its plotting testset needs a
libgobject this container lacks) and a focused check covering all four variants at
every perturbation order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fa11GzkuyTSc6xjoEP17br

* delete Agent progress

* Fix higher-order inversion-filter pullbacks against finite differences

The compressed-Kronecker migration left several reverse-mode paths on the
uncompressed convention, or reusing buffers that were still live.

Weights. d/dx compressed_kron²_power(aug(x)) is 2·compressed_kron²(aug, ∂aug/∂x)
and the cubic analogue carries a 3, so the 1/2 and 1/6 in front of the 𝐒₂ and 𝐒₃
terms cancel down to 1 and 1/2 in the pullback. The non-pruned stochastic
steady-state pullbacks kept the forward weights, which is the main regression
here: the SSS correction is itself second-order small, so this read as 0.15 % on
∂state but cancelled up to ~390 % in the likelihood gradient, and it also reached
get_steady_state's parameter Jacobian. The same off-by-a-factor appears in the
cubic third-order warmup term, in accumulate_cubic_kron_jacobian_pullback! (whose
helper is already the exact VJP), and in the second-order warmup, where a pair
VJP was double-counting the 1/2.

Aliasing. compressed_kron{²,³}_power_vjp! overwrite their output while the
identity variants accumulate; three call sites passed a shared accumulator and
so dropped the terms added before them. Separately, the dense pruned third-order
path used the ∂kronstate¹⁻_vol cotangent as forward scratch, and the third-order
with-missing backward loop read a workspace buffer still holding the last
forward step's value.

KKT blocks. The dense pruned second- and third-order shock-solver cotangents
were assembled by uncompressed ℒ.kron!, which crashed on any model with nExo ≠ 2.
Rewritten in the compressed pair/triple bases.

Forward side. third_order_warmup_observation_and_jacobian still built its
shock-state mixed terms uncompressed, so it disagreed with its own pullback;
compressed_pair_hessian! sets both triangles, hence the factor 2 the shock
solver's Newton step was missing; the ForwardDiff Dual overloads needed the same
compressed treatment.

FS2000 parameter gradients, Mooncake against central_fdm(5,1), worst relative
error over T = 1/5/40 and all five algorithms: 8.5e-08, against 3.9 before.
test_inversion_filter_gradients 264/264, test_higher_order_1 7869/7869,
test_missing_data 128/128, test_rrule_robustness 57/57.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address particle-filter review comments

Naming. The bridging controls were named after tempering but are shared with the
guided filter, so tempering_* becomes particle_* and the two per-variant values
keep their variant's name (DEFAULT_GUIDED_MH_STEPS, DEFAULT_TEMPERED_MH_STEPS).
The convention is now stated at the top of the defaults block. Magic numbers that
were inline in particle.jl — scratch budget, block sizes, Metropolis adaptation
targets and bounds, the low-ESS warning threshold — move to named defaults.

propagate_block! loses its algorithm if/elseif chain in favour of dispatch on the
perturbation order. build_guided_proposal reuses its factorisation across the
Gauss-Newton refinement instead of re-solving. Comments that leaned on jargon are
rewritten to say what the code does, and the defaults' commentary is cut back to
the conclusions with the supporting tables dropped.

Statistics was declared as a test dependency without a compat entry, which Aqua
flags. JET could not correlate get_loglikelihood's measurement-error guard with
the separate `if` that uses it, so the narrowing moves to the use site.

Deletes the tasks/ scratch files, which were working notes rather than sources.

test_particle_filter, Aqua and the JET hot-path suite all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Correct the particle-filter guidance in the filters guide

The page told readers to fall back to :tempered_particle "when the model is
strongly nonlinear", which its own pruned-second-order benchmark contradicts, and
to prefer it for estimates. Both were right when written, before the guided filter
gained its own Metropolis sweeps; neither is now.

Replaces the vague trigger with the assumption that actually binds — the guided
proposal is built from the first-order shock impact on observables, so it wants
the observation roughly linear in the shock — with the diagnostic that fires when
it fails (the post-bridge ESS warning) and the price of switching (~10x). The
estimates recommendation flips to :guided_particle, which the page's own table has
at 0.078 seed dispersion against 0.093 at an eighth of the cost. The "estimates
versus likelihoods" section now attributes the fix to mutation rather than to
tempering, since both filters mutate. Also repairs a broken @ref.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address review comments on the compressed higher-order work

Correctness questions raised in review, both answered with finite differences
and now pinned by tests. The compressed-power derivative identities are
d/dx compressed_kron²_power(x) = 2·compressed_kron²(x, dx) and
d/dx compressed_kron³_power(x) = 3·compressed_kron³(x, x, dx), so the forward
Taylor weights 1/2 and 1/6 become 1 and 1/2 in every Jacobian built on them.
That is why the third-order warmup recursion divides the cubic term by 2 where
the uncompressed form divided by 6, and where the factor-of-3 change in
occasionally_binding_constraints comes from. Checked against central differences
at 1e-10; the alternatives are off by exactly 2 and 3. The OBC Jacobian was also
checked end to end against differences of its own output (7.9e-10 at second
order, 3.9e-10 at third).

Structure. The Aumann-Shapley historical shock decomposition belongs to neither
filter that uses it, so it moves to src/filter/decomposition.jl. The particle
filter's three column-wise Kronecker kernels were a second implementation of
what perturbation/solution.jl already does per vector; they become
compressed_kron{²,³}_power_columns!/compressed_kron²_columns! next to the
kernels they delegate to. The compressed_kron test set was orphaned — no CI job
ever ran it — and now runs as part of basic.

Performance, each measured rather than assumed.
  - The cubic shock/state index sets and their row maps were rebuilt per call
    (a sort plus a binary search per row) though they depend only on the model's
    dimensions. Memoised on those dimensions.
  - The KKT pair-block cotangents read the target column back on the right of a
    `.+=`, which materialises it: 240 B per call at nExo = 2 up to 328 kB at
    nExo = 40, and 1.6-5.8x slower than two `ger!`s. Rewritten as preallocated
    rank-1 updates. End to end it is a small share (0.5 MB of 150 MB on
    Smets-Wouters at nExo = 7) but it grows with nExo.
  - `A_mat .-= 2 .* Matrix(I(n_x))` in the ForwardDiff KKT blocks materialised a
    dense identity for a diagonal update. Now a loop.
  - `@simd` on the compressed kernels was tried and is 1.6-2.6x *slower* — the
    body's branch is perfectly predicted and `@simd` trades it for a masked
    store. Recorded so nobody retries it.
  - Densifying 𝐒₂/𝐒₃ for the particle filters was questioned. In the compressed
    basis they are 28-87% dense on every model measured, and dense `gemm` beats
    sparse over all of that range (3.2-7.9x with one BLAS thread, up to 26x with
    four); the crossover is near 1% density. Measurements are in the comment.

The `filtered` keyword on solve_stochastic_steady_state_newton had no caller
passing `false` — both live call sites pre-slice and pass `true` — so it and its
dead branch are gone from all four methods.

Documentation and naming. The filters guide now maps every knob to the algorithm
step it controls, for the tempered and the guided filter alike. The bridging
options are shared by both filters, not tempered-only as their docstrings
implied, and now say so; `filter`'s docstring notes that the inversion filter is
the fastest nonlinear option, that it can fail outright rather than degrade, and
that its smoothed and filtered estimates coincide. The guided filter's internal
`particle_mh_steps` fallback said 4 where the public selector gives it 2.
Semicolon-joined statements are split one per line, cryptic locals in the
particle filters are named for what they hold, and the de-indented lines the
compressed migration left in inversion.jl are re-indented.

Comments answering the rest: how the Laplace approximation feeds the guided
proposal and why the reported likelihood does not depend on it, how the guided
bridge differs from the tempered one, why the mutating filters keep last
period's cloud when the others do not, and why the Gauss-Newton step is fine
with fewer observables than shocks.

test_compressed_kron 239/239 (including the new argument-order, derivative-weight
and column-wise sets), test_inversion_filter_gradients 264/264,
test_jet_hot_paths 302/302, test_filter_free_gradients 247/247,
test_initial_state 187/187, test_particle_filter 146/146, test_missing_data
128/128, test_rrule_robustness 57/57, test_inversion_filter_likelihood 7/7.
test_higher_order_1 was not run to completion locally; CI covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Take the cubic index sets from the model's constants

The model already cached these. `third_order_indices` carries
shock_state_state_idxs/_rows and shock_shock_state_idxs/_rows, filled once by
ensure_conditional_forecast_constants!, and the inversion filter loops already
read them; the memoised compressed_cubic_shock_maps added in the previous commit
was a second, global, dict-and-lock cache of the same four vectors built a
different way. It is gone.

What is left is one construction — compressed_shock_state_state_index_map and
its shock-shock sibling — used both to fill the model's constants and by the few
pullback entry points that can be called without the model in scope, so the two
cannot drift. The joint-warmup solver and its observation/Jacobian routine now
take the sets as keyword arguments, mirroring what the rrules already did, and
all ten call sites in the filters and their pullbacks pass the model's cached
vectors down. The remaining fallback is lazy, where the old code hit the dict
even when the caller had supplied everything.

Dead code the sweep turned up, all of it left behind by the compressed
migration:
  - second_order_warmup_observation_and_jacobian built the *cubic* index sets
    and never read them. That predates the memoisation, which only made the dead
    work cheap.
  - third_order_indices.I_exo2, a sparse I(nExo^2) built for every third-order
    model and read by nothing. The compressed shock pair is nExo(nExo+1)/2.
  - `II = I(n_exo_pair)` in all six inversion filter functions, assigned and
    never used, two of them allocating a sparse identity. The compressed path
    replaced kron(II, state_vol) with compressed_triple_state_to_pair!. `II` is
    still live in rrules.jl, where the uses are real.
  - compressed_shock_shock_state_indices, which had no callers.

Also answered in a comment above DEFAULT_GUIDED_NEWTON_STEPS: how the guided
proposal's Gauss-Newton step relates to the inversion filter. It is the same
problem with the shock prior left in — the inversion filter solves r(eps) = 0,
this maximises -0.5||eps||^2 - 0.5 r(eps)' H^-1 r(eps) — and the two coincide as
the measurement error vanishes, since M = I + Bo'H^-1 Bo -> Bo'H^-1 Bo and
K -> pinv(Bo). Checked: ||K r - pinv(Bo) r|| falls as O(sigma^2), 2e-1 / 2e-3 /
2e-7 at sigma^2 = 1e-2 / 1e-4 / 1e-8, for square Bo and both rectangular shapes.
The comment also records why it does not inherit the inversion filter's hard
failure (M is positive definite by construction, so the solve succeeds at any
shape; the filter's own bail-out is cloud degeneracy, a different condition) and
why running the inversion filter per particle instead would give up the shared
factorisation that makes the proposal cheap.

New testset pins the two builders against the sorted set plus the binary-search
row map at four shapes.

test_inversion_filter_gradients 264/264 (this is the suite that drives the
third-order joint-warmup pullbacks), test_inversion_filter_likelihood 7/7,
test_compressed_kron clean including the new set. Function-name diff on
inversion.jl against the previous commit: nothing missing, nothing added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Thore Kockerols <Thore.Kockerols@ecb.europa.eu>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants