Skip to content

Interface contracts: valid nodes and leaves

This page is the reference for what makes a type a valid participant in composition, stated as the exact method contract the package relies on. A composer combines named child distributions into an event tree; the tree walkers reach every node and leaf through a small set of methods, so any type that implements those methods composes with the built-ins with no extra work.

The reusable interface-conformance suite ComposedDistributions.TestUtils checks these contracts over every built-in node shape and a user-defined node, and the package runs it in test/interfaces.jl, so the prose here and the tests stay in sync. To add a valid member, subtype the right abstract, implement the methods listed for its role, and run test_composed_interface (or test_interface for a plain leaf) over an instance.

The type landscape

The composer nodes share one supertype, AbstractComposedDistribution{F, S}. The named-child composers and the univariate one_of family sit under it; leaves and leaf wrappers are plain univariate distributions under no composer supertype.

text
Distribution{F, S}
└── AbstractComposedDistribution{F, S}   named children → an event tree
    ├── AbstractMultiChild{S}            positional, tree-walked together
    │   ├── Sequential                   named steps in series (a chain)
    │   └── Parallel                     named branches off one origin (fan-out)
    ├── Choose                           data-selected disjoint alternatives
    └── AbstractOneOf                    one univariate time-to-event marginal
        ├── Resolve                      a fixed-probability mixture
        └── Compete                      racing hazards (soonest cause fires)

plain univariate leaves (no composer supertype):
    Shared    a tied leaf (one free parameter across branches)
    NoEvent   an absorbing no-event branch
    any Distributions.jl UnivariateDistribution

AbstractComposedDistribution is parametric on variate form F (Univariate / Multivariate), so one supertype spans the univariate one_of members and the multivariate event-tree composers while preserving Distribution{F, S}. AbstractMultiChild is an intermediate that groups the two positional multi-child composers (Sequential, Parallel) the tree walkers dispatch over together; Choose is a sibling, not a multi-child node; and AbstractOneOf re-roots the univariate one_of family under the composed supertype, so it stays a UnivariateDistribution while sharing the composed abstract. The tree walkers still dispatch on AbstractOneOf wherever the two one_of nodes behave alike (one event slot per outcome, the shared origin, the per-outcome draw) and on the concrete type only where the scoring arithmetic differs. Downstream extension packages (CensoredDistributions and its siblings) dispatch on these supertypes, so the names and shape match the shared contract. test_abstract_membership pins the membership down as a test, so a type filed under the wrong supertype fails.

The composer-node contract

A realisation of a composed tree is one flat vector of leaf values laid out depth-first, and each node reads and writes only its own contiguous slice by an offset. Three methods carry this, reached by the qualified name (ComposedDistributions.child_nleaves and friends, which are public but not exported):

  • child_nleaves(node) returns a positive Int, the flat-slot count (one per leaf below the node);

  • child_logpdf(node, x, offset, n) returns a finite scalar over the node's n-wide slice x[offset + 1 : offset + n], independent of the surrounding padding;

  • child_rand!(out, offset, rng, node) fills exactly that slice in place and returns nothing, leaving the padding either side untouched.

A node delegates to each child by the same three methods, passing each child its own offset, so it nests inside any other node automatically. component_names(node) returns a Tuple of the child names.

A univariate leaf is the base case: it occupies one slot (child_nleaves == 1), child_rand! writes its single draw, and child_logpdf scores x[offset + 1]. Any Distributions.jl distribution is therefore a valid leaf with no package-specific hooks.

Adding a valid composer node

  1. Implement the three child_* methods so they read and write only the node's own slice, delegating to each child by the same methods.

  2. Implement component_names if the node carries named children.

  3. Verify against the suite by running the same conformance checks the built-ins pass.

julia
using ComposedDistributions, Distributions, Random
import ComposedDistributions: child_nleaves, child_logpdf, child_rand!

# A minimal node combining two branches side by side.
struct Both{A, B}
    first::A
    second::B
end

child_nleaves(b::Both) = child_nleaves(b.first) + child_nleaves(b.second)

function child_logpdf(b::Both, x, offset, ::Int)
    n1 = child_nleaves(b.first)
    n2 = child_nleaves(b.second)
    return child_logpdf(b.first, x, offset, n1) +
           child_logpdf(b.second, x, offset + n1, n2)
end

function child_rand!(out, offset, rng::AbstractRNG, b::Both)
    n1 = child_nleaves(b.first)
    child_rand!(out, offset, rng, b.first)
    child_rand!(out, offset + n1, rng, b.second)
    return nothing
end

node = Both(Gamma(2.0, 1.0), LogNormal(0.5, 0.4))
out = zeros(child_nleaves(node))
child_rand!(out, 0, Random.default_rng(), node)
child_logpdf(node, out, 0, child_nleaves(node))

The one_of-outcome family: AbstractOneOf

The two one_of-outcome nodes share the supertype AbstractOneOf: Resolve (the fixed-probability mixture, cause and timing independent) and Compete (racing hazards, with the winning probability derived from the hazards). AbstractOneOf subtypes AbstractComposedDistribution{Univariate, Continuous}, so the one_of family is the univariate arm of the composer hierarchy. Both are univariate marginals, so each occupies a single flat slot and satisfies the node contract through the univariate-leaf base case.

A valid member subtypes AbstractOneOf, stores its outcome names, and implements the standard univariate interface (logpdf, rand, and the moments it can compute) so the marginal is a proper distribution.

julia
using ComposedDistributions, Distributions

r = resolve(:death => (Gamma(1.5, 1.0), 0.3), :disch => (Gamma(2.0, 1.5), 0.7))
c = compete(:death => Gamma(2.0, 3.0), :recover => Gamma(3.0, 2.0))
r isa ComposedDistributions.AbstractOneOf
c isa ComposedDistributions.AbstractOneOf

The introspection contract

A composed tree exposes its structure through name introspection, and every built-in node keeps these in agreement:

  • component_names(node) — the Tuple of immediate child names;

  • event_names — the flat per-event name tuple (one entry per leaf edge, plus the origin);

  • event_tree — the same names as a nested record;

  • event — fetch a child or descend a name path;

  • params_table — the free parameters flattened to a Tables.jl table, one row per parameter.

julia
using ComposedDistributions, Distributions
import ComposedDistributions: component_names

tree = compose((onset_admit = Gamma(2.0, 1.0),
    admit_death = LogNormal(0.5, 0.4)))
component_names(tree)          # (:onset_admit, :admit_death)
event(tree, :onset_admit)      # Gamma(2.0, 1.0)
params_table(tree)             # a Tables.jl table of the free parameters

The leaf-wrapper contract

A leaf wrapper wraps one inner base distribution and stays transparent to the prior and parameter surface. The package reaches the inner leaf through two methods (public, not exported):

  • free_leaf(d) returns the free inner leaf (a Distribution), peeling any wrapping;

  • rewrap_leaf(d, inner) reconstructs an equivalent wrapper around a new inner leaf.

Together they must round-trip: rewrap_leaf(d, free_leaf(d)) reproduces a node whose density matches d. A plain leaf is its own free leaf (free_leaf(d) == d, rewrap_leaf(d, inner) == inner); Truncated peels to its untruncated base and rebuilds the bounds; Shared peels through to its inner leaf and rebuilds the tie.

julia
using ComposedDistributions, Distributions
import ComposedDistributions: free_leaf, rewrap_leaf

d = shared(:inc, Gamma(2.0, 1.0))
free_leaf(d)                                 # Gamma(2.0, 1.0)
free_leaf(rewrap_leaf(d, Gamma(3.0, 1.5)))   # Gamma(3.0, 1.5)

Keeping the hierarchy honest

The reusable ComposedDistributions.TestUtils suite is the machine-checkable statement of these contracts, and the package runs it in test/interfaces.jl. test_interface runs the public checklist over the fixture set (example_fixtures); test_node_interface runs the node-extension checklist; test_composed_interface wraps both and asserts the AbstractComposedDistribution membership; and test_abstract_membership asserts the whole hierarchy (every composer under AbstractComposedDistribution, Sequential / Parallel under AbstractMultiChild, the one_of family under AbstractOneOf, Choose a sibling, and plain leaves and Shared standalone). Drop the same suite into your own tests to verify a custom leaf or composer conforms, and run it after adding a type to a family:

julia
using ComposedDistributions.TestUtils: test_composed_interface, test_abstract_membership

test_abstract_membership()

Conformance suite reference

The reusable suite lives in the ComposedDistributions.TestUtils submodule.

ComposedDistributions.TestUtils Module
julia
ComposedDistributions.TestUtils

Public interface-conformance harness for the composers.

TestUtils.test_interface(d) runs one interface checklist over a composed distribution (or a bare leaf), so a downstream author writing a new leaf or composer can drop it into their own @testset to verify conformance against the package's public interface. test_node_interface(node) is the companion check for a new composer node, asserting its child_nleaves / child_logpdf / child_rand! methods round-trip on a flat event vector. test_interface, example_fixtures, test_rejects_invalid, test_node_interface, test_composed_interface and test_abstract_membership are exported from this submodule.

The harness is dependency-light — Test (a stdlib), Tables, and the package's own public surface — and each check returns its @testset result so a caller can assert on it. The package runs the same checklist over its fixture set in test/interfaces.jl (see example_fixtures).

Examples

julia
using ComposedDistributions, Distributions
using ComposedDistributions.TestUtils

tree = compose((onset_admit = Gamma(2.0, 1.0),
    admit_death = LogNormal(0.5, 0.4)))
TestUtils.test_interface(tree)
nothing # hide

Exports


Imports

  • Base
source
ComposedDistributions.TestUtils.test_interface Function

Run the public interface-conformance checklist over a composed distribution.

test_interface(d; name) runs one @testset of interface assertions against d (a composed distribution or a bare leaf), so a downstream author writing a new leaf / composer can verify conformance by dropping it into their own tests.

The checklist asserts, where applicable to the node's shape:

  • a rand(d) realisation is a scalar (univariate) or a labelled NamedTuple (multivariate composer);

  • the overall mean / var / std are shaped as the fixture's overall declares (a scalar for a univariate-collapsible node, a per-endpoint NamedTuple for a Parallel);

  • logpdf is finite on the supplied in-support draw;

  • a univariate cdf is monotone and in [0, 1];

  • params works and params_table is a Tables.jl table (Tables.istable(params_table(d)));

  • event_names (flat) and event_tree agree in leaf count;

  • event(d, path...) round-trips the supplied known path;

  • observed_distribution collapses a chain to a univariate scalar.

Pass the fixture metadata (an example_fixtures entry, or the keyword arguments directly) so the harness knows the in-support draw, a known event path, a Choose kind, and the overall moment shape. Returns the @testset object.

Examples

julia
using ComposedDistributions, Distributions
using ComposedDistributions.TestUtils: test_interface

d = compose((onset_admit = Gamma(2.0, 1.0), admit_death = LogNormal(0.5, 0.4)))
test_interface(d; draw = [1.5, 0.8], path = (:onset_admit,),
    overall = :vector, has_endpoint = false)
source
ComposedDistributions.TestUtils.test_composed_interface Function

Assert a composed distribution satisfies the AbstractComposedDistribution contract.

test_composed_interface(node; kwargs...) checks node subtypes AbstractComposedDistribution, exposes component_names, and passes both the node-extension checklist (test_node_interface) and the public interface checklist (test_interface; pass the same fixture keyword arguments). Returns the @testset object.

source
ComposedDistributions.TestUtils.test_node_interface Function

Assert a composer node satisfies the public node-extension contract.

test_node_interface(node) checks the three methods a new composer node implements (child_nleaves, child_logpdf, child_rand!) round-trip on a flat event vector, the same way the composers walk one. It asserts that

  • child_nleaves(node) is a positive Int;

  • child_rand! fills exactly the node's offset + 1 : offset + n slot, leaving any padding either side untouched;

  • child_logpdf(node, x, offset, n) is a finite scalar on that drawn vector and does not depend on the surrounding padding.

Pass offset and pad to place the node inside a wider vector, and rng for a reproducible draw. Returns the @testset object.

Examples

julia
using ComposedDistributions, Distributions
using ComposedDistributions.TestUtils: test_node_interface

node = compose((onset_admit = Gamma(2.0, 1.0), admit_death = LogNormal(0.5, 0.4)))
test_node_interface(node)
source
ComposedDistributions.TestUtils.test_abstract_membership Function

Assert the built-in composer types subtype the right family supertype.

test_abstract_membership() is the meta-test that the abstract hierarchy stays consistent: every composer node subtypes AbstractComposedDistribution, the two positional multi-child composers (Sequential / Parallel) subtype AbstractMultiChild, and the one_of family (Resolve / Compete) subtypes AbstractOneOf. Choose is a sibling, not a multi-child node. A type filed under the wrong family fails here. Returns the @testset object.

source
ComposedDistributions.TestUtils.test_rejects_invalid Function

Assert each composer rejects invalid construction in its inner constructor.

test_rejects_invalid() checks that the standard composers validate their structural invariants on every construction path, so a malformed node errors at build time rather than later: Sequential needs at least one component, Resolve at least two outcomes with branch probabilities in [0, 1], and Choose at least two alternatives with unique names. Returns the @testset object.

source
ComposedDistributions.TestUtils.test_ad_safety Function

Assert a parameterised log density differentiates under an injected AD backend.

test_ad_safety(f, θ; ad_gradient, name) evaluates ad_gradient(f, θ) (e.g. ForwardDiff.gradient) on a closure f(θ::Vector) -> Real reconstructing a distribution from its parameter vector and returning a scalar log density, and asserts the gradient is finite. Returns the @testset object.

source
ComposedDistributions.TestUtils.test_registry_coverage Function

Assert the fixture registry covers every public composer type.

test_registry_coverage(fixtures = example_fixtures()) checks that every type in registry_types appears (possibly nested) in at least one fixture, so a new public composer type added without a test_interface fixture fails here. The walk descends composer children. Returns the @testset object.

source
ComposedDistributions.TestUtils.registry_types Function

The public composer types the fixture registry must cover.

registry_types() returns the Vector of the package's own public composer types that test_interface is expected to exercise. test_registry_coverage asserts every entry appears in at least one example_fixtures fixture, so a new public composer type added without a fixture fails.

source
ComposedDistributions.TestUtils.example_fixtures Function

The example fixture set over every composer shape, for test_interface.

Returns a Vector of test_interface-ready fixtures covering the composer shapes (Sequential, Parallel, Resolve, Compete, choose), a bare leaf, a nested mix, and a deep-nesting matrix (a Sequential of Parallel, a Choose of Sequentials). test_registry_coverage asserts these cover every public composer type. The package runs the conformance checklist over these in test/interfaces.jl; a downstream author can read them as worked examples of the metadata test_interface expects (a draw, an event path, the overall moment shape, an ad probe).

source