Skip to content

fix(ad): close the AD backend/scenario coverage gap - #232

Merged
seabbs merged 51 commits into
mainfrom
fix/202-ad-coverage-v2
Aug 14, 2026
Merged

fix(ad): close the AD backend/scenario coverage gap#232
seabbs merged 51 commits into
mainfrom
fix/202-ad-coverage-v2

Conversation

@seabbs-bot

@seabbs-bot seabbs-bot commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

ad.yaml and codecov.yml declared AD backends that test/ADFixtures and
test/ad/scenarios.jl never actually exercised, so Enzyme forward and
Mooncake forward were green and empty. This PR closes that gap, and fixes
the failures it exposed. Closes #202.

Changes

  • Exercise all seven backends: ForwardDiff, ReverseDiff (tape), ReverseDiff
    (compiled), Enzyme forward, Enzyme reverse, Mooncake reverse, Mooncake
    forward. ReverseDiff (compiled) is new to ad.yaml.
  • Extend the scenario set to 43, adding Hierarchy, the standalone
    RecordExpectedLatent/RecordExpectedObs and
    PrefixLatentModel/PrefixObservationModel wrappers, and the patch models.
  • Seed the fixture data. The scenarios previously drew from the unseeded
    global RNG, so each run measured a different problem and the per-backend
    broken lists recorded a lottery rather than a result.
  • Fix the underlying cause of the failures: the unnormalised Gravity kernel
    overflowed Float64 into BigFloat on raw population counts. The fixture
    populations are rescaled, and gravity/Gravity gain an opt-in
    normalise kwarg for a row-stochastic operator.
  • Narrow simulated observations to a concrete eltype before conditioning
    (concrete_observations), so DynamicPPL stops deepcopying the data
    argument on every evaluation.
  • Add an Enzyme extension: deepcopy of genuinely-missing data is marked
    inactive, and forward-mode dot gets the rule BLAS does not provide.
  • Type-stabilise the scan, diff, delay and Split paths that Enzyme's type
    analysis rejected.

Status

  • 43/43 scenarios pass on all seven backends. backend_broken_scenarios() is
    empty.
  • A partly-observed series is carried as a concrete value vector plus a
    presence mask rather than a Union{Missing, T} array, and scored by driving
    the tilde functions off that mask. DynamicPPL deepcopys any argument whose
    type admits Missing, and Enzyme's generic reverse path cannot
    differentiate that copy. The blanks are still sampled under y_t[i], which
    is what forecast reads back.
  • Reshaping the scan state to carry the scanned value, so get_state reads a
    field rather than indexing the accumulated array, let dot back into the AR
    recursion, the delay convolution and the single-series renewal_pressure.
    Enzyme forward went from 27 failures to none, and its CI job from cancelled
    at the 60-minute cap to passing in 48m46s.
  • The two stratified renewal_pressure methods stay as row-wise reductions:
    Enzyme forward has no runtime-activity gemv.
  • The Enzyme dot rule was silently adding the primal to the derivative for
    arguments that runtime activity marks inactive by aliasing the shadow to the
    primal. Now guarded, matching Enzyme's own mul! rules.
  • Gravity normalises internally: any overall scale on the operator is
    indistinguishable from scaling R_t, so leaving it unnormalised made the
    parameterisation unidentifiable as well as prone to overflow.
  • The tree is reformatted with Runic, following the packaging kit's migration.
    That commit is formatting-only and is listed in .git-blame-ignore-revs.

Closes #202. Closes #97. Closes #251Renewal+Split cascade posterior now differentiates
under both Enzyme modes.

This was opened by a bot. Please ping @seabbs for any questions.

@seabbs-bot
seabbs-bot requested a review from seabbs as a code owner August 3, 2026 12:54
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Try this Pull Request!

Option 1: Julia Package Manager

Open Julia and type:

import Pkg
Pkg.activate(temp=true)
Pkg.add(url="https://github.com/EpiAware/ComposableTuringIDModels.jl", rev="fix/202-ad-coverage-v2")
using ComposableTuringIDModels

Option 2: Local Checkout

If you have the repo locally:

git checkout fix/202-ad-coverage-v2
julia --project=. -e "using Pkg; Pkg.instantiate()"

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Benchmark comparison vs base

Minimum time per call. Buckets are PR time as a % of base, so lower is faster (🟢 faster, ⚪ within 5%, 🔴 slower). Counts of benchmarks per bucket:

Group 🟢 <50% 🟢 50–75% 🟢 75–95% ⚪ 95–105% 🔴 105–125% 🔴 125–150% 🔴 >150%
Evaluation 5 · · 4 · · ·
ForwardDiff 1 · · · · 1 ·
ReverseDiff (tape) · 1 · · · · 1
Mooncake reverse 1 · · 1 · · ·
Enzyme reverse 1 · · · · · ·
Evaluation — 9 benchmarks (by time change)
Benchmark base PR time memory
Model evaluation / DirectInfections+Poisson / rand 72.66 μs 671.0 ns 🟢 0.01× 🟢 0.18×
Model evaluation / DirectInfections+Poisson / forward 73.14 μs 1.12 μs 🟢 0.02× 🟢 0.16×
Model evaluation / Renewal+NegativeBinomial / rand 74.59 μs 2.19 μs 🟢 0.03× 🟢 0.36×
Model evaluation / Renewal+NegativeBinomial / forward 77.67 μs 4.7 μs 🟢 0.06× 🟢 0.35×
Sampling / NUTS (DirectInfections+Poisson, 50 draws) 1.05 s 89.22 ms 🟢 0.09× 🟢 0.73×
Model evaluation / AR latent / rand 631.0 ns 651.0 ns ⚪ 1.03× ⚪ 1.01×
Model evaluation / RandomWalk latent / rand 451.0 ns 460.0 ns ⚪ 1.02× ⚪ 1.02×
Model evaluation / AR latent / forward 521.0 ns 531.0 ns ⚪ 1.02× ⚪ 1.01×
Model evaluation / RandomWalk latent / forward 420.0 ns 420.0 ns ⚪ 1.0× ⚪ 1.02×
AD gradients — 7 benchmarks (by time change)
Benchmark base PR time memory
AD gradients / AR latent logjoint / ReverseDiff (tape) 50.62 μs 184.37 μs 🔴 3.64× 🟢 0.94×
AD gradients / DirectInfections+Poisson posterior / Enzyme reverse 76.32 μs 3.4 μs 🟢 0.04× 🟢 0.35×
AD gradients / DirectInfections+Poisson posterior / ForwardDiff 224.11 μs 14.28 μs 🟢 0.06× 🟢 0.71×
AD gradients / DirectInfections+Poisson posterior / Mooncake reverse 85.36 μs 9.48 μs 🟢 0.11× 🟢 0.35×
AD gradients / DirectInfections+Poisson posterior / ReverseDiff (tape) 170.05 μs 103.0 μs 🟢 0.61× ⚪ 0.98×
AD gradients / AR latent logjoint / ForwardDiff 6.62 μs 8.84 μs 🔴 1.33× ⚪ 1.01×
AD gradients / AR latent logjoint / Mooncake reverse 6.07 μs 6.17 μs ⚪ 1.02× ⚪ 1.04×

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📖 Documentation preview is ready!

View the docs for this PR at: https://EpiAware.github.io/ComposableTuringIDModels.jl/previews/PR232/

This preview will be updated automatically when you push new commits.

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.02454% with 26 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
ext/ComposableTuringIDModelsEnzymeExt.jl 58.82% 14 Missing ⚠️
...ion_models/ObservationErrorModels/BinomialError.jl 75.00% 3 Missing ⚠️
src/utils/HalfNormal.jl 50.00% 2 Missing ⚠️
src/base/base.jl 75.00% 1 Missing ⚠️
src/inference/post-inference.jl 0.00% 1 Missing ⚠️
...c/latent_models/manipulators/ConcatLatentModels.jl 92.30% 1 Missing ⚠️
src/observation_models/Split.jl 90.00% 1 Missing ⚠️
src/utils/SafeInt.jl 0.00% 1 Missing ⚠️
src/utils/SafePoisson.jl 75.00% 1 Missing ⚠️
src/utils/turing-methods.jl 0.00% 1 Missing ⚠️
Flag Coverage Δ
ad-enzyme-forward 68.41% <76.31%> (+68.41%) ⬆️
ad-enzyme-reverse 66.48% <67.08%> (+14.42%) ⬆️
ad-forwarddiff 67.04% <74.82%> (+12.65%) ⬆️
ad-mooncake-forward 67.04% <74.82%> (+67.04%) ⬆️
ad-mooncake-reverse 67.04% <74.82%> (+12.36%) ⬆️
ad-reversediff 67.20% <75.00%> (+15.98%) ⬆️
ad-reversediff-compiled 67.20% <75.00%> (?)
unit 93.21% <96.21%> (+1.50%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/base/prettyprinting.jl 100.00% <100.00%> (ø)
src/base/priors.jl 92.30% <100.00%> (+3.84%) ⬆️
src/base/shapes.jl 50.00% <100.00%> (+50.00%) ⬆️
src/compose.jl 96.77% <100.00%> (+40.52%) ⬆️
src/infection_models/CombineInfections.jl 100.00% <100.00%> (+100.00%) ⬆️
src/infection_models/DirectInfections.jl 100.00% <100.00%> (ø)
src/infection_models/ExpGrowthRate.jl 100.00% <100.00%> (+9.09%) ⬆️
src/infection_models/Renewal.jl 100.00% <100.00%> (+14.00%) ⬆️
src/infection_models/utils.jl 97.14% <100.00%> (+22.14%) ⬆️
src/inference/DirectSample.jl 100.00% <100.00%> (ø)
... and 55 more

... and 4 files with indirect coverage changes

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

@seabbs-bot

This comment has been minimized.

seabbs-bot added a commit that referenced this pull request Aug 4, 2026
Merges origin/main into fix/202-ad-coverage-v2 to resolve the
CONFLICTING state reported on PR #232. The conflict was entirely in
the backend_broken_scenarios() docstring table in
test/ADFixtures/src/ADFixtures.jl, where #53 added three GP scenarios
(HilbertSpaceGP, HilbertSpaceGP Matern, ExactGP) to the same table
this branch rewrote for the six-backend matrix.

The _models()/scenarios() code merged cleanly (both branches added
different scenarios to the same list), giving 35 total. The docstring
table and surrounding prose are left as placeholders pending a fresh
six-backend x 35-scenario re-measurement, since the three GP scenarios
have never been run under Enzyme forward or Mooncake forward.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
@seabbs-bot

This comment has been minimized.

@seabbs-bot

This comment has been minimized.

@seabbs

seabbs commented Aug 4, 2026

Copy link
Copy Markdown
Member

Why is there no mooncake support?

seabbs-bot and others added 17 commits August 5, 2026 18:19
Close the gap between the declared 6-backend AD matrix (ad.yaml,
codecov.yml) and what ADFixtures/scenarios.jl actually exercised: add
Enzyme forward and Mooncake forward to backends() and add their
matching test/ad/scenarios.jl testitems.

Extend the scenario set with three previously-untested manipulators:
Hierarchy (the #76 prior-seam partial-pooling latent), and the
standalone RecordExpectedLatent/RecordExpectedObs and
PrefixLatentModel/PrefixObservationModel wrappers (previously only
exercised inline via other composed scenarios).

Mark DirectInfections+PrefixModifiers posterior broken for Enzyme
reverse alongside its DirectInfections+Ascertainment day-of-week
sibling: both wrap a submodel with the same
prefix(as_turing_model(...), ...) + to_submodel call already
documented as raising an EnzymeNoShadowError, so the new scenario
carries the same regression risk without a matching exclusion.

Add an Enzyme forward entry to backend_broken_scenarios(), measured
directly against this package's local macOS/aarch64 Enzyme install
(not yet cross-checked against the Linux ad.yaml job): the same
accumulate_scan/dot recursion family already broken under Enzyme
reverse, plus a missing forward-mode Enzyme rule for deepcopy on the
Vector{Union{Missing, Int64}} observed-data every count-likelihood
IDModel posterior conditions on.

Refs #202

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
Replaces the placeholder table/prose left by the merge with main's
Hilbert-space GP work (#53) with a real 35-scenario x 6-backend matrix.

ForwardDiff and ReverseDiff are measured directly for all 35 scenarios,
including the three new Gaussian-process ones. The 32 pre-existing
scenarios keep their previously-measured Mooncake/Enzyme results
unchanged (the merge did not touch how they run).

The three new GP scenarios are marked unmeasured ("?") against Mooncake
(both modes) and Enzyme (both modes) rather than guessed: this
package's own local re-measurement run was killed mid-JIT by machine
contention before it produced a real result, and this branch's own
review already argued against listing an unmeasured scenario as
"broken" or "passing" without evidence (a wrong pass silently swallows
a real failure; a wrong broken-listing hides a real capability). The
underlying code already reflects this honestly: none of the three GP
scenario names were added to backend_broken_scenarios(), so the Linux
ad.yaml job — which runs all six backends on every push — is the real,
authoritative measurement for that combination.
Blocking finding 2 from review: settles whether the 15 Enzyme-forward
exclusions attributed to a missing deepcopy rule for
Vector{Union{Missing,Int64}} are a real Enzyme limitation or a
fixture artefact.

ADFixtures._models()'s `sim` helper simulated data via
`as_turing_model(m, missing, nn)().generated_y_t`, which returns
DynamicPPL's Union{Missing,T}-eltype predictive container even once
every entry is concrete. Feeding that back as conditioning data means
the model's data argument still `hasmissing`
(DynamicPPL.convert_model_argument), so the deepcopy-for-missing-
safety branch fires again on already-concrete data.

Confirmed by direct before/after test on DirectInfections+Poisson
posterior under Enzyme forward: with the Union{Missing,Int64}
container, it raises exactly the documented deepcopy MethodError;
with the same values narrowed to a concrete Vector{Int64} first, it
differentiates correctly and matches the ForwardDiff reference.

`sim` and the standalone BinomialError fixture's y_binom now narrow
to a concrete element type before conditioning, via a `_concrete`
helper that only narrows when no genuine `missing`s remain (some
scenarios, e.g. DirectInfections+Aggregate, legitimately keep real
missings in unscored time points).

DirectInfections+Poisson posterior is removed from the enzyme_forward
broken set (individually re-confirmed). The other 14 scenarios
previously attributed to the same cause share the identical
Vector{Union{Missing,Int64}} argument shape and are expected fixed by
the same change, but were not individually re-run this pass, so they
stay in the broken set pending the ad.yaml enzyme_forward job.

Also:
- Fixes the MA latent logjoint self-contradiction: the docstring
  claimed MA differentiates under Enzyme reverse while MA is (and
  stays) in the enzyme_reverse broken set for a different,
  submodel-threading reason than the AR-family accumulate_scan cases.
- Rewrites the backend_broken_scenarios() docstring to state
  provenance explicitly per section: what this PR's own
  re-measurement pass actually ran (ForwardDiff/ReverseDiff on all 35
  scenarios, one Enzyme-forward scenario before/after) vs what is
  carried forward unverified from this PR's prior local measurement
  (Mooncake both modes, Enzyme reverse, the rest of Enzyme forward).
  The three Hilbert-space GP scenarios from #53 have no Mooncake- or
  Enzyme-forward/reverse measurement at all: local re-measurement runs
  for both were started and killed incomplete under heavy machine
  contention. Marked `?` (not measured) rather than guessed.
- Tightens the "seven Enzyme-reverse scenarios measured passing but
  declared broken" note to make clear this PR did not independently
  re-verify Enzyme reverse, and that the ad.yaml enzyme_reverse job is
  what should settle unbreaking them.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
`L * z` with `L::LowerTriangular` dispatches to the triangular BLAS path
(`trmv`/`trmm`), which Enzyme forward mode has no derivative rule for. It
raises `EnzymeNoDerivativeError` and is one of the two errors reddening the
`ad / Enzyme forward` CI job.

Materialising the factor densely forces a plain `gemv`, which Enzyme does
support. The product is mathematically identical, since the entries above
the diagonal are exactly zero, and adds only an O(n^2) copy next to the
O(n^3) factorisation already paid on every call.

This is the only site in the package where a triangular factor meets a
product on a differentiated path; `HilbertSpaceGP` uses a plain dense basis
matrix and `Hierarchy` has no factorisation at all.
`ARStep`/`MAStep` took an inner product over the lag vector with
`LinearAlgebra.dot`. Those vectors are lag-order length, typically 2 to 3,
far too short for BLAS `ddot` to buy anything, and Enzyme falls back to a
slow BLAS replacement for it, warning "Using fallback BLAS replacements for
cblas_ddot64_" throughout the Enzyme CI logs.

A `mapreduce` inner product is a plain generic reduction that every backend
differentiates directly with no BLAS call. At these lengths neither BLAS
`ddot` nor `mapreduce` reorders the summation, so the result is bit
identical.

Note this changes only the order > 1 path. The default AR and MA
constructors are order 1 and never reach these lines, so contrary to the
`backend_broken_scenarios()` docstring this affects exactly one registry
scenario, `AR vector-prior latent logjoint`. `dot` is left in place, and
still imported, for the four other call sites that use it.
The process-parameter branch of `UncertainDelay` collected its per-parameter
submodel draws in a `Vector{Any}` and read them back through `_at` inside a
closure. Every element stayed boxed as `Any`, so Enzyme's activity analysis
could not resolve a shadow per element and boxed each in a `Base.RefValue`
that `_at` cannot index, raising
`MethodError: no method matching _at(::RefValue, ::Int)`.

Freezing the draws into a `Tuple` before the closure reads them fixes each
element's type to its concrete runtime type, so a draw reaches `_at` as a
concrete `Number` or `Vector`. This is a type stability improvement on a
differentiated path in its own right, independent of Enzyme.
DynamicPPL's predictive container keeps a `Union{Missing, T}` element type
even once every entry is concrete. Conditioning on one leaves the receiving
model's data argument `hasmissing`, so `DynamicPPL.convert_model_argument`
fires a defensive `deepcopy` on every model evaluation of data that has
nothing missing left. Enzyme forward has no `deepcopy` rule for such an
array, which is the second of the two errors reddening the
`ad / Enzyme forward` CI job.

Simulating from a model's prior and feeding the result back in as data is a
normal thing for a user to do, so the fix belongs in the package rather than
in the test fixture. `concrete_observations` is public but not exported, and
`as_turing_model(::IDModel, y_t, n)` now applies it automatically.

It narrows only when nothing is genuinely missing; data with real reporting
gaps is returned unchanged and identically, so a user deliberately
conditioning on partial data sees no difference. The narrowing wraps the
`@model` rather than sitting inside it, because the `hasmissing` check runs
on the stored argument before the model body is ever called.

Adds a `DirectInfections+PartiallyMissing posterior` scenario covering the
genuinely-ragged case that cannot be narrowed away.
DynamicPPL defensively `deepcopy`s a model argument whenever its type
`hasmissing`, on every model evaluation and at every level such an argument
is threaded through. For genuinely partially-missing data, a ragged or
reporting-gap series, that cannot be avoided by narrowing the element type,
because some entries really are unobserved.

Enzyme forward has no `deepcopy` rule for a `Union{Missing, T}` array. The
`deepcopy` is of observed conditioning data rather than anything derived
from the differentiated parameter vector, so it carries no tangent in either
mode, which is what `EnzymeRules.inactive` is for. This mirrors the pattern
`EpiAwareADTools` uses for its own primal tape-strip.

This is a DynamicPPL and Enzyme interaction rather than anything specific to
this package's models, so it belongs upstream in `EpiAwareADTools.jl`
alongside its other Enzyme rules. It lives here for now so this PR is self
contained.
The `DirectInfections+PartiallyMissing posterior` scenario I added in
3b8315e broke the whole fixture registry. A partially-missing vector makes
the WHOLE `y_t` latent rather than only its blank entries, so
`_logdensity`'s `link` has to find a bijector for the observation
distribution. Count families are discrete and have none, so it raised
`MethodError: no method matching scalar_to_scalar_bijector(::SafePoisson)`
before a single scenario was built, denying every scenario to every backend.

Switching the error family to `NormalError` keeps the linked log-density
well defined. The scenario still tests what it exists for: the data argument
keeps its `Union{Missing, T}` eltype, which `concrete_observations` cannot
narrow away, so DynamicPPL's `hasmissing` `deepcopy` still fires on every
evaluation and the Enzyme extension's `inactive` rule is still what makes it
differentiable under Enzyme forward.

Verified by building every scenario individually: 36 models, all OK.

Also rewrites the `backend_broken_scenarios()` docstring, which had grown to
around 200 lines of provenance narration around claims now known false. It
said the Gaussian-process scenarios had no Mooncake measurement, when both
Mooncake jobs report 735/735 across all 35 scenarios. It blamed the AR/MA
family on an `accumulate_scan`/`dot` recursion, when the order-1 default
constructors take a dot-free path and only `AR vector-prior` executes `dot`.
It described prefixed submodel threading as a blocker, when neither prefix
scenario is among the eight genuine Enzyme reverse failures. Remaining
failures are now grouped by cause, each marked established or hypothesised.
`_split_models`'s named-entries branch built a `Vector` comprehension over
the stream models. With two or more differently-typed streams Julia infers a
packed small-union eltype, e.g.
`Vector{Union{NegativeBinomialError, LatentDelay{...}}}`. Enzyme's strict
type analysis cannot resolve that memory layout and fails the whole
compilation with `IllegalTypeAnalysisException`, whose own message says
"This usually indicates the use of a Union type".

A `Tuple` holds the same heterogeneous values in the same order without the
packed-union array layout. `names` already equals `keys(m.streams)` from the
`Split(streams::NamedTuple)` constructor, so this preserves order and values
exactly. The homogeneous template branch is a single concrete type already
and is unchanged.

This fixes `Renewal+Split cascade posterior` under Enzyme reverse, which was
previously recorded as an unfixable consequence of Split's design. Verified
across all six backends: ForwardDiff, ReverseDiff, Mooncake reverse, Mooncake
forward and Enzyme reverse all pass. Enzyme forward still fails on this
scenario for a separate reason (`EnzymeNoDerivativeError`), so it stays
listed broken there.
The process-parameter branch of `UncertainDelay` read its drawn parameters
back with a runtime index into `params_t`. That tuple is heterogeneous, e.g.
`Tuple{Vector{Float64}, Float64}` for a process meanlog with a constant
sdlog, so indexing it with a runtime `i` returns a `Union` of the element
types. Enzyme's type analysis cannot resolve that and fails compilation with
`IllegalTypeAnalysisException`, the same "use of a Union type" failure
`Split._split_models` hit through a packed union-eltype array, reached here
through a runtime-indexed heterogeneous tuple instead.

`_at_all` recurses on `first`/`Base.tail`, so every element is read at its
own statically known position. No runtime index, no union.

This supersedes two earlier attempts on the same line. `Tuple(params)` alone
moved the failure to a `getindex(::Base.Generator)` MethodError, and an
`ntuple` with a runtime index reintroduced the union.

Verified across all six backends on
`Renewal+TimeVaryingLatentDelay posterior`: ForwardDiff, ReverseDiff,
Mooncake reverse, Mooncake forward and Enzyme reverse all pass. Enzyme
forward still fails on the separate, already-tracked `deepcopy` gap for
`Vector{Union{Missing, Int64}}` observed data, so it stays listed broken
there.
This reverts the `dot` to `mapreduce` change in `ARStep`/`MAStep` from
a8c43c6. It regressed ReverseDiff and fixed nothing.

`mapreduce(*, +, a, b)` lowers to `map(*, a, b)` followed by a reduce.
`dot` went through ReverseDiff's dedicated BLAS rule; `map` instead hits its
elementwise `ForwardOptimize` fast path, which throws
`BoundsError: attempt to access Tuple{Float64} at index [2]` on the order-2
`AR vector-prior latent logjoint` scenario. That reddened the
`ad / ReverseDiff (tape)` CI job, which had been green.

The change also bought nothing. It was made on the premise that `dot` caused
that scenario's Enzyme failure, but the real cause is a union type — first in
`as_turing_submodel`'s prior seam, and beneath that a `_typed_vcat!`
specialisation. `AR vector-prior` still fails under both Enzyme modes with
`mapreduce` in place, so the trade was a regression for no gain.

Reverting restores ReverseDiff: `AR vector-prior` passes in 1.5s and `MA`
passes, both confirmed locally, alongside ForwardDiff.

The forward-mode Enzyme BLAS `dot` gap is real, but the right remedy is the
`EnzymeRules.forward` rule in the Enzyme extension, which fixes the gap
without changing what any other backend sees.
The broken lists declared scenarios unsupported that demonstrably work.
Both lists are now measured rather than inferred.

Removed 9 names from `enzyme_reverse`, leaving 6 genuine failures. Removed
6 from `enzyme_forward`, leaving 14. Two independent lines of evidence
agree: a full local sweep of all 36 scenarios under both Enzyme modes, and
the CI arithmetic from the `ad / Enzyme reverse` job, which reported 15
listed entries with 6 recorded broken and therefore 9 passing. Removals
include `Renewal+Split cascade`, fixed in ad9c106, and both prefix
scenarios, which were listed on inference and never failed.

Added `DirectInfections+PartiallyMissing posterior` to `enzyme_reverse`.
That is the scenario currently reddening the job, and it is a genuine
upstream limitation rather than a package defect. It was reproduced
standalone with no DynamicPPL or package code: `deepcopy` of a
`Union{Missing, T}` array followed by a parameter-dependent write into a
missing slot raises `AssertionError: Enzyme Internal Error
(rewrite_union_returns_as_ref[2])` from inside Enzyme's own LLVM rewrite
pass. It fails identically with and without our `EnzymeRules.inactive`
rule and for both `Int64` and `Float64`, so no user-level rule can address
it. The scenario passes the other five backends and is the only coverage of
ragged reporting-gap data, so it stays.

Rewrote four comments whose explanations this work disproved. The
`accumulate_scan`/`dot` attribution was wrong: only `AR vector-prior` ever
executes `dot`, and the shared cause is a union-typed value reaching
Enzyme's type analysis, by three routes found so far. The prefix
submodel-threading attribution was wrong: neither prefix scenario fails.
The `Split` forward-mode comment blamed the observation side, but the
failure originates in the renewal step. The time-varying delay comment
cited a `RefValue` error three fixes out of date.

Also drops stale provenance narration that claimed the list was carried
forward unverified from an earlier local run.
Completes two fixes that were called out in the commit history but never
landed:

1. Enzyme forward-mode LinearAlgebra.dot rule (ext/EnzymeExt.jl)

   The renewal/delay-convolution path uses dot for generation-interval
   and reporting-delay kernel products (user-supplied lengths, routinely
   10-30+ days). Enzyme forward-mode has no BLAS dot rule, raising
   EnzymeNoDerivativeError. The previous attempt to work around this by
   rewriting ARStep/MAStep to mapreduce was reverted in f69939f because
   it regressed ReverseDiff and fixed nothing (the AR/MA dot calls are
   on lag-order vectors of length 2-3, not the epi-length vectors the
   mapreduce rewriting was aimed at). The correct fix, as stated in that
   revert commit, is a custom EnzymeRules.forward rule, which handles
   both unbatched and BatchDuplicated (DifferentiationInterface gradient
   batching) paths.

2. Remove the filldist/product_distribution runtime ternary (priors.jl)

   The third union-type route, alongside Split (ad9c106) and LatentDelay
   (0e740ef). all(first(v) .== v) ? filldist(first(v), n) : ... creates
   a runtime Union{Product, FillDist} return type that Enzyme's strict
   type analysis cannot resolve (IllegalTypeAnalysisException). Switching
   to unconditional product_distribution is type-stable and the cost of
   losing the Fill-based logpdf specialisation is negligible at the
   vector lengths used here (AR/MA lag orders, DiffLatentModel orders).
…fix Replicate.jl Vector{Any}

Two fixes missed by the initial worker pass:

1. priors.jl: the `as_turing_model(prior::AbstractVector{<:Distribution}, n::Int)`
   @model method still had a runtime
   `all(first(prior).==prior) ? filldist(...) : product_distribution(...)`
   ternary, creating a Union{Product, FillDist} return type that Enyme
   type analysis cannot resolve (IllegalTypeAnalysisException). The
   `as_turing_submodel` vector method was already fixed (375eee3), but this
   sibling method was never addressed. Now unconditionally uses
   `product_distribution`.

2. Replicate.jl: `paths = Vector{Any}(undef, n_strata)` collected per-stratum
   draws into a boxed Any vector. Enzyme cannot resolve packed-union arrays
   (IllegalTypeAnalysisException). Fix by drawing the first stratum to
   determine the concrete element type, then collecting into a
   `Vector{typeof(drawn_1)}` rather than a boxed `Vector{Any}`, mirroring
   the Split._split_models pattern (ad9c106).
After rebasing onto main (4e9c175, multi-strata infections) and applying
the filldist ternary fix to the as_turing_model vector method and the
Replicate.jl Vector{Any} fix:

Enzyme reverse:
- MA latent logjoint was fixed (the priors ternary was the exact cause)
- 4 AR-related scenarios (ARIMA, DiffLatentModel(RW), ARMA, AR vector-prior)
  still fail with IllegalTypeAnalysisException (accumulate_scan(ARStep) path)
- DirectInfections+PartiallyMissing stays broken (upstream Enzyme LLVM bug)
- Renewal+TimeVaryingLatentDelay removed (confirmed passing after _at_all fix)

Enzyme forward:
- The 4 AR-related scenarios still fail (same as reverse)
- All 9 renewal/delay scenarios still fail with EnzymeNoDerivativeError
  despite the forward-mode dot rule (Enzyme inlines BLAS cblas_ddot64_
  before the rule can intercept it)
- 13 total broken, matching the 492/13 pass/broken split in CI
@seabbs-bot
seabbs-bot force-pushed the fix/202-ad-coverage-v2 branch from 375eee3 to a133424 Compare August 5, 2026 21:12

@seabbs seabbs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Insanely long comments have been added plus heavily cut and rationalise

seabbs-bot and others added 10 commits August 10, 2026 11:23
…iagonal

`normalise = true` was presented as the remedy for raw population counts.
It is not. Row-normalising bounds the total force of infection, so the
recursion no longer overflows, but the off-diagonals still dominate by
~1e7, leaving a diagonal around 1e-7. That says a patch essentially never
infects itself, which trades a loud failure for a silent one.

Scale `pop` so `pop^a` stays comparable to `within`. The tutorial now
passes populations in hundreds of thousands, and the docstring says
normalise bounds the total rather than rescuing raw counts. Tests pin
both halves.

Drop the issue numbers and the "does not cover" aside from the AD
comments, and cut the remaining long blocks in priors.jl, the Enzyme
extension, and the selective runner header.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
`renewal_pressure` computes `R_g * sum_h K[g,h] * L_h`, so any overall
scale on `K` is indistinguishable from scaling `R_t`. Leaving the operator
unnormalised did not just risk overflow, it made the parameterisation
unidentifiable. `within = 1.0` was also being compared against a term
carrying population units, which is why raw counts exploded.

Take populations in units of their mean and normalise each row, both
unconditionally. `pop` now accepts raw counts, `within` means the
self-weight relative to a typical pairwise term, and the `normalise`
keyword, the scaling advice, and the fixture's rescaled populations all
go away. Alpha still needs a non-zero `within`, which is the one caveat
that is real.

Share the scan-window reduction as `_wsum` so the reason it is not `dot`
is stated once rather than at four call sites, and document the
row-wise reductions in `renewal_pressure`. Drop the remaining issue
references and past-behaviour framing from comments and docstrings.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
Under `set_runtime_activity`, Enzyme marks an argument that is inactive at
run time by aliasing its shadow to its primal. The forward `dot` rule read
`dval` whenever the argument was not statically `Const`, so an aliased
argument contributed `dot(x.val, y.val)` — the primal — to the derivative.
The gradient then came back silently wrong, off by the primal, with no error.

Guard both the un-batched and the batched rule with the same test Enzyme's
own `mul!` rules use: treat an argument as constant when it is `Const` or
when runtime activity is on and its shadow aliases its primal.

Measured on a delay-convolution scan with a non-const kernel: without the
guard Enzyme forward returns true_grad .+ f(x); with it the gradient matches
ForwardDiff exactly.
The rule does fire inside `accumulate_scan`; a poisoned rule returning a
constant derivative propagates through the scan as expected. What breaks
is downstream: a BLAS call makes Enzyme substitute its own implementation
and the window loses the shape `get_state` indexes it with.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
The AD matrix carries a seventh backend that the scaffold's backend list
does not, so `codecov.yml` was left with six AD flags and a gate of seven
against eight uploads. Codecov finalised on whichever seven arrived first,
making `codecov/patch` a scheduling race: it reported pass and fail on the
same commit.

Add the missing flag, raise both gates to eight, and let the two Enzyme
flags claim `ext` since those are the only jobs that load the extension,
which otherwise no job can cover.

Both files say they are scaffold-managed. Say instead that they are
generated and then edited together, and that `update()` reverts them, so
the next person is not misled by a header that no longer holds.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
A BLAS call in a scan step made Enzyme substitute its own implementation,
after which the accumulated window lost the shape `get_state` indexed it
with. The steps avoided `dot` to work around that, at the cost of an
allocation per time step in the two hottest loops.

Pair the newest value with the window in the state, as `MAStep` already
did, so `get_state` reads a field instead of indexing. `ARStep`, `LDStep`,
`TimeVaryingLDStep` and the single-series `renewal_pressure` then take
`dot` again. Enzyme forward goes from 27 failures to none.

The two stratified `renewal_pressure` methods stay as row-wise
reductions: Enzyme forward has no runtime-activity `gemv`, which is its
own limitation rather than a shape problem.

Split the window builder from the state it seeds, and make both
`renewal_init_window` and `renewal_init_state` public so a new renewal
step can implement them. Without the split a drawn mixing operator, whose
seam hands back the core itself, seeded an unwrapped window.

Also guard the Enzyme dot rule with an `isa` test rather than dispatch,
which was not catching a `Const` and let a shadow read through.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
A blank entry became a latent parameter, so a series with reporting gaps
carried one parameter per gap. Each is conditionally independent of the
rest given the expected series, so its term integrates to one: dropping it
leaves the parameter posterior unchanged and keeps it out of the sampler.
A test pins that equivalence against a model conditioned on the observed
entries alone.

Blanks stay `missing` in the returned series for a predictive pass to
fill, and a series that is entirely `missing` still samples every entry,
so prior-predictive simulation is unaffected. A fully observed series
keeps an unbranched scoring loop.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
The comment named `accumulate_into`, which was the symptom of the earlier
arrangement. Scoring only the observed entries removed the latent series,
so that call is no longer reached. What fails now is DynamicPPL's
`deepcopy` of an argument whose type admits `Missing`, differentiated by
Enzyme's generic reverse path without consulting the `inactive` rule the
package ships for it.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
Skipping the blank entries breaks `forecast`. It extends a model by
padding the series with `missing` over the horizon and reading the drawn
values back out of the chain, so sampling a blank is the mechanism, not an
accident: four forecast tests failed with `KeyError: key y_t[16] not
found`.

The parameter posterior is the same either way, but the returned draws are
not, and the horizon is the point. Restore the unconditional loop and say
so where the loop is, so the next reader does not make the same change.

This reverts commits 46b7a3b and af3f95a.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
`test/Project.toml` tracks the packaging kit at `rev = "main"`, so CI
resolved a newer kit whose formatting check uses Runic while the test
environment did not carry it. The job died on the missing package with
1544 tests passing.

That error aborted the quality item before its later checks ran, hiding
four real failures: the kit requires a public name's docstring to carry a
runnable example and to name the function, and `renewal_init_state` and
`renewal_init_window` had prose only. Both now have examples, verified to
execute.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
Comment thread test/unit/infection_models/renewal_step.jl Outdated
Comment thread test/Project.toml
seabbs-bot and others added 6 commits August 11, 2026 09:58
The packaging kit migrated its managed formatting standard from
JuliaFormatter to Runic. `test/Project.toml` tracks the kit at `rev =
main`, so CI picked the new check up while the repo was still formatted
for the old one and every Julia 1 job failed on it.

Reformat the standard trees, take the kit's migrated formatter
environment and pre-commit hook, and drop `.JuliaFormatter.toml`: Runic
has one canonical style and no per-package config.

Formatting only. No behaviour change.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
The guard asserts the formatter pin in the shared test environment matches
the managed one, so a kit bump cannot leave the shared environment on a
different version while the check asserts a style nothing applies. It
still looked for JuliaFormatter and found neither pin. Point it at Runic
and pin `=1.7.0` on both sides.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
DynamicPPL deep-copies any model argument whose static type admits
Missing before the model body runs; Enzyme reverse cannot differentiate
that deepcopy. concrete_observations now splits a genuinely
partly-missing vector into a MissingObservations carrier (a concrete
value vector plus a presence mask), so the stored model argument never
admits Missing.

Reconstructing a Union{Missing,T} array from that carrier inside the
model body avoided the deepcopy but traded it for a different failure:
Enzyme's type analysis cannot compile a Union-typed array built on the
AD-active path, in either direction. Scoring the carrier by driving
DynamicPPL's tilde_assume!!/tilde_observe!! directly off its present
mask avoids constructing a Missing-admitting value at all, closing the
last entry in the AD matrix. Every entry is still scored (observed or
sampled), which is what forecast's y_t[T+h] chain read-back depends on.

Enzyme reverse and Enzyme forward now match the ForwardDiff reference
gradient on DirectInfections+PartiallyMissing; backend_broken_scenarios
is empty and the full 43-scenario x 7-backend matrix passes.
The docs build passed at 2h18m and has since crept past the 150-minute
cap, landing at 2h35m. Ten tutorial pages run real inference, so the cost
is the content rather than a regression.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
seabbs-bot and others added 4 commits August 12, 2026 17:52
Scoring went through a closure defined in the model body. Julia boxes a
closure's captures there, so every scored entry paid a dynamic dispatch,
on the common fully-observed path as well as the partly-observed one it
was written for. A renewal posterior's log-density went from 2.7 to 9.9
microseconds against main, with allocations up by a third.

Hold the pieces in a concrete callable instead. The same log-density is
back to 2.8 microseconds and 6.7 kB.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
Five scenarios reach code the registry never differentiated: an ODE
infection process, two infection processes combined and aggregated
through a weight map, susceptible depletion on its own, a per-pair
generation interval, and the `arima()` helper.

Measuring them found three things. Reverse-mode differentiation through
an ODE solve needs SciMLSensitivity, which is not a dependency, so the
ODE family is ForwardDiff-only. The per-pair `renewal_pressure` still
reached for a matrix product, which Enzyme forward has no
runtime-activity `gemv` for; it now reduces row-wise like its
neighbours. Enzyme forward returns a finite but wrong gradient for the
weighted split, every entry offset by the same constant, where every
other backend matches the reference.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
Forward-mode gradients cost one directional derivative per parameter, so
the Enzyme forward job scales with the summed scenario dimension. Adding
five scenarios took it past the sixty-minute cap its workflow allows.

Eight time points exercise the same code paths as twelve for every
scenario here, and the stratified panel needs no more. The job drops from
over an hour to a quarter of one, with the per-backend results unchanged.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
Live-benchmarking every (backend, scenario) pair in-process, serially,
now sums to roughly the wall-clock of all seven per-backend ad.yaml
jobs combined (7 backends x up to 43 scenarios). Documenter has failed
on every commit since 277146a, never completing the step.

Interim fix until the artefact-based redesign lands upstream in
EpiAwarePackageTools.jl: read pre-computed per-backend CI benchmark
output instead of running it live during the docs build.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
@seabbs-bot

Copy link
Copy Markdown
Collaborator Author

Documenter fix: stubbed the AD comparison page (interim)

Documenter / Documentation has failed on every commit since 277146a
(5 runs, 2026-08-10 through today), never completing. Traced it: the
ad-comparison.jl benchmark page live-benchmarks every (backend,
scenario) pair in-process, serially, during the docs build. Before
this PR that was ~4 backends x 32 scenarios; this PR's whole point is
growing it to 7 backends x up to 43 scenarios. CI logs confirm the
step logs "executing ad-comparison.jl in a fresh subprocess" then goes
silent for 85+ minutes before the runner gets reclaimed -- consistent
with summing the seven per-backend ad.yaml job times (7m..52m, ~3h
total), since each backend still pays its own prepare/compile cost in
that one process. Raising the docs job timeout to 240m (184d376)
didn't help; whatever reclaims the runner does so before that.

Stubbed ad-comparison.jl via FORCE_STUB_TUTORIALS in
docs/docs_config.jl (a7ce59f) -- the mechanism the managed page's
own comment names for exactly this situation, and the BENCHMARK_STUBS
entry for it was already sitting unused, ready to render.

This is interim. The real fix -- reading pre-computed per-backend
benchmark artefacts from CI instead of running them live during the
docs build -- is a template-level change spanning EpiAware/.github
(a benchmark artefact-producing workflow) and EpiAwarePackageTools.jl
(the page itself), so it doesn't belong in this PR. Filing that
upstream now and will link the issue here.

This was opened by a bot. Please ping @seabbs for any questions.

@seabbs-bot

Copy link
Copy Markdown
Collaborator Author

Design issue filed: EpiAware/EpiAwarePackageTools.jl#443.

…d sync

b8f7966 bundled a docs/Project.toml prune alongside its actual AD
scenario work and dropped AlgebraOfGraphics, which
docs/src/tutorials/realtime-nowcast.md still `using`s for its three
heatmap/line/comparison plots. Latent until now: the docs build always
died earlier, at the ad-comparison.jl benchmark step, before ever
reaching this page.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
@seabbs-bot

Copy link
Copy Markdown
Collaborator Author

Documenter fix, take 2: a real (unrelated) regression, not our modelling code

Stubbing `ad-comparison.jl` let the build progress far enough to hit a
second failure: `docs/src/tutorials/realtime-nowcast.md` failed three
`@example` blocks with `ArgumentError: Package AlgebraOfGraphics not
found in current path`. Traced it -- not a modelling regression.
`b8f7966` bundled a `docs/Project.toml` prune alongside its actual AD
scenario work and dropped `AlgebraOfGraphics` (deps + compat), which
that tutorial's `using` still needs. It's been broken on this branch
since `b8f7966`, just never reached: the build always died earlier at
the `ad-comparison.jl` benchmark step. The other two failures on that
page (`UndefVarError: data`, `UndefVarError: Figure`) are the same
root cause cascading -- `using AlgebraOfGraphics, CairoMakie,
DataFrames` aborts the whole statement when the first package is
missing, so none of the three get bound.

Restored both entries (99bb4ff), verified `AlgebraOfGraphics v0.12.13`
resolves and the exact failing `using` line loads clean. Cross-checked
every other `using` across `docs/src/**` against `docs/Project.toml`
deps -- nothing else is missing.

This was opened by a bot. Please ping @seabbs for any questions.

@seabbs-bot

Copy link
Copy Markdown
Collaborator Author

Status update

Recap of where this PR stands:

  • AD matrix: all 7 backends green as of `a7ce59f`/`8f019ed` (43/43
    scenarios, confirmed by CI, not just local measurement).
  • Documenter: two real, unrelated regressions found and fixed:
    1. `ad-comparison.jl` outgrew what a docs-build subprocess can do
      (7 backends x 43 scenarios, ~3h serially) -- stubbed via
      `FORCE_STUB_TUTORIALS` (`a7ce59f`), interim pending a proper
      redesign (see below).
    2. `docs/src/tutorials/realtime-nowcast.md` broke on
      `AlgebraOfGraphics`, accidentally dropped from `docs/Project.toml`
      by an unrelated scaffold-sync bundled into `b8f7966` -- restored
      and verified (`99bb4ff`).
      A fresh Documenter run is in progress on `99bb4ff`; will confirm once
      it lands.
  • Review: seabbs's `CHANGES_REQUESTED` from 2026-08-05 has since
    been addressed point-by-point in the PR comments above, but the
    review itself is still outstanding -- worth a re-review once CI is
    green.
  • `codecov/patch`: green as of the last full CI pass.

Follow-up work, tracked upstream, not blocking this PR

The `ad-comparison.jl` stub is interim. The real fix -- reading
pre-computed per-backend benchmark artefacts from CI instead of
live-benchmarking during the docs build -- is filed as
EpiAware/EpiAwarePackageTools.jl#443, with implementation PRs in
progress against both EpiAware/.github and EpiAware/EpiAwarePackageTools.jl
(both draft, not yet ready for review). Once those land, this repo gets
a follow-up PR to adopt them and drop the interim stub -- tracked
separately, not part of #232.

This was opened by a bot. Please ping @seabbs for any questions.

@seabbs
seabbs merged commit 9eda011 into main Aug 14, 2026
30 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

2 participants