fix(inference): IF2 Algorithm-1 conformance — perturb before the step (gh#365), per-particle x₀ (gh#364) - #476
Merged
Merged
Conversation
vsbuffalo
force-pushed
the
fix/gh365-gh364-if2-correctness
branch
2 times, most recently
from
July 25, 2026 03:26
723df16 to
a46d902
Compare
Within each observation window IF2 ran propagate → fold → perturb →
weight, so the latent state X_n was simulated at Θ^F_{n-1} while the
measurement density g(y_n | X_n; θ) was evaluated at the freshly
perturbed Θ^P_n. Ionides et al. (2015) Algorithm 1 perturbs FIRST, so a
single Θ^P_n drives both. For a parameter appearing only in the process
or only in the measurement the difference is a phase offset that vanishes
as σ → 0; for a parameter appearing in BOTH it is a genuine coupling
error — the weight scores a state that was never simulated at the θ being
scored.
Move the observation-time perturbation to the top of the observation
loop, ahead of the propagation.
VERIFICATION (both sides read, not inferred)
Ionides, Nguyen, Atchadé, Stoev & King (2015), "Inference for dynamic and
latent variable models via iterated, perturbed Bayes maps", PNAS
112(3):719-724, doi:10.1073/pnas.1410597112. Algorithm 1, inner loop:
Θ^P_{n,j} ~ h_n(θ | Θ^F_{n-1,j}, σ_m)
X^P_{n,j} ~ f_{X_n|X_{n-1}}(x_n | X^F_{n-1,j}; Θ^P_{n,j})
w_{n,j} = f_{Y_n|X_n}(y*_n | X^P_{n,j}; Θ^P_{n,j})
pomp implements the same order.
`curl -sS https://raw.githubusercontent.com/kingaa/pomp/master/R/mif2.R`
(pomp 6.4.0.2, kingaa/pomp@0eaf3c0),
`sed -n 461,493p` — `pomp:::mif2_pfilter`:
for (nt in seq_len(ntimes)) {
## perturb parameters
pmag <- cooling.fn(nt,mifiter)$alpha*rw.sd[,nt]
params <- .Call(P_randwalk_perturbation,params,pmag)
tparams <- partrans(object,params,dir="fromEst",.gnsi=gnsi)
## get initial states
if (nt == 1L) { x <- rinit(object,params=tparams) }
## advance the state variables according to the process model
X <- rprocess(object,x0=x,...,params=tparams,.gnsi=gnsi)
## determine the weights
weights <- dmeasure(object,...,x=X,params=tparams,log=TRUE,...)
One `tparams` feeds `rprocess` and `dmeasure`.
`docs/methods/particle-methods.md:143` already described the correct
order ("Perturbation step (added to propagate): before each observation
t, perturb"); `docs/inference.md:526` (at 9f6adc3) described the
implemented (wrong) one. Classified doc-vs-code with the code as the
loser: the code is fixed and `docs/inference.md` updated to match.
NOT VERIFIED: the IF2-vs-`mif2` MLE cross-check the issue asks for needs
R + pomp driving the same data, which this change does not run. What is
verified is the step ORDER against pomp's source and against Algorithm 1,
plus the structural test below. The numerical cross-check remains open.
RED → GREEN
Before (cargo test -p sim --test gh365_if2_perturb_before_step):
running 1 test
test if2_weights_the_same_theta_that_drove_the_step ... FAILED
gh#365: 240/240 particle-observations were weighted at a θ different
from the θ that generated their state — IF2 must perturb BEFORE the
process step (Ionides et al. 2015 Algorithm 1). First offender: obs 0
stepped θ=0.538370508862 but weighted θ=1.498434206341
test result: FAILED. 0 passed; 1 failed
After:
running 1 test
test if2_weights_the_same_theta_that_drove_the_step ... ok
test result: ok. 1 passed; 0 failed
BASELINES
Moving the perturbation ahead of the propagation reorders per-particle
RNG consumption (each particle's stream now yields its perturbation draws
before its process draws), so every IF2 trajectory, log-likelihood and
MLE moves. This is expected and legitimate. No pinned IF2 baseline exists
to update: `gate_inference_baseline.rs` has no IF2 arm, and the IF2 users
in the suite assert structure or tolerance-band recovery, not bit values
— all re-run green on this change:
if2 12 passed (β/γ recovery, bounds, no-cooling spread)
if2_cooling 3 passed
gh216_cursor_firing 15 passed (loglik vs analytic oracle, 1e-6)
pf_watchdog 5 passed (incl. thread-count invariance of θ̂)
Content-addressed fit artifacts re-key automatically: the fit CAS
digest folds `engine_version` (`cli/src/version.rs::VERSION_SHORT`, which
carries the git hash), so no cached pre-fix result can be served under a
post-fix run_id.
The `(1 + n_obs)` cooling accounting is unchanged — `global_step` moves
with the perturbation, so the SD applied at observation k is still
`per_step_cooling^(k+1)`.
`run_if2_with_progress` evaluated `process.initial_state(¤t_params)`
ONCE per iteration, from the iteration-mean parameters, and copied the
result into every particle. The per-particle t=0 perturbation written
into `particle_params` therefore never reached the initial compartment
counts.
For a *pure* initial-condition `ivp` parameter — one that appears in
`initial_conditions` and in no transition rate and in no observation
model, i.e. exactly the documented `S0` / `E0` / `I0` case, and the
simplex-group composition case — x₀ is the parameter's only channel to
the data. With every particle sharing one x₀ the weights are independent
of each particle's θ, resampling is a blind subsample, and the reported
filter mean drifts without ever being selected. This is silent: it
produces a number, not an error, and `ic_free = true` validates that an
`ivp` parameter exists precisely to guarantee the t=0 spread the engine
was discarding.
Move the initial-state construction after the t=0 perturbation and do it
per particle, from that particle's own θ.
VERIFICATION
`ivp` params do NOT re-enter `step` — the issue's precondition. They
reach the model only through `initial_state`, which evaluates the
`InitialConditions::Parameterized` expressions
(`compiled_model.rs:1787`, `int_counts[local] = v.round() as i64`). The
`ivp` flag itself is only ever read to *skip* the observation-time
perturbation: `rg -n '\.ivp\b' rust/crates/sim/src/inference/` returns a
single engine site, `if2.rs:522`
(`if spec.ivp || simplex_member_indices.contains(&spec.index) { continue }`);
the remaining hits in that crate are `LogLikComponents::ivp`, a PGAS
diagnostic field. The CLI's uses are reporting and validation only —
including `fit/mod.rs:493`, which prints "initial state spread from ivp
params: [...]" to the user for a spread the engine then discarded.
Ionides, Nguyen, Atchadé, Stoev & King (2015), PNAS 112(3):719-724,
doi:10.1073/pnas.1410597112, Algorithm 1, per-iteration preamble:
Θ^F_{0,j} ~ h_0(θ | Θ^{m-1}_j; σ_m) for j in 1:J
X^F_{0,j} ~ f_{X_0}(x_0; Θ^F_{0,j}) for j in 1:J
The `j` on Θ inside `f_{X_0}` is the whole content of this fix.
pomp does the same. `R/mif2.R` (pomp 6.4.0.2,
kingaa/pomp@0eaf3c0), `sed -n 461,472p`:
for (nt in seq_len(ntimes)) {
pmag <- cooling.fn(nt,mifiter)$alpha*rw.sd[,nt]
params <- .Call(P_randwalk_perturbation,params,pmag)
tparams <- partrans(object,params,dir="fromEst",.gnsi=gnsi)
if (nt == 1L) { x <- rinit(object,params=tparams) }
`tparams` is an `npars × Np` matrix and `src/mif2.c`'s
`randwalk_perturbation` jitters it column by column
(`for (k = 0, xs = xp+(*pidx); k < nreps; k++, xs += npars)`), so
`rinit` is handed one perturbed parameter column per particle.
SEAM
`ProcessModel::initial_state` was already the producer being called;
this routes each particle through it instead of the swarm mean, so no
second implementation appears. PGAS's per-particle `Binomial(N₀, θ)`
draw (`pgas.rs::csmc_as`) is deliberately not reused: it exists because
PGAS needs a tractable initial-state *density* p(x₀|θ) for the
complete-data likelihood, it is keyed off `IVPMapping`s built by
finite-differencing a `&CompiledModel` that `ProcessModel` only
optionally exposes (`try_compiled_model`), and it would inject
Monte-Carlo variance Algorithm 1 does not ask for. IF2 needs a draw, not
a density; camdl's f_{X_0} is the deterministic `initial_state`, which
is also what pomp's `rinit` is here.
RED → GREEN
`cargo test -p sim --test gh364_if2_per_particle_initial_state`
Before — the mechanism, exactly, plus the harm on a real chain-binomial
SIR whose `i0` is a pure IC parameter:
running 2 tests
test if2_initial_state_uses_each_particles_own_theta ... FAILED
gh#364: 144/144 particles carry an initial state generated from a θ
that is not their own — IF2 evaluated initial_state() once from the
swarm mean. First offender: x₀ from θ=0.050000000000, particle
θ=0.498936143204
test pure_ic_ivp_param_is_identified ... FAILED
gh#364: i0 start=0.01000 true=0.00600 estimate=0.01370;
mean weighted_var_ratio=0.9978
per-iteration i0 filter mean: 0.01054 0.01073 0.01069 0.01107 …
0.01308 0.01341 0.01370
test result: FAILED. 0 passed; 2 failed
`weighted_var_ratio` ≈ 0.998 is the issue's claim measured: the weights
are blind to i0. The mean moves anyway, but AWAY from the truth —
that motion is the data-free Jensen drift of a logit-perturbed parameter
averaged back on the natural scale, which pushes toward the midpoint of
the declared bounds (0.0253 here). The fixture starts i0 above the truth
for exactly that reason: starting below would let the drift walk the
estimate through the answer and pass a filter that had learned nothing.
After:
running 2 tests
test if2_initial_state_uses_each_particles_own_theta ... ok
gh#364: i0 start=0.01000 true=0.00600 estimate=0.00716;
mean weighted_var_ratio=0.9365
per-iteration i0 filter mean: 0.00766 0.00702 0.00716 0.00717 …
0.00705 0.00714 0.00716
test result: ok. 2 passed; 0 failed
i0 locks on within two iterations and holds for the remaining 28,
against the upward drift. The residual +19% (0.00716 vs 0.00600) is that
drift finding a new equilibrium against selection, not a recovery
failure; the assertion band is 30%. `weighted_var_ratio` is reported,
not asserted — at low ESS it can fall below 1 for reasons unrelated to
selection on θ, so recovery is the gate.
BASELINES
RNG consumption is unchanged: `initial_state` draws nothing and the move
is to after the t=0 perturbation, which already ran in the same order.
Results move only for models where an estimated parameter appears in
`initial_conditions` — for `InitialConditions::Explicit` fixtures the
initial counts are bit-identical. Re-run green on this change:
gh364_if2_per_particle_initial_state 2 passed
gh365_if2_perturb_before_step 1 passed
if2 12 passed
if2_cooling 3 passed
gh216_cursor_firing 15 passed
pf_watchdog 5 passed
gate_licm_ab 3 passed
A profile cell whose loglik came back non-finite serialised
`"best_loglik": null` (JSON has no ±inf) and said nothing about it. The
leaf is still written and the run still prints "N cells written", so
every consumer that reads `best_loglik` — the curve, the summary, the
rollup — simply has one fewer point than the sweep asked for, with no
indication that a cell failed to score. A grid point dropping out of a
profile is a result the user has to see.
Found while diagnosing a CI failure on this branch: the `R0 = 15` cell
of `profile_multi_stream`'s sweep returned -inf and vanished, and the
only symptom anywhere was a test asserting "expected 2 grid points, got
[-1443.65…]".
REPRODUCTION (before this commit; the fixture's truth is R0 = 20)
camdl simulate ir/golden/seir_spatial_5_inference.ir.json \
--backend chain_binomial --dt 1 --seed 42 \
--scenario true_params --obs-only obs.tsv
CAMDL_OUTPUT_DIR=out camdl profile \
ir/golden/seir_spatial_5_inference.ir.json \
--scenario true_params --data obs.tsv --obs cases \
--sweep 'R0=lin(15,25,2)' \
--particles 100 --iterations 1 --starts 1 --rw-sd auto \
--fixed sigma=0.125 --fixed gamma=0.2 --fixed kappa=0.05 \
--fixed amplitude=0.3 --fixed iota=1e-06 --fixed rho=0.4 \
--fixed sigma_se=0.05 --fixed k=10.0 --output prof.tsv --seed 1
Reading `inputs.best_loglik` out of each `profile_point` leaf:
point=['R0=25.0000'] best_loglik=-1443.6524135604348
point=['R0=15.0000'] best_loglik=None <- silently gone
stderr carried nothing about it; the run's own summary line said
"profile: 2 cells written".
The cell is not ruled out — it is under-sampled. The same command at
higher particle counts scores it fine:
100 -> null (-inf; the swarm dies, cell wall time ~8 ms)
500 -> -1876.13
2000 -> -1610.70
so the message names `--particles` as the lever rather than leaving the
user to guess.
After:
warning: profile grid point 0 (start 0) produced a non-finite
log-likelihood (-inf); its `best_loglik` is recorded as null and the
point will be MISSING from the profile curve. This is usually a
particle filter too small to score a θ this far from the optimum —
re-run with more `--particles`.
Verified it fires only when it should: at `--particles 100` the message
appears once (grid point 0); at `--particles 500` it appears zero times.
stderr only. The recorded `inputs` JSON is byte-unchanged, so no CAS
leaf re-keys and no cached profile point is invalidated.
Moving IF2's perturbation ahead of the process step removes a
per-observation post-selection noise injection (the old order resampled
on θ^F_{n-1} and then added a fresh perturbation before recording), so
one filter pass now moves the swarm a different distance. Two cli tests
read quantities that had been calibrated against the old distance. Both
were verified against `origin/main` first — both pass there and fail
here, so these are consequences of this branch, not pre-existing flakes.
Neither assertion is weakened. No threshold is relaxed.
1. synthetic_fit_grid::v2_if2_chains_diverge_at_iter_0_when_no_starts_from
Class (a): the recorded quantity's relationship to the chain start
changed. `write_chain_starts` says so in its own docstring
(runner.rs:2583) — `parameter_traces.tsv` iteration 0 is
"post-first-filter (already perturbed)" and cannot answer "did the
chains get distinct starts?"; `chain_starts.tsv` is the instrument for
that, and assertion 1 against it still passes (starts span
0.0214…1.7323 of the 4.99-wide bounds).
NOT class (b): per-chain init spread is intact. Measured on this
fixture with the branch build:
chain start_beta iter0_beta
1 0.0366 1.9054
2 0.9311 0.9385
3 0.0214 2.2312 <- min start
4 0.8730 0.7535
5 0.0509 2.0929
6 0.0553 1.5353
7 0.0604 1.3580
8 1.7323 1.6384 <- max start
The chains that started near the shared fallback (2, 4, 8 — starts
0.87…1.73) stay in 0.75…1.64; the chains that started near the lower
bound (1, 3, 5, 6, 7 — starts 0.02…0.06) are carried to 1.36…2.23. A
run where every chain started from the shared `start = 1.0` could not
produce the second group. Swarm-wide iter-0 spread is 1.477.
The defect was in the test: it hard-coded "chain 1 vs chain 8",
assuming chain_starts came out ordered. It does not — the LHS draws
land in arbitrary chain order, so the pair can be any two of the
eight, and here chains 1 and 8 happen to land 0.267 apart. Select the
pair by ACTUAL extremity of start instead (chains 3 and 8 on this
run): |2.2312 - 1.6384| = 0.593. The `> 0.3` threshold is unchanged.
Also added `sim/tests/if2_honours_per_chain_initial.rs`: the exact,
threshold-free version of the same property, at the level it actually
lives. Same `base_params`, same seed, same config, differing ONLY in
`EstimatedParam::initial` must produce a different first θ and a
different MLE; identical `.initial` must reproduce bit-for-bit (the
negative control). That is the real guard for the 2026-04-18 incident
and it cannot be retuned by a change to filter dynamics; the cli test
now guards the runner→engine wiring end to end.
2. profile_multi_stream::profile_family_root_sums_all_expanded_streams
The fixture was under-powered, not the expectation wrong. The sweep
`R0=lin(15,25,2)` straddles the truth (R0 = 20), so the R0 = 15 cell
is scored well off the optimum, where a 100-particle bootstrap filter
on this 5-patch spatial SEIR loses the whole swarm and returns -inf.
That serialises as `"best_loglik": null`, the point drops out of the
test's `collect_logliks`, and the failure reads "expected 2 grid
points, got [-1443.65…]" — nothing to do with what the test checks
(that `--obs cases` sums all five expanded streams).
The cell is not genuinely ruled out. Measured:
100 -> null (-inf; cell wall time ~8 ms)
500 -> -1876.13
2000 -> -1610.70
Raise the filter to 500 particles — the smallest of those that scores
every cell — so the assertions test what they say they test. Every
assertion is untouched: still `assert_eq!(len, 2)`, still finite and
negative at both points, still the >=3x family-vs-single magnitude
check. The sibling `profile_multi_stream_model_requires_explicit_obs`
stays at 100 particles; it asserts an argument-validation error and
never runs a filter.
The silent-drop itself is fixed separately in the preceding commit.
VERIFICATION
cargo test -p sim --test if2_honours_per_chain_initial 2 passed
cargo test -p cli --test synthetic_fit_grid 8 passed
cargo test -p cli --test profile_multi_stream 2 passed
Plus the IF2-touching cli binaries swept green (`rg -l 'if2|IF2'
rust/crates/cli/tests/`): profile_diagnostics, profile_grid_identity,
profile_priors, profile_greedy_order_invariant, profile_indexed_long_obs,
profile_pmmh, prior_posterior_invariants, calendar_fit_summary,
gh226_degenerate_fit, and the rest of the list.
vsbuffalo
force-pushed
the
fix/gh365-gh364-if2-correctness
branch
from
August 5, 2026 04:09
a46d902 to
1d5b3ea
Compare
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.
Two IF2 correctness fixes, one commit each, both in
rust/crates/sim/src/inference/if2.rs. Both issues were tagged SUSPECTED; bothreproduce. Together they bring the per-iteration loop into conformance with
Algorithm 1 of Ionides et al. (2015).
Closes #365
Closes #364
The reference, read rather than recalled
Ionides, E. L., Nguyen, D., Atchadé, Y., Stoev, S. & King, A. A. (2015).
Inference for dynamic and latent variable models via iterated, perturbed Bayes
maps. PNAS 112(3), 719–724.
doi:10.1073/pnas.1410597112.
Algorithm 1:
pomp 6.4.0.2 (
kingaa/pomp@0eaf3c018f4d5857741fe29b88ef755f06b64220),R/mif2.Rlines 461–493,pomp:::mif2_pfilter:tparamsis annpars × Npmatrix andsrc/mif2.c'srandwalk_perturbationjitters it column by column, so both facts below arevisible in one loop: perturb precedes
rprocess, andrinitreceives theper-particle perturbed matrix.
gh#365 — perturb before the process step
Real. Within each observation window IF2 ran propagate → fold → perturb →
weight, so
X_nwas simulated atΘ^F_{n-1}whileg(y_n | X_n; θ)was scoredat the freshly perturbed
Θ^P_n. Harmless (a vanishing phase offset) for aparameter living only in the process or only in the measurement; a genuine
coupling error for one living in both.
The fix moves the observation-time perturbation to the top of the loop.
global_stepmoves with it, so the cooling schedule is untouched — the SDapplied at observation
kis stillper_step_cooling^(k+1).Red/green is exact, not statistical: a mock
ProcessModelstamps the θ it wasstepped with into the particle state, and the observation model compares it
against the θ it is handed for weighting.
docs/methods/particle-methods.md:143already documented the correct order;docs/inference.md:526documented the implemented (wrong) one. Classifieddoc-vs-code with the code as the loser — code fixed,
docs/inference.mdupdated.
gh#364 — every particle gets its own initial state
Real, and it silently disables a documented feature.
initial_statewasevaluated once per iteration from the iteration-mean parameters and copied to
every particle, so the per-particle t=0 perturbation never reached the initial
compartment counts.
The issue asked to verify the precondition first:
ivpparameters do notre-enter
step. Confirmed —rg -n '\.ivp\b' rust/crates/sim/src/inference/returns a single engine site,
if2.rs:522, which only skips theobservation-time perturbation. A pure-IC
ivpparameter (the documentedS0/E0/I0case, and simplex-group compositions) reaches the data throughx₀ and nothing else. With one shared x₀ the weights are independent of it,
resampling is a blind subsample, and its filter mean is never selected.
ic_free = truevalidates that such a parameter exists precisely to guaranteethe t=0 spread the engine was discarding, and
fit/mod.rs:493prints "initialstate spread from ivp params: [...]" to the user for spread that did not exist.
Measured on a chain-binomial SIR whose
i0setsI₀and appears in no rate andin no observation model:
weighted_var_ratiofori0i0estimate (start 0.01000, truth 0.00600)weighted_var_ratio ≈ 1is the issue's claim measured directly: the weights areblind to
i0. After the fixi0locks on within two iterations and holds forthe remaining 28.
One trap worth recording, because it made an earlier version of this test pass
against the broken code: an
ivpparameter perturbed symmetrically on the logitscale but averaged back on the natural scale picks up a data-free Jensen
drift toward the midpoint of its declared bounds. In the first draft that
drift walked
i0straight through the truth and the test went green on a filterthat had learned nothing (
weighted_var_ratiowas 0.9986 the whole time). Thefixture now starts
i0on the far side of the truth from the bounds midpoint,so the drift pushes away and only selection can close the gap. (pomp has the
same natural-scale averaging —
mif2.R:323,start <- apply(.paramMatrix,1L,mean)— so this is inherent to IF2 aspublished, not a camdl divergence.)
Seam
ProcessModel::initial_statewas already the producer being called; the fixroutes each particle through it instead of the swarm mean, so no second
implementation appears. PGAS's per-particle
Binomial(N₀, θ)draw(
pgas.rs::csmc_as) is deliberately not reused: it exists because PGASneeds a tractable initial-state density
p(x₀|θ)for the complete-datalikelihood, it is keyed off
IVPMappings built by finite-differencing a&CompiledModelthatProcessModelonly optionally exposes, and it wouldinject Monte-Carlo variance Algorithm 1 does not ask for. IF2 needs a draw, not
a density — which is also exactly what pomp's
rinitis here.What is NOT verified
The IF2-vs-
mif2MLE cross-check the issue asks for. It needs R + pompdriving the same synthetic data, which this PR does not run. What is verified is
the step order and the
rinitargument against pomp's source and againstAlgorithm 1, plus the two exact structural tests. The numerical cross-check
remains open — worth its own issue if you want it.
Baselines
gh#365 reorders per-particle RNG consumption (each stream now yields its
perturbation draws before its process draws), so every IF2 log-likelihood and
MLE moves. Expected and legitimate. gh#364 is RNG-neutral —
initial_statedraws nothing, and it moved to after a perturbation that already ran in the same
order — so results move only for models whose
initial_conditionsmention anestimated parameter;
Explicitinitial conditions are bit-identical.No pinned IF2 baseline exists to update:
gate_inference_baseline.rshas no IF2arm, and every IF2 test in the suite asserts structure or a tolerance band, not
bit values. Content-addressed fit artifacts re-key automatically — the fit CAS
digest folds
engine_version(cli/src/version.rs::VERSION_SHORT, whichcarries the git hash), so no cached pre-fix result can be served under a
post-fix
run_id.Tests
Targeted suites, all green on the final tree (
make testnot run locally — CIis the gate):
Adjacent things noticed, not touched
(
current_params = param_means, then every particle is re-seeded from it).Algorithm 1 sets
Θ^m_j = Θ^F_{N,j}— particlejcarries its own valueforward — and pomp passes
paramMatrixfrom iteration to iteration withoutcollapsing it (
mif2.R:389). camdl's behaviour is deliberate and documented(
docs/methods/particle-methods.md), but it is a third divergence from thepublished algorithm and it is what makes the Jensen drift above accumulate.
Worth a decision, out of scope here.
particle_filter.rscopies one
initial_state(params)to every particle, and θ is global there),so the
ic_freeprecondition documented inSMCConfigcannot be met on thatpath the way it now can on IF2's.
docs/methods/particle-methods.mdcarries a stale cooling exponent(
σ_n = σ_0 · c^{2n/N}— the exponent-2 form gh#363 fixed) and a staleif2.rs:191line reference.CI round 2 — two cli-crate failures, both consequences of gh#365
Both were verified against
origin/mainfirst (if2.rsreverted, everythingelse identical): both pass on main and fail here, so neither is a
pre-existing flake. Neither assertion was weakened and no threshold was
relaxed.
synthetic_fit_grid::v2_if2_chains_diverge_at_iter_0_when_no_starts_from— class (a)The recorded quantity's relationship to the chain start changed.
write_chain_starts' own docstring (runner.rs:2583) already saysparameter_traces.tsviteration 0 is "post-first-filter (already perturbed)"and cannot answer "did the chains get distinct starts?" —
chain_starts.tsvisthe instrument for that, and assertion 1 against it still passes.
Not (b). Per-chain init spread is intact. Measured on the branch build:
Chains starting near the shared fallback (2, 4, 8 — starts 0.87…1.73) stay in
0.75…1.64; chains starting near the lower bound (1, 3, 5, 6, 7 — starts
0.02…0.06) are carried to 1.36…2.23. A run where every chain started from the
shared
start = 1.0could not produce the second group. Swarm-wide iter-0spread is 1.477.
The defect was in the test: it hard-coded "chain 1 vs chain 8", assuming
chain_startscame out ordered. It does not — the LHS draws land in arbitrarychain order, so the pair can be any two of the eight, and chains 1 and 8
happened to land 0.267 apart. The pair is now selected by actual extremity of
start (chains 3 and 8 here): |2.2312 − 1.6384| = 0.593. The
> 0.3threshold is unchanged.
Added
sim/tests/if2_honours_per_chain_initial.rs— the exact, threshold-freeversion of the same property at the level it lives: same
base_params, seed andconfig, differing only in
EstimatedParam::initial, must give a different firstθ and a different MLE; identical
.initialmust reproduce bit-for-bit. That isthe real guard for the 2026-04-18 incident and no change to filter dynamics can
retune it.
On the run's
2/2 parameters have R̂ > 1.1 (max 5.07): that is a 2-iteration,50-particle smoke fixture whose 8 chains start anywhere in [0.01, 5.0]; R̂ is
large on main too. It is not evidence of (b) — the chains have not converged
because they were never asked to.
profile_multi_stream::profile_family_root_sums_all_expanded_streamsRoot cause: the
R0 = 15cell returned-inf, which serialises as"best_loglik": null(JSON has no ±inf), so the point dropped out of the test'scollect_logliks— "expected 2 grid points, got [-1443.65…]".The cell is not genuinely ruled out; it is under-sampled. The sweep straddles
the truth (
true_paramshas R0 = 20), and a 100-particle bootstrap filter onthis 5-patch spatial SEIR loses the whole swarm that far off the optimum (cell
wall time ~8 ms). Measured:
--particlesnull(−inf)Two changes, kept separate:
fix(profile): …). A non-finite cell loglikemitted nothing — the leaf was still written and the run still printed "2
cells written", so the point just vanished from the curve. It now warns on
stderr, names the grid point, and names
--particlesas the lever. Verifiedit fires once at 100 particles and zero times at 500. stderr only; the
recorded
inputsJSON is byte-unchanged, so no CAS leaf re-keys.measured value that scores every cell). Every assertion is untouched: still
assert_eq!(len, 2), still finite-and-negative at both points, still the ≥3×family-vs-single magnitude check. The sibling test that asserts an
argument-validation error stays at 100 — it never runs a filter.
Verification
Plus every IF2-touching cli binary (
rg -l 'if2|IF2' rust/crates/cli/tests/,27 binaries) swept green — profile_diagnostics, profile_grid_identity,
profile_priors, profile_greedy_order_invariant, profile_indexed_long_obs,
profile_pmmh, prior_posterior_invariants, calendar_fit_summary,
gh226_degenerate_fit, fit_predict_e2e, acceptance_fit_cas, cas_integration (33),
fit_sparse_holes, fit_survey_denominator, fit_experiment_management,
backend_provenance, compare_auto_prequential, contrasts_e2e,
fit_table_quantities, pfilter_indexed_long_obs, polio_afp_es_multicadence,
ode_dt_check, state_grad_gating, survey_top_k_pgas, survey_top_k_pmmh.