Skip to content

Clean up workflow ergonomics, defer flat_prior-only inits, drop sero for now#52

Merged
smwindecker merged 14 commits into
simplifyfrom
clean-workflow
Jul 6, 2026
Merged

Clean up workflow ergonomics, defer flat_prior-only inits, drop sero for now#52
smwindecker merged 14 commits into
simplifyfrom
clean-workflow

Conversation

@smwindecker

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #51, addressing review feedback on that branch before it merges further, plus general polish for this week's MVP.

Ergonomics — no more manual jurisdiction naming/listing. stack_jurisdictions() is now the explicit, user-facing combining step, taking named ... args (mirroring define_observation_model()'s own cases=/hospitalisations= pattern) instead of a pre-built setNames(list(...), jurisdictions). A single jurisdiction needs no combining step at all — define_observation_model()'s output goes straight to fit_waves(), which detects whether it received an already-stacked object or a raw single-jurisdiction one (via class) and wraps the latter internally.

Deferred, flat_prior-only inits. GAM-based initial values (inits_by_jurisdiction()) were running unconditionally for every stream × jurisdiction during data prep, even though GP-based infection models never reference them at all (confirmed in create_infection_timeseries()observable_infection is only used inside the flat_prior branch). A new compute_flat_prior_inits() computes this lazily, only inside fit_waves()'s flat_prior branch, on the fully stacked object.

A real bug found and fixed along the way: compute_flat_prior_inits() initially read the DOW-corrected prop_mat from the stacked object, but the original (pre-refactor) code deliberately computed inits from the proportion before DOW correction — using the corrected version made the "deterministic smoothed guess" depend on a prior-predictive draw of the DOW effect instead. Fixed by having stack_stream() retain prop_mat_raw (pre-correction) specifically for this.

Seroprevalence pathway removed from this MVP (not merged, not abandoned): define_sero_data(), create_small_sero_model(), and the discrete_weights widening in new_convolution_matrix()/evaluate() are pulled back out — the pathway was only ever validated against fabricated data, and we didn't want if-based dispatch for a not-yet-validated feature sitting in the MVP's core functions. Design notes for reintroducing it cleanly (merge into define_observation_data()/create_observation_model() rather than parallel functions) are in dev/sero-integration-notes.md (excluded from the package build).

API surface cleanup. prepare_observation_data() and inits_by_jurisdiction() are now internal (@noRd) — both were only ever called from one place, never from a workflow script. create_epiwave_timeseries()/create_epiwave_fixed_timeseries() are deleted entirely: the former had exactly one caller whose job as_matrix()'s existing numeric dispatch already did on its own (verified identical output), the latter had zero callers anywhere. create_epiwave_greta_timeseries() is renamed to as_greta_timeseries() (class renamed epiwave_greta_timeseriesgreta_timeseries to match) to pair naturally with the new as_epiwave_timeseries() auto-coercion helper, which replaces the need for users to hand-write class(x) <- c(...) on their raw observation data.

New self-contained workflow scripts and README usage examples. tests/test_workflow/{single,multi}_jurisdiction_workflow.R are fully self-contained (fabricated data, no external files), unlike testing.R/test2.R which depend on local not-synced data. The multi-jurisdiction one deliberately uses staggered/non-identical date coverage between jurisdictions to exercise the master-date-axis alignment. README.Rmd/README.md gained a Usage section with trimmed versions of both.

Test plan

  • devtools::check() — 0 errors, 0 warnings throughout (same 3 pre-existing NOTEs unrelated to this branch)
  • Both synthetic workflow scripts re-run end to end after every change in this branch, including the dow_model = TRUE + flat_prior combination that exposed the DOW/inits bug
  • testing.R's real-data fit (../data) re-verified against both flat_prior and gp_growth_rate after every change
  • Standalone checks for: stack_jurisdictions()'s named-args API, the unnamed-args error path, fit_waves() correctly routing both a raw single-jurisdiction object and an already-stacked multi-jurisdiction object, and confirming gp_growth_rate never triggers the GAM-based inits computation at all
  • class(ihr) confirmed greta_timeseries epiwave_timeseries list post-rename, with as_matrix.greta_timeseries() dispatch working
  • README usage examples run to completion exactly as written before being added

🤖 Generated with Claude Code

…series

The date-alignment check in prepare_observation_data() assumed any already-
classed epiwave_timeseries object stores its dates in a flat $date column,
but epiwave_greta_timeseries objects (e.g. the IHR-from-CHR pattern used for
hospitalisation proportions in tests/test_workflow/testing.R and test2.R,
built via create_epiwave_greta_timeseries()) are a list wrapping a greta
array alongside the date tibble, with dates nested at $timeseries$date
instead. The check was comparing as.Date(NULL) against target_infection_dates
and always failing with "`proportion_infections` dates must match
`target_infection_dates`".

Found by actually running tests/test_workflow/testing.R's basic
(cases + hospitalisations) fit against real data in ../data -- this is a
core supported pattern (proportion driven by a greta array), not an edge
case, and would have broken on the first real multi-stream fit using it.

Verified: the same fit now runs to completion against real data.
Adds as_epiwave_timeseries(data): a new exported helper that takes a plain
data.frame/tibble with date/value columns (partial date coverage is fine)
and classes it as epiwave_fixed_timeseries -- replacing the fragile,
unvalidated class(x) <- c('epiwave_fixed_timeseries', 'epiwave_timeseries',
class(x)) pattern users previously had to hand-write for every observation
stream. Already-classed epiwave_timeseries objects and plain numeric
values/vectors pass through unchanged, so it's safe to call unconditionally.

prepare_observation_data() now calls this automatically on timeseries_data
and size_vec (mirroring the auto-coercion that delay_from_infection/
proportion_infections already had), so users can pass a plain data.frame
straight into define_observation_data()/define_sero_data() without ever
manually setting a class themselves. This directly targets the messy
"before define_observation_data()" data prep in tests/test_workflow/
testing.R, where every observation stream needed its own hand-rolled
class(x) <- ... line.

Verified: a raw unclassed data.frame produces identical output to the
equivalent pre-classed object; a data.frame missing the required columns
errors with a clear message instead of failing deep inside as_matrix().
define_observation_data() now accepts a plain date/value data.frame directly
(via as_epiwave_timeseries(), added earlier on this branch), so the
hand-rolled class(notif_dat) <- c('epiwave_fixed_timeseries',
'epiwave_timeseries', class(notif_dat)) lines are no longer needed.

Verified: re-ran testing.R's basic (cases + hospitalisations) fit against
real data in ../data with the classing removed -- notif_dat/hosp_dat stay
plain tbl_df objects throughout, and the fit runs to completion identically.
testing.R/test2.R depend on local, not-synced data (../data, simdata/) that
isn't in the repo -- useful as personal real-data scripts, but not runnable
by anyone else who clones it, and don't exercise the multi-jurisdiction path
at all (the package has never had a multi-jurisdiction reprex before this
branch).

Adds two fully self-contained scripts (fabricated data, no external
dependencies) that demonstrate the current API end to end:

- single_jurisdiction_workflow.R: raw data.frames passed straight to
  define_observation_data() (no manual class(x) <- c(...)), delay
  distributions built via epiwave.params (discrete_pmf, combined with `+`),
  a greta-array-derived proportion (IHR-from-CHR), and an "advanced" section
  showing a time-varying discrete_pmf_series delay.
- multi_jurisdiction_workflow.R: two jurisdictions with deliberately
  staggered/non-identical date coverage, dow_model = TRUE to exercise
  hierarchical DOW pooling, and an explicit alignment check confirming a
  date only one jurisdiction has data for stays correctly non-NA/NA in the
  right columns.

Both run to completion (small MCMC settings for speed) with their
stopifnot() checks passing.
Captures why the seroprevalence pathway (define_sero_data(),
create_small_sero_model(), the discrete_weights widening in
new_convolution_matrix()/evaluate()) is being pulled back out of this MVP,
the recommended architecture for re-integrating it (merge into
define_observation_data()/create_observation_model() rather than parallel
functions with if-based dispatch), the real bugs found in the original sero
code during this work, and what validation is still needed before it ships.

dev/ is excluded from R CMD build via .Rbuildignore, matching the existing
pattern for .claude/.positai.
Per discussion: don't ship if-statement dispatch for a not-yet-validated
feature. Removes define_sero_data(), create_small_sero_model(), the
total_pop/size_vec plumbing through prepare_observation_data() and
stack_jurisdictions()'s stack_stream(), the model_fn dispatch in
fit_waves(), and the discrete_weights/discrete_weights_series widening in
new_convolution_matrix()/evaluate() (which had no other consumer once sero
is removed). inits_by_jurisdiction()'s mean_step() helper simplifies back to
a plain mean(x) call accordingly.

The design for how to re-integrate this cleanly (merge into
define_observation_data()/create_observation_model() rather than parallel
functions with if-based dispatch, as agreed before removing it), the real
bugs found in the original sero code, and what's still needed before it
ships are captured in dev/sero-integration-notes.md (previous commit).

fit_waves()/stack_jurisdictions()/prepare_observation_data() now only
handle the validated cases/hospitalisations pathway.
testing.R's sero fit block called define_sero_data(), which no longer
exists after the previous commit -- it was already non-functional (sero_dat/
sero_size_mat/sero_conversion were never assigned), so this just removes
dead code rather than breaking anything that worked. Left short pointer
comments to dev/sero-integration-notes.md at both spots (data prep, and
where a third define_observation_data() block would go) for whoever picks
this back up.

devtools::document() drops create_small_sero_model/define_sero_data exports
and man pages, and the now-unused importFrom(greta, binomial).

Verified after all sero-removal changes: devtools::check() is 0 errors, 0
warnings (same 3 pre-existing NOTEs); testing.R's basic fit still runs
against real data in ../data; both single_jurisdiction_workflow.R and
multi_jurisdiction_workflow.R still run to completion with their
stopifnot() checks passing.
Two changes, implemented together since they touch the same functions:

1. stack_jurisdictions() becomes the explicit, user-facing combining step,
   taking named ... args (mirroring define_observation_model()'s own
   cases=/hospitalisations= pattern) instead of a pre-built named list --
   e.g. stack_jurisdictions(VIC = obs_vic, NSW = obs_nsw). A single
   jurisdiction needs no combining step at all: define_observation_model()'s
   output (now stamped with class epiwave_observation_model) goes straight
   to fit_waves(), which detects whether it already received a stacked
   object (class epiwave_stacked_observations) or a raw single-jurisdiction
   one and wraps the latter internally (auto-labelled, since there's no
   jurisdiction identity to preserve for n=1). This removes the
   setNames(list(...), jurisdictions) step that was previously required even
   for a single jurisdiction.

2. GAM-based initial values (inits_by_jurisdiction(), and the cross-stream
   union/mean that combines them) are only ever consumed by
   infection_model_type = 'flat_prior' -- every GP-based infection model
   ignores them entirely (confirmed in create_infection_timeseries():
   observable_infection is referenced only inside the flat_prior branch).
   Previously this ran unconditionally for every stream x jurisdiction
   during data prep, running an mgcv::gam() fit that was silently discarded
   whenever a GP model was used instead. prepare_observation_data() no
   longer computes inits at all (keeps delays in its output instead, needed
   later); define_observation_model()/stack_jurisdictions() no longer
   eagerly combine them either. A new compute_flat_prior_inits() does this
   work on the fully-stacked object, called lazily by fit_waves() only
   inside its flat_prior branch, before create_infection_timeseries() (which
   needs the result) and reused for the MCMC initial values.

Verified: a standalone check exercising both a single-jurisdiction fit
(flat_prior AND gp_growth_rate, confirming the GP path never triggers inits
computation) and a two-jurisdiction stack_jurisdictions() combination (plus
the unnamed-args error path) all pass. devtools::check() is 0 errors, 0
warnings. testing.R's real-data fit (../data) verified against both
flat_prior and gp_growth_rate. Both synthetic workflow scripts updated to
the new API and re-verified end to end.
compute_flat_prior_inits() was reading prop_mat from the stacked object,
but stack_stream() applies DOW correction to prop_mat before returning it --
so for any stream with dow_model = TRUE, inits_by_jurisdiction() was being
handed a greta array (the DOW-corrected proportion) instead of a plain
numeric one. It didn't error (there's already a greta_array branch in
inits_by_jurisdiction()), but that branch draws from the *prior* predictive
distribution of the DOW effect via greta::calculate(nsim = 100) -- no MCMC
has run yet at this point -- making the "deterministic smoothed guess"
inits computation silently stochastic, and adding an unnecessary
100-draw simulation per DOW-modelled stream x jurisdiction whenever
flat_prior is used.

Checked against the original, pre-refactor code to confirm this wasn't just
a style difference: it explicitly computed inits from prop_mat *before*
applying DOW correction, deliberately. My first refactor (PR #51) preserved
this ordering (DOW correction lived in stack_jurisdictions(), which ran
after prepare_observation_data()'s inits computation); deferring inits to
run after stacking (previous commit) inverted it by accident.

Fix: stack_stream() now also returns prop_mat_raw (captured before DOW
correction is applied), and compute_flat_prior_inits() reads that instead
of prop_mat. create_observation_model() is unaffected -- it still reads the
DOW-corrected prop_mat for the actual likelihood, as it should.

Verified: prop_mat_raw is a plain numeric column (not a greta_array) even
when dow_model = TRUE, so inits_by_jurisdiction() no longer needs
greta::calculate() at all for this case. Re-ran both synthetic workflow
scripts and testing.R's real-data fit (flat_prior + dow_model = TRUE, the
exact previously-affected combination) end to end. devtools::check() is
still 0 errors, 0 warnings.
It's called from exactly one place (define_observation_model()) and never
appears in any workflow script -- it's implementation detail, the same
category as stack_stream()/compute_flat_prior_inits(), which are already
@nord. Exporting it overstated it as public API and was contributing to a
"why are there three equally-important functions here" feeling, when really
only define_observation_data()/define_observation_model() (plus
stack_jurisdictions()/fit_waves()) are meant to be called directly.

No logic changed -- devtools::check() still 0 errors/0 warnings, both
synthetic workflow scripts re-verified end to end.
Same situation as prepare_observation_data(): called from exactly one place
(compute_flat_prior_inits()), never from a workflow script. Pure internal
machinery.

No logic changed -- devtools::check() still 0 errors/0 warnings, both
synthetic workflow scripts re-verified end to end.
… simplify prop coercion

create_epiwave_fixed_timeseries() had zero call sites anywhere in the
package or workflow scripts, and did nothing as_epiwave_timeseries() doesn't
already do better (it takes a real date/value table directly, rather than
requiring separate dates + value arguments).

create_epiwave_timeseries() had exactly one remaining caller, in
prepare_observation_data()'s proportion_infections coercion -- but that
coercion step was itself redundant: as_matrix() already has an
as_matrix.numeric method that recycles a scalar or validates a same-length
vector against target_infection_dates, identically to what going through
create_epiwave_timeseries() first produced (verified: identical() output
both ways, for both the scalar and vector cases). prepare_observation_data()
now only branches on already-classed epiwave_timeseries objects (for date
validation); a bare numeric prop passes straight to as_matrix(), which
dispatches correctly on its own.

create_epiwave_timeseries.R now holds only create_epiwave_greta_timeseries()
(kept in epiwave rather than epiwave.params specifically because it depends
on greta, which epiwave.params should not) and as_epiwave_timeseries().

Verified: both synthetic workflow scripts and testing.R's real-data fit
produce identical output to before these changes (e.g. single-jurisdiction
posterior median range unchanged at 0-7185). devtools::check() still 0
errors, 0 warnings.
…-export as_epiwave_timeseries()

Renamed to pair naturally with as_epiwave_timeseries() now that the two
create_*/dead functions removed from this file are gone -- as_* matches the
coercion-style naming already used elsewhere (epiwave.params::as_discrete_pmf(),
as_matrix()). The class it produces is renamed to match:
epiwave_greta_timeseries -> greta_timeseries (still inherits from
epiwave_timeseries, so existing inherits(x, 'epiwave_timeseries') checks
that need to catch both plain and greta-backed timeseries objects continue
to work unchanged). as_matrix.epiwave_greta_timeseries() is renamed to
as_matrix.greta_timeseries() to match, and
prepare_observation_data()'s inherits() check on proportion_infections
updated accordingly.

as_epiwave_timeseries() becomes internal (@nord): checked and confirmed no
workflow script actually calls it by name -- users get its behaviour
indirectly (passing a raw data.frame to define_observation_data(), which
coerces internally). It remains available as an unexported helper rather
than being deleted, since it's still the one general-purpose coercion path,
just not meant as public API for now.

Verified: class(ihr) is now `greta_timeseries epiwave_timeseries list`,
dispatch to as_matrix.greta_timeseries() works correctly, both synthetic
workflow scripts produce identical output to before the rename (e.g.
single-jurisdiction posterior median range unchanged at 0-7185), and
testing.R's real-data fit runs to completion. devtools::check() still 0
errors, 0 warnings.
Two minimal examples -- single jurisdiction, and multiple jurisdictions
combined via stack_jurisdictions() -- mirroring
tests/test_workflow/single_jurisdiction_workflow.R and
multi_jurisdiction_workflow.R, trimmed to the essential shape for a README.
Both verified to run to completion exactly as written before adding them;
chunks are eval = FALSE in README.Rmd since running them needs a full
greta/Python setup, which shouldn't be a precondition for a routine README
re-knit.

README.Rmd had been missing from the repo (only README.md existed) despite
a pre-commit hook expecting both to move together; it's restored here from
an old, partially-filled scaffold (its Installation section still suggested
pak::pak() and its Example section was a never-filled-in placeholder).
Updated Installation to match the remotes::install_github() already
decided in README.md, and replaced the Example placeholder with the new
Usage section. Left the empty Citation section and placeholder Support
text untouched rather than inventing content, and left the informal draft
paragraph after the srr-tags chunk untouched (out of scope for this
change).

Also gitignores README.html, a preview-render artifact that isn't source.
@smwindecker smwindecker merged commit d455309 into simplify Jul 6, 2026
0 of 7 checks passed
@smwindecker smwindecker deleted the clean-workflow branch July 6, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant