Skip to content

fix(inference): IF2 Algorithm-1 conformance — perturb before the step (gh#365), per-particle x₀ (gh#364) - #476

Merged
vsbuffalo merged 4 commits into
mainfrom
fix/gh365-gh364-if2-correctness
Aug 5, 2026
Merged

fix(inference): IF2 Algorithm-1 conformance — perturb before the step (gh#365), per-particle x₀ (gh#364)#476
vsbuffalo merged 4 commits into
mainfrom
fix/gh365-gh364-if2-correctness

Conversation

@vsbuffalo

@vsbuffalo vsbuffalo commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Two IF2 correctness fixes, one commit each, both in
rust/crates/sim/src/inference/if2.rs. Both issues were tagged SUSPECTED; both
reproduce. 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:

Θ^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
for n in 1:N
  Θ^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})
  resample (Θ, X) jointly

pomp 6.4.0.2 (kingaa/pomp@0eaf3c018f4d5857741fe29b88ef755f06b64220),
R/mif2.R lines 461–493, 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,t0=times[nt],times=times[nt+1],params=tparams,...)
  ## determine the weights
  weights <- dmeasure(object,y=...,x=X,times=times[nt+1],params=tparams,log=TRUE,...)

tparams is an npars × Np matrix and src/mif2.c's
randwalk_perturbation jitters it column by column, so both facts below are
visible in one loop: perturb precedes rprocess, and rinit receives the
per-particle perturbed matrix.

gh#365 — perturb before the process step

Real. Within each observation window IF2 ran propagate → fold → perturb →
weight, so X_n was simulated at Θ^F_{n-1} while g(y_n | X_n; θ) was scored
at the freshly perturbed Θ^P_n. Harmless (a vanishing phase offset) for a
parameter 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_step moves with it, so the cooling schedule is untouched — the SD
applied at observation k is still per_step_cooling^(k+1).

Red/green is exact, not statistical: a mock ProcessModel stamps the θ it was
stepped with into the particle state, and the observation model compares it
against the θ it is handed for weighting.

before: 240/240 particle-observations were weighted at a θ different from the
        θ that generated their state. First offender: obs 0 stepped
        θ=0.538370508862 but weighted θ=1.498434206341
after:  ok. 1 passed

docs/methods/particle-methods.md:143 already documented the correct order;
docs/inference.md:526 documented the implemented (wrong) one. Classified
doc-vs-code with the code as the loser — code fixed, docs/inference.md
updated.

gh#364 — every particle gets its own initial state

Real, and it silently disables a documented feature. initial_state was
evaluated 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: ivp parameters do not
re-enter step. Confirmed — rg -n '\.ivp\b' rust/crates/sim/src/inference/
returns a single engine site, if2.rs:522, which only skips the
observation-time perturbation. A pure-IC ivp parameter (the documented
S0/E0/I0 case, and simplex-group compositions) reaches the data through
x₀ 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 = true validates that such a parameter exists precisely to guarantee
the t=0 spread the engine was discarding, and fit/mod.rs:493 prints "initial
state spread from ivp params: [...]" to the user for spread that did not exist.

Measured on a chain-binomial SIR whose i0 sets I₀ and appears in no rate and
in no observation model:

before after
mean weighted_var_ratio for i0 0.9978 0.9365
i0 estimate (start 0.01000, truth 0.00600) 0.01370 0.00716

weighted_var_ratio ≈ 1 is the issue's claim measured directly: the weights are
blind to i0. After the fix i0 locks on within two iterations and holds for
the remaining 28.

One trap worth recording, because it made an earlier version of this test pass
against the broken code: an ivp parameter perturbed symmetrically on the logit
scale 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 i0 straight through the truth and the test went green on a filter
that had learned nothing (weighted_var_ratio was 0.9986 the whole time). The
fixture now starts i0 on 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 as
published, not a camdl divergence.)

Seam

ProcessModel::initial_state was already the producer being called; the fix
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 IVPMappings built by finite-differencing a
&CompiledModel that ProcessModel only optionally exposes, and it would
inject Monte-Carlo variance Algorithm 1 does not ask for. IF2 needs a draw, not
a density — which is also exactly what pomp's rinit is here.

What is NOT verified

The IF2-vs-mif2 MLE cross-check the issue asks for. It needs R + pomp
driving the same synthetic data, which this PR does not run. What is verified is
the step order and the rinit argument against pomp's source and against
Algorithm 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_state
draws nothing, and it moved to after a perturbation that already ran in the same
order — so results move only for models whose initial_conditions mention an
estimated parameter; Explicit initial conditions are bit-identical.

No pinned IF2 baseline exists to update: gate_inference_baseline.rs has no IF2
arm, 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, which
carries 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 test not run locally — CI
is the gate):

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   (incl. θ̂ thread-count invariance)
gate_licm_ab                           3 passed

Adjacent things noticed, not touched

  • camdl collapses the particle cloud to its mean between IF2 iterations
    (current_params = param_means, then every particle is re-seeded from it).
    Algorithm 1 sets Θ^m_j = Θ^F_{N,j} — particle j carries its own value
    forward — and pomp passes paramMatrix from iteration to iteration without
    collapsing it (mif2.R:389). camdl's behaviour is deliberate and documented
    (docs/methods/particle-methods.md), but it is a third divergence from the
    published algorithm and it is what makes the Jensen drift above accumulate.
    Worth a decision, out of scope here.
  • The bootstrap PF has no t=0 spread mechanism at all (particle_filter.rs
    copies one initial_state(params) to every particle, and θ is global there),
    so the ic_free precondition documented in SMCConfig cannot be met on that
    path the way it now can on IF2's.
  • docs/methods/particle-methods.md carries a stale cooling exponent
    (σ_n = σ_0 · c^{2n/N} — the exponent-2 form gh#363 fixed) and a stale
    if2.rs:191 line reference.

CI round 2 — two cli-crate failures, both consequences of gh#365

Both were verified against origin/main first (if2.rs reverted, everything
else 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 says
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.

Not (b). Per-chain init spread is intact. Measured on 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

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.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 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.3
threshold is unchanged.

Added sim/tests/if2_honours_per_chain_initial.rs — the exact, threshold-free
version of the same property at the level it lives: same base_params, seed and
config, differing only in EstimatedParam::initial, must give a different first
θ and a different MLE; identical .initial must reproduce bit-for-bit. That is
the 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_streams

Root cause: the R0 = 15 cell returned -inf, which serialises as
"best_loglik": null (JSON has no ±inf), so the point dropped out of the test's
collect_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_params has R0 = 20), and a 100-particle bootstrap filter on
this 5-patch spatial SEIR loses the whole swarm that far off the optimum (cell
wall time ~8 ms). Measured:

--particles R0 = 15 loglik
100 null (−inf)
500 −1876.13
2000 −1610.70

Two changes, kept separate:

  1. The silent drop is now loud (fix(profile): …). A non-finite cell loglik
    emitted 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 --particles as the lever. Verified
    it fires once at 100 particles and zero times at 500. stderr only; the
    recorded inputs JSON is byte-unchanged, so no CAS leaf re-keys.
  2. The fixture gets enough particles to answer (100 → 500, the smallest
    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

sim  if2_honours_per_chain_initial    2 passed
cli  synthetic_fit_grid               8 passed
cli  profile_multi_stream             2 passed

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.

@vsbuffalo
vsbuffalo force-pushed the fix/gh365-gh364-if2-correctness branch 2 times, most recently from 723df16 to a46d902 Compare July 25, 2026 03:26
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(&current_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
vsbuffalo force-pushed the fix/gh365-gh364-if2-correctness branch from a46d902 to 1d5b3ea Compare August 5, 2026 04:09
@vsbuffalo
vsbuffalo merged commit efd312a into main Aug 5, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant