Event skeletons: reusable topologies with @events
Introduction
The composers in Composing distributions build one tree in one call: name each branch, wire it, and its distributions are baked in on the spot. That is fine for a single fit, but a natural-history topology is often shared: the same onset-admission-outcome pathway describes several pathogens, or the same reporting structure repeats across regions, each time with different delay parameters. Rebuilding the nested sequential/resolve calls for every variant duplicates the topology and buries it inside the distribution literals.
@events separates the two: write the topology once as a readable operator diagram, an EventSkeleton with named holes and no distributions, then fill the holes with update as many times as there are settings. This tutorial builds a hospital pathway skeleton and fills it several ways: two pathogens, a racing-hazard outcome, a parallel branch, a nested outcome path, a delay tied across branches, a literature-uncertain delay, and a region-varying delay. It then edits a filled tree and bulk-updates it from its parameter table. It builds on Composing distributions and Competing outcomes.
using ComposedDistributions
using ComposedDistributions: update
using Distributions
using RandomThe operator diagram
A hospital pathway: onset, then an admission delay, then a death-or-discharge split. → (\to) chains events, and | marks the outcome split; the whole diagram is the topology, no distributions yet.
skeleton = @events begin
onset → admission → (death | discharge)
endEventSkeleton(onset → admission → (death | discharge))@events lowers this straight to an EventSkeleton: a bare identifier becomes a named hole (the fill key), and show renders it back as the same operator diagram.
skeletonEventSkeleton(onset → admission → (death | discharge))Filling the skeleton
update(skeleton; name = fill, ...) substitutes every hole and builds the concrete tree through the ordinary composer verbs: the → chain becomes a Sequential and the | group a Resolve or Compete, decided by the fill value type (see Competing outcomes). Filling death with a (dist, prob) tuple and leaving discharge bare — the residual form used in Composing distributions — makes this a fixed-probability Resolve, the death probability the case-fatality ratio.
cfr = 0.12
pathogen_a = update(skeleton;
onset = Gamma(2.0, 1.0),
admission = LogNormal(0.5, 0.4),
death = (Gamma(1.5, 1.0), cfr),
discharge = Gamma(2.0, 1.5))Sequential (3 steps)
├─ onset: Distributions.Gamma{Float64}(α=2.0, θ=1.0)
├─ admission: Distributions.LogNormal{Float64}(μ=0.5, σ=0.4)
└─ death_or_discharge: Resolve (2 outcomes)
├─ death (p = 0.12): Distributions.Gamma{Float64}(α=1.5, θ=1.0)
└─ discharge (p = 0.88): Distributions.Gamma{Float64}(α=2.0, θ=1.5)The tree prints as the sequential-of-resolve it built; the nested one_of step is auto-named from its branches (death_or_discharge), so a fill names only the branch holes, never the group.
pathogen_aSequential (3 steps)
├─ onset: Distributions.Gamma{Float64}(α=2.0, θ=1.0)
├─ admission: Distributions.LogNormal{Float64}(μ=0.5, σ=0.4)
└─ death_or_discharge: Resolve (2 outcomes)
├─ death (p = 0.12): Distributions.Gamma{Float64}(α=1.5, θ=1.0)
└─ discharge (p = 0.88): Distributions.Gamma{Float64}(α=2.0, θ=1.5)It is a distribution like any composed tree: rand simulates a record, and logpdf scores one straight back.
record = rand(Xoshiro(1), pathogen_a)
logpdf(pathogen_a, record)-4.505429199602361event reaches the resolve node by its auto-name to read the outcome split directly.
outcome = event(pathogen_a, :death_or_discharge)
probs(outcome)(death = 0.12, discharge = 0.88)Reusing the skeleton across settings
The point of separating topology from fill: the same skeleton builds a second pathogen's tree with no change to the diagram, only the numbers.
pathogen_b = update(skeleton;
onset = Gamma(2.3, 0.9),
admission = LogNormal(0.7, 0.3),
death = (Gamma(1.7, 1.1), 0.28),
discharge = Gamma(2.2, 1.3))
(pathogen_a = mean(pathogen_a), pathogen_b = mean(pathogen_b))(pathogen_a = 6.606038430750074, pathogen_b = 6.759241434980728)Every fill still goes through the same validation: an unfilled hole is rejected and named in the error, so a copy-pasted fill missing a branch fails loudly rather than silently building a half-specified tree.
incomplete = try
update(skeleton; onset = Gamma(2.0, 1.0), admission = LogNormal(0.5, 0.4))
nothing
catch err
err
end
sprint(showerror, incomplete)"ArgumentError: event skeleton hole `death` was not filled; pass `death = <dist>` (holes: [:onset, :admission, :death, :discharge])"A racing-hazard fill
The same | group becomes a Compete instead when every branch is filled with a bare distribution, no probabilities: the split then follows from which cause-specific delay fires first, not a fixed rate.
racing = update(skeleton;
onset = Gamma(2.0, 1.0),
admission = LogNormal(0.5, 0.4),
death = Gamma(1.5, 1.0),
discharge = Gamma(2.0, 1.5))
event(racing, :death_or_discharge)Compete (2 racing outcomes)
├─ death (racing): Distributions.Gamma{Float64}(α=1.5, θ=1.0)
└─ discharge (racing): Distributions.Gamma{Float64}(α=2.0, θ=1.5)Parallel branches
& runs branches off one shared origin instead of chaining them; a notification delay alongside the admission delay is one more hole, not a nested tree written by hand.
parallel_skeleton = @events begin
onset → (admission & notification)
end
parallel_tree = update(parallel_skeleton;
onset = Gamma(2.0, 1.0),
admission = LogNormal(0.5, 0.4),
notification = Gamma(1.0, 1.0))
parallel_treeSequential (2 steps)
├─ onset: Distributions.Gamma{Float64}(α=2.0, θ=1.0)
└─ admission_and_notification: Parallel (2 branches)
├─ admission: Distributions.LogNormal{Float64}(μ=0.5, σ=0.4)
└─ notification: Distributions.Gamma{Float64}(α=1.0, θ=1.0)The parallel group is auto-named the same way, joining its branch names with _and_, so event reaches into it by that name.
event(parallel_tree, :admission_and_notification, :notification)Distributions.Gamma{Float64}(α=1.0, θ=1.0)Nesting a chain inside an outcome
An outcome is not always a single leaf: a death arm might itself run through an intensive-care step first. A parenthesised → chain nests straight inside a | branch, so the topology stays one diagram.
nested_skeleton = @events begin
onset → admission → ((icu → death) | discharge)
endEventSkeleton(onset → admission → (icu → death | discharge))A branch that is itself a multi-step chain has no single probability to carry, so the fill can only read it as a racing hazard: a | group with any nested chain builds a Compete, and the death arm contributes its own icu and death steps.
nested_tree = update(nested_skeleton;
onset = Gamma(2.0, 1.0),
admission = LogNormal(0.5, 0.4),
icu = Gamma(1.2, 1.0),
death = Gamma(1.5, 1.0),
discharge = Gamma(2.0, 1.5))
event(nested_tree, :death_or_discharge, :death)Sequential (2 steps)
├─ icu: Distributions.Gamma{Float64}(α=1.2, θ=1.0)
└─ death: Distributions.Gamma{Float64}(α=1.5, θ=1.0)Tying a delay across branches
When two branches share one delay parameter, fill both holes with a shared leaf carrying the same tag. The two occurrences are then treated as one free parameter: params_table lists the delay group once rather than once per branch.
tied = update(parallel_skeleton;
onset = Gamma(2.0, 1.0),
admission = shared(:delay, Gamma(1.0, 1.0)),
notification = shared(:delay, Gamma(1.0, 1.0)))
params_table(tied)params_table (4 rows)
edge param value support prior
───── ───── ───── ────────── ─────
onset shape 2.0 (0.0, Inf)
onset scale 1.0 (0.0, Inf)
delay shape 1.0 (0.0, Inf)
delay scale 1.0 (0.0, Inf)An uncertain fill
A fill value is any valid leaf, not only a plain distribution: an @uncertain leaf carries literature uncertainty on a parameter, and it fills a hole exactly like a bare distribution. Here the onset delay's shape is uncertain rather than fixed at 2.0.
uncertain_tree = update(skeleton;
onset = (@uncertain Gamma(Normal(2.0, 0.3), 1.0)),
admission = LogNormal(0.5, 0.4),
death = (Gamma(1.5, 1.0), cfr),
discharge = Gamma(2.0, 1.5))
has_uncertain(uncertain_tree)trueparams_table carries the attached prior on its prior column, same as any other uncertain leaf; nothing about @events changes how the estimation surface reads it.
params_table(uncertain_tree)params_table (10 rows)
edge param value support prior
─────────────────────────────── ───────── ───── ────────── ───────────────────────────────────────────
onset shape 1.0 (0.0, Inf) Distributions.Normal{Float64}(μ=2.0, σ=0.3)
onset scale 1.0 (0.0, Inf)
admission mu 0.5 (0.0, Inf)
admission sigma 0.4 (0.0, Inf)
death_or_discharge.death shape 1.5 (0.0, Inf)
death_or_discharge.death scale 1.0 (0.0, Inf)
death_or_discharge.discharge shape 2.0 (0.0, Inf)
death_or_discharge.discharge scale 1.5 (0.0, Inf)
death_or_discharge.branch_probs death 0.12 (0.0, 1.0)
death_or_discharge.branch_probs discharge 0.88 (0.0, 1.0)A region-varying fill
A delay that changes with a covariate fills a hole with a varying leaf: a map from the covariate level to a distribution, plus a reference used until the tree is resolved.
regional = varying(
region -> region == :south ? Gamma(2.4, 1.0) : Gamma(2.0, 1.0);
covariate = :region, reference = Gamma(2.0, 1.0))
varying_tree = update(skeleton;
onset = regional,
admission = LogNormal(0.5, 0.4),
death = (Gamma(1.5, 1.0), cfr),
discharge = Gamma(2.0, 1.5))
has_varying(varying_tree)trueinstantiate resolves the tree against a Context, looking the covariate up and swapping in the level's distribution.
south = instantiate(varying_tree, Context(region = :south))
event(south, :onset)Distributions.Gamma{Float64}(α=2.4, θ=1.0)Editing a filled tree
A filled skeleton is an ordinary composed tree, so the topology edits apply to it directly: reuse a skeleton for the common case, then adjust the one tree that differs rather than writing a new diagram. splice inserts a step around a node, here a reporting delay after admission.
with_report = splice(pathogen_a, :admission;
after = :admission_report => Gamma(1.0, 2.0))
event(with_report, :admission, :admission_report)Distributions.Gamma{Float64}(α=1.0, θ=2.0)update(tree, path => node) replaces a node in place, keeping the shape; here it retunes the admission delay.
retuned = update(pathogen_a, :admission => LogNormal(0.7, 0.3))
event(retuned, :admission)Distributions.LogNormal{Float64}(μ=0.7, σ=0.3)prune drops an outcome and renormalises the remaining branch probabilities. A Resolve keeps at least two outcomes, so pruning needs a three-way split to drop from.
triage_skeleton = @events begin
onset → admission → (death | discharge | transfer)
end
triage = update(triage_skeleton;
onset = Gamma(2.0, 1.0),
admission = LogNormal(0.5, 0.4),
death = (Gamma(1.5, 1.0), 0.12),
discharge = (Gamma(2.0, 1.5), 0.68),
transfer = (Gamma(1.0, 1.0), 0.20))
dropped = prune(triage, :death_or_discharge_or_transfer, :transfer)
probs(event(dropped, :death_or_discharge_or_transfer))(death = 0.15, discharge = 0.85)Bulk edits through the parameter table
params_table is a Tables.jl table, and update(tree, table) folds one back in, so the table is a round-trip edit surface: read it, change a value (or a whole column, in a DataFrame or a spreadsheet), and apply it in one call. Re-applying the unchanged table is a no-op.
tbl = params_table(pathogen_a)
update(pathogen_a, tbl) == pathogen_atrueEditing the value column and folding the table back sets the matching parameters, here bumping the onset shape to 3.0.
tbl.value[1] = 3.0
bumped = update(pathogen_a, tbl)
event(bumped, :onset)Distributions.Gamma{Float64}(α=3.0, θ=1.0)Summary
@eventslowers a→/|/&operator diagram to anEventSkeleton: names and structure, no distributions.update(skeleton; name = fill, ...)fills every hole and builds the concrete tree through the ordinary composer verbs, validating that every hole is filled and every fill key names a hole.The
|group becomes aResolveor aCompetedepending on whether the fill values carry probabilities; a branch that is itself a nested chain has no probability to carry, so it forces aCompete.A fill value is any valid leaf — a plain distribution, a
sharedleaf, an@uncertainleaf, avaryingleaf, or a pre-built subtree — so one skeleton reuses across pathogens, regions or scenarios by filling it differently each time.A filled skeleton is an ordinary composed tree, so
splice,pruneandupdateedit it, andparams_tablewithupdate(tree, table)is a round-trip bulk-edit surface.A group's auto-name (
_or_for one_of,_and_for parallel) is structural; a fill names only the branch holes, never the group.
Where next
Composing distributions is the full verb walkthrough behind the tree
updatebuilds.Competing outcomes works through
resolveversuscompetein depth.Concepts maps every verb, including
update, to the layer it belongs to.