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 of AbstractMultiChild, not a multi-child node itself.

  • 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 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, through the free_leaf / rewrap_leaf pair (public, not exported). See The leaf protocol for the full contract, the round-trip guarantee, and a worked example.

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