A ground-up rxmc: declare, then compile#
This document guides a rewrite of rxmc from a blank repository. It is the
plan of record for that rewrite, not a comparison with the current code: the
current api_generalisation branch stays where it is as the reference
implementation, and design.md / bugs_found.md remain the record of how
it was designed and what was found wrong with it. recipes.md is the
companion: one short user story per use case the rewrite must support,
each with the spelling below and the behaviour a user should expect.
The concepts of design.md survive unchanged: evidence as a weighted sum
over independent constraints; a constraint as the maximal block of mutually
correlated data; one Term type with three kinds; the likelihood as a
functional of (d2, logdet, n); the comparison space owned by the data side;
masks as part of support; and the case A (couple the data) / case B (share
a parameter) distinction. The mechanics are rebuilt around one rule:
Everything the user constructs is an immutable declaration.
Problemis the only compile step.
Three goals drive every choice below, in this order:
The maintainer. The smallest implementation that covers every capability the current notebooks and tests demonstrate, with no lifecycle rules to remember. Budget: about 2,300 lines of package code in 14 modules, down from 6,199 in 23.
The user. Declaring a problem, including each statistical modelling choice in it, reads as a statement of the model. A reviewer should be able to read the declaration and write down the likelihood.
External samplers only. emcee, dynesty and the
black-box-bayesCLI are the calibration drivers. The in-package Gibbs walker and its samplers are not carried over.
1. Rules for the maintainer#
These are the review checklist for every change in the new repository.
Specs hold no caches and no solver state.
Parameter,Dataset,Model,Term,Comparison,Constraintare frozen dataclasses witheq=False: identity is the equality, for every spec, not onlyParameter. (A generated__eq__would try to compare the arrays they hold and raise.) Anything expensive or grid-dependent lives in the objectsProblembuilds.Exactly one function walks the parameter graph:
Problem.__init__. Nothing else assigns slots, checks names, resolves supports, or decides which prior covers which slot.Gather, never split. Every callable node receives one integer gather array at compile time and is evaluated as
node(*theta[gather]). There is non_model_params, noindices[:-1], nosplit_params.Every user-supplied callable has one shape:
fn(context, *values), wherecontextis the gridx(models, transforms) or aTermContext(terms, bases, amplitudes) andvaluesare the sampled values of the parameters the node declares, in declaration order.Sharing is spelled by passing the same
Parameterobject. Inside a term, between terms, between a model and a term, across constraints. There is no second identity notion and no value-equality anywhere.One factorisation path.
StructuredCovarianceis the only way a covariance is factored. The dense matrix exists as a display method and as the reference in tests.Fail at compile, and name the dataset. Singular constant covariance, duplicate parameter names, a slot no prior covers, an
on=that references a block outside its constraint, a non-finite value in comparison space: all raised byProblem, never mid-chain.Nothing is folded into a covariance silently. A dataset’s reported systematics become terms only when the user asks (
block.reported_terms()).A
Problempickles withdill.black-box-bayesships it to every MPI rank by path. A round-trip test on a reaction problem is part of the suite.Correctness lives in tests that pin numbers, not in defensive branches: the closed-form Student-t, delta-method errors under
log, the regression log-likelihood1.195784087817536, dense-versus- structured equality on every error-model form. Every recipe inrecipes.mdis a test undertest/recipes/, one file per recipe namedtest_recipe_NN_<slug>.py, whose docstring quotes the recipe’s intent and whose assertions are its Expected behaviour bullets. A CI check fails when a## NN.heading inrecipes.mdhas no test file, or a test file has no heading.
2. The API skeleton#
Read top to bottom. Module names are the package layout of §6. Type hints are the documentation; the prose says only what the signature cannot.
2.1 params.py#
@dataclass(eq=False, frozen=True)
class Parameter:
name: str
bounds: tuple[float, float] = (-inf, inf)
prior: object | None = None # frozen scipy univariate: logpdf, cdf, ppf, rvs
unit: str = ""
latex: str | None = None
eq=False keeps identity hashing: a parameter is its object, and it can
key a dict. A parameter may carry its own marginal prior. The rules, enforced
once at compile:
declared |
prior used |
|---|---|
|
|
finite |
uniform on |
neither |
must be covered by a joint prior given to |
This replaces IndependentPrior, TruncatedNormalPrior, as_prior and the
list-of-scipy-distributions form with no classes at all.
2.2 transforms.py#
Harvested from the current module minus per_observation_scaling and the
contextual flag.
class Transform:
fn: Callable # fn(a, *values) -> array
params: tuple[Parameter, ...]
derivative: Callable | None # d fn / d a, elementwise; finite difference fallback
inverse: Transform | None # parameter-free transforms only
def __call__(self, a, *values) -> ndarray
def __or__(self, other) -> Transform # (f | g)(a) = g(f(a)); params f + g
identity, log, exp # parameter-free singletons
def scale(parameter=None, log=True) -> Transform # rho * a; default Parameter("log_rho")
def as_transform(t) -> Transform # None -> identity; callable -> parameter-free Transform
One type, three roles: the comparison space of a Comparison, a mean transform
composed onto a Model, and the coordinates a Term is evaluated in.
2.3 units.py and data.py#
# units.py — the one pint registry and the unit contract
ureg = UnitRegistry()
XS_UNIT = ureg.barn / ureg.steradian # every cross section stored in b/sr
RUTHERFORD_UNIT = ureg.millibarn / ureg.steradian # what jitr reports
MB_PER_B = 1000.0
DEFAULT_LMAX = 20
def check_angle_grid(angles_rad, name) -> None
# data.py
@dataclass(frozen=True)
class Dataset:
x: ndarray # angles in radians for reaction data
y: ndarray # physical units (b/sr, dimensionless, ...)
y_err: ndarray # statistical, physical units
norm_err: float | ndarray | None = None # reported fractional normalisation
offset_err: float | ndarray | None = None # reported absolute offset, physical units
label: str = ""
meta: Mapping = field(default_factory=dict) # kinematics: reaction, Elab, ExIAS, quantity, k, ...
def from_measurement(measurement, *, reaction=None, quantity=None, ExIAS=None) -> Dataset
Dataset is pure data. No comparison transform, no mask, no solver
workspace, no identity key.
from_measurement is the single EXFOR adapter. It reads the
exfor_tools.Distribution fields (x, y, Einc, quantity, y_units, statistical_err, systematic_norm_err, systematic_offset_err, subentry),
converts units once through ureg, divides every dimensionful error by the
conversion factor norm, passes the fractional normalisation error through
untouched, converts angles to radians, and fills meta with reaction,
Elab, quantity, k (and ExIAS for the (p,n) channel). The
dXS/dA ↔ dXS/dRuth conversions need the Rutherford cross section on
the data grid. In jitr that is a closed form of the kinematics,
10 * eta**2 / (4 * k**2 * sin(theta/2)**4) mb/sr
(DifferentialWorkspace.rutherford_xs), so from_measurement computes it
from reaction.kinematics(Elab) with no workspace. A per-angle norm
array is the result in those cases, exactly as today.
2.4 model.py#
A model is a spec of “observables from parameters”. A predictor is that model bound to a grid.
class Model:
params: tuple[Parameter, ...]
def __init__(self, fn, params): ... # fn(x, *values) -> y, physical space
def bind(self, x, meta=None) -> Predictor: ... # generic: closes over x
def __or__(self, transform) -> Model: ... # mean transform; its params appended
def __add__(self, other) -> Model: ... # additive mean discrepancy; params concatenated
def __mul__(self, other) -> Model: ... # multiplicative x-dependent correction; scale() is its constant case
class Predictor:
params: tuple[Parameter, ...]
x: ndarray
def __call__(self, *values) -> ndarray # physical space, on x
def polynomial(order) -> Model # a_0 + a_1 x + ... ; params a0..an
Model.__or__composes at the model level, soomp | scale(rho_1)is a model whose parameters areomp.params + (rho_1,);bindcomposes the bound predictor with the transform. Per-dataset Kennedy–O’Hagan scales are distinctrho_iobjects on distinct comparisons. No routing table.Model.__add__is the explicit mean discrepancy:omp + deltawithdelta = Model(lambda x, *phi: ..., phi_params)predictsomp(x) + delta(x)in physical space, with parametersomp.params + delta.params(a parameter object in both is shared, as everywhere).bindbinds both sides to the same grid and sums. This is the Kennedy–O’Hagan sampled discrepancy, in contrast tokernel, which marginalises it into the covariance; the two may be used together. A reaction model and a plain-function model add without special cases because addition happens on the bound predictors. Composition is left-to-right:(omp + delta) | scale(rho)scales the sum,(omp | scale(rho)) + deltascales only the model.Model.__mul__is the multiplicative counterpart:omp * gwithg = Model(lambda x, *phi: ..., phi)predictsomp(x) · g(x). An additive discrepancy in log space is exactly this, andscale(rho)is the constant caseomp * Model(lambda x, r: np.exp(r), [rho]), kept as a helper.+and*share one binary-composition implementation; the parametric use of|remains for non-separable transforms only.Plotting on a fine grid is
omp.bind(x_fine, d.meta)(*values). This replacesvisualization_workspaceandvisualizable_model_prediction.Reaction models subclass
Modeland override onlybind.
# reactions/elastic.py
class ElasticXS(Model):
def __init__(self, quantity, central, spin_orbit, args_from_params, params,
coulomb=None, *, lmax=DEFAULT_LMAX, wavelengths_beyond_range=2.0,
zeros_per_node=5): ...
def bind(self, x, meta) -> Predictor
# reads meta["reaction"], meta["Elab"];
# builds the jitr IntegralWorkspace + DifferentialWorkspace on x
# (set_up_solver, harvested); returns a Predictor that evaluates the
# potentials on ws.radial_grid(), solves, and extracts dXS/dA | dXS/dRuth | Ay
def momentum_transfer(x, k) -> ndarray # q = 2 k sin(x/2), for coords=
# reactions/ias.py
class IsobaricAnalogPN(Model):
def __init__(self, U_p_coulomb, U_p_central, U_p_spin_orbit, U_n_central,
U_n_spin_orbit, args_from_params, params, *, lmax=..., ...): ...
def bind(self, x, meta) -> Predictor # reads reaction, Elab, ExIAS
The model owns its solver; the data does not. A masked view, a copy, or an
unpickled problem cannot lose a workspace, because nothing routes on the
identity of a data object. The predictor is a pure function of the
potential: there is no compound_correction hook. A compound-elastic
contribution is subtracted from data.y as preprocessing (recipe 20),
which keeps the model free of data-side state and works on any grid. Optional, not in v0: cache the
IntegralWorkspace on the model instance keyed on
(Elab, lmax, wavelengths_beyond_range, zeros_per_node) so two datasets at
one energy solve the basis once.
2.5 terms.py#
@dataclass(frozen=True)
class TermContext:
x: ndarray # coords(x) on the support
y: ndarray # data on the support, comparison space
ym: ndarray | None # prediction on the support; None while factoring constant parts
def __len__(self) -> int
def meta(self, key) -> ndarray # the owning block's data.meta[key], one value per point;
# for a term spanning blocks, the per-point concatenation
@dataclass(frozen=True)
class Term:
fn: Callable | ndarray # fn(c: TermContext, *values) -> vector | matrix
params: tuple[Parameter, ...] = ()
kind: Literal["diag", "mode", "matrix"] = "matrix"
on: Comparison | Dataset | Sequence[Comparison | Dataset] | None = None # None = whole constraint
coords: Transform = identity # applied to x before fn sees it; its params appended
constant: bool = False # fn reads neither ym nor parameters
|
|
contribution |
|---|---|---|
|
standard-deviation vector |
|
|
vector |
|
|
symmetric block |
|
Support is a reference, not integer indices.
on=b1places the term on that block,on=[b1, b2]spans both (case A),on=Noneis the whole constraint.on=also accepts a block’sDataset, and compile resolvesblock is target or block.data is target, so a term written against a block still resolves aftermasked/complementrebuild the constraint.stacked_supportsandsupport=np.arange(...)are gone.A term is stateless. No
bind, no cache. The same object may be placed in two constraints.
The factory helpers keep their bodies and signatures with support= renamed
on=:
statistical(y_err, on=None)
offset(magnitude=None, parameter=None, mask=None, log=True, on=None)
normalization(magnitude=None, parameter=None, mask=None, log=True, on=None)
noise(parameter, log=True, basis=None, basis_params=(), on=None, coords=None)
noise_fraction(parameter, log=True, on=None)
model_error(parameter, averaging=True, log=True, on=None)
systematic(parameter, basis, log=True, basis_params=(), on=None, coords=None)
kernel(kernel, coords=None, amplitude=None, amplitude_params=(), jitter=1e-10,
prefix="discrepancy", params=None, on=None)
# params=: the hyperparameter Parameter objects, one per free element in kernel.theta order;
# None derives fresh ones named f"{prefix}_{name}". Pass the same objects to share
# hyperparameters between per-block kernels. Two kernel terms with derived
# names and the same prefix fail compile on the duplicate name; the error says
# to pass prefix= (distinct kernels) or params= (one shared kernel).
# bases and amplitudes: ones, ym, averaging, x_basis(scale), exp_growth(scale, base=ones),
# constant_amplitude, exp_growth_amplitude(scale)
Hierarchy is sharing plus meta. A hyperparameter shared by several
datasets is one Parameter object placed in one term per block; anything
dataset-specific the term needs (energy, excitation energy, a flag) comes
from c.meta(key). A discrepancy correlated across datasets, such as a
GP over energy and angle, is one matrix term spanning the blocks whose
fn builds its inputs from c.meta("Elab") and c.x. It takes the dense
path; there is no Kronecker structure because real data share no angle
grid. Recipes 22–24 spell all three out.
2.6 likelihood.py#
Harvested. A likelihood is a functional of (d2, logdet, n, *values) with
optional parameters that are ordinary nodes in the index.
class Likelihood:
params: tuple[Parameter, ...] = ()
def log_likelihood(self, d2, logdet, n, *values) -> float
def chi2(self, d2, logdet, n, *values) -> float # d2
class Gaussian(Likelihood): ...
class StudentT(Likelihood): # StudentT(nu=None) -> Parameter("nu", bounds=(1, inf))
# two constraints with the default each derive a "nu";
# compile fails on the duplicate name and says to pass nu=
class Chi2(Likelihood): ... # -0.5 d2, no log-determinant
2.7 constraint.py#
A block is the unit the residual is formed on: one dataset, one model, one comparison space. A constraint is a tuple of comparisons plus the terms, the likelihood functional, the tempering weight, and the active-point masks. Each comparison is one block of the stacked covariance (§2.9), so “block” below and in §2.9 means that.
@dataclass(frozen=True)
class Comparison:
data: Dataset
model: Model # bound at construction: model.bind(data.x, data.meta)
space: Transform = identity # parameter-free comparison transform
# derived, computed once in __post_init__ / cached_property:
predictor: Predictor
y: ndarray # space(data.y)
y_err: ndarray # |space'(data.y)| * data.y_err (delta method)
log_jacobian_all: ndarray # log |space'(data.y)| per point
def reported_terms(self) -> list[Term] # offset then normalisation modes from data.norm_err /
# data.offset_err, delta-method propagated, on=self
@dataclass(frozen=True)
class Constraint:
comparisons: tuple[Comparison, ...]
terms: tuple[Term, ...] = ()
likelihood: Likelihood = Gaussian()
weight: float = 1.0 # tempering; multiplies the log-likelihood only
statistical: bool = True # add each block's y_err diagonal
masks: tuple[ndarray, ...] | None = None # active points per block; None = all active
def masked(self, masks) -> Constraint
def masked_where(self, predicate) -> Constraint # masks = [predicate(comp.data.x) for comp in comparisons]
def complement(self) -> Constraint # every inactive point active, and vice versa
The comparison transform lives on the block because it is a modelling choice: a Gaussian in log space is a different distribution from one in linear space. Terms are authored in comparison space, as today.
Masks live on the constraint, not on the block or the data.
masked,masked_where,complementreturn aConstraintwith the sameComparison,Term, andParameterobjects and new masks. Nocopy.copy, no identity key. A held-out problem built fromcomplement()shares every parameter with the fit, so a posterior sample scores it directly.weightsubsumes bothEvidence(weights=)andCalibrationConfig.likelihood_scaling: one knob, honoured by every driver.Eager checks here need nothing from the parameter graph: comparisons are distinct, an array-valued term has the right shape for its
on, the comparison space is finite on every active point (named block).
2.7b How the pieces thread#
There are exactly two parametric entry points. Everything else is a constant fixed when the comparison is built.
stage |
space |
what enters |
parametric |
|---|---|---|---|
1. |
physical |
|
model parameters |
2. mean modifications, composed on the |
physical |
|
ρ, φ |
3. |
physical → comparison |
|
none |
4. |
comparison |
statistical diagonal; experimental terms (noise, reported modes, USU); model-discrepancy terms (kernel, EFT truncation) |
term parameters, and |
5. |
comparison |
functional of |
Student-t ν only |
Two rules follow. Experimental and model-discrepancy covariance terms
are one mechanism; the difference is what the user means, not what the
code does. A term that reads ym sees the prediction after the mean
modifications and after space, which is right: a reported
normalisation error applies to the measured scale, so its mode is
η · ρ f, and Comparison.reported_terms() gets that for free. And:
mean-side discrepancy is physical-space and x-aware; covariance-side
discrepancy is comparison-space and ym-aware; neither sees the other.
That is why the normalisation stays on the mean rather than dividing the
data: dividing the data would make space parametric, with a
ρ-dependent Jacobian and ρ-dependent error propagation, and in linear
space it is the data-side normalisation that Peelle’s Pertinent Puzzle
warns about (recipe 27).
2.8 problem.py — the compile step#
class Problem:
def __init__(self, constraints, priors=()): # priors: [(params, joint), ...]
index: ParameterIndex # slot(p), slots(ps), names, bounds, ndim
constraints: tuple[CompiledConstraint, ...]
priors: tuple # the (params, joint) pairs as given; reuse for a held-out Problem
ndim: int
names: list[str]
bounds: ndarray # (ndim, 2)
def log_prior(self, theta) -> float
def log_likelihood(self, theta) -> float # sum_c c.weight * c.log_likelihood(theta)
def log_posterior(self, theta) -> float # prior first; -inf short-circuits the forward model
def prior_transform(self, u) -> ndarray # unit cube -> theta (dynesty, bbb)
def sample_prior(self, n, rng=None) -> ndarray # (n, ndim)
def predict(self, theta, physical=False) -> list[list[ndarray]] # per constraint, per block
def chi2(self, theta) -> float
def log_jacobian(self) -> float # sum over constraints, active points
def columns(self, params) -> ndarray # chain columns of these parameters
# black-box-bayes spellings of the same six things
NDIM, parameter_names, starting_location(n), log_posterior_batch(thetas)
Problem.__init__ is the only place that walks the graph. It produces
index, the compiled constraints, and the assembled prior. Compile the
same declarations twice and you get two independent problems; nothing
user-facing is mutated.
def compile(constraints, priors):
index = ParameterIndex() # ordered: Parameter -> slot, first seen
compiled = []
for c in constraints:
y, y_err, offsets = stack(c.comparisons) # comparison space, one slice per comparison
active = concatenate(offsets[i][mask_i] for each block)
preds = [(offsets[i], index.add_all(b.predictor.params), b.predictor) for i, b in ...]
terms = ([statistical(comp.y_err, on=comp) for comp in c.comparisons] if c.statistical else []) + list(c.terms)
resolved = [(resolve(t.on, c.comparisons, offsets), index.add_all(t.params), t) for t in terms]
like_gather = index.add_all(c.likelihood.params)
cov = StructuredCovariance(resolved, offsets, active, blocks=c.comparisons)
cov.factor_constant_parts() # eager; names the block on failure
compiled.append(CompiledConstraint(...))
index.check_names_unique()
prior = assemble_prior(index, priors) # every slot covered exactly once
return index, tuple(compiled), prior
index.add_all(params) returns the gather array for a node, adding unseen
parameters in first-seen order (block predictors, then terms, then the
likelihood, constraint by constraint). That is the whole routing story.
Prior assembly. Each slot is covered by its parameter’s marginal
(§2.1) or by exactly one joint block from priors. A joint block is
(params, joint) where joint is any object with logpdf(values) over
those parameters in that order, optionally prior_transform(u) and
rvs(n); scipy.stats.multivariate_normal qualifies. A hyperprior is a
joint block that includes its hyperparameter: the children carry no
marginal, the block’s logpdf is sum_i log p(child_i | hyper) + log p(hyper),
and its prior_transform draws the hyperparameter first (recipe 24). log_prior sums
marginal log-densities and joint logpdfs. prior_transform maps each
marginal slot through its rescaled ppf; a joint scipy.stats.multivariate_normal
block is whitened, theta = mu + L Φ⁻¹(u) with L Lᵀ = cov; any other joint
must expose prior_transform(u) or Problem.prior_transform raises a clear
error naming it. A parameter with finite bounds inside a joint block is
truncated in log_prior only (-inf outside the bounds); a truncated joint
has no unit-cube map, so prior_transform raises for that problem and names
the parameter. sample_prior draws marginals and joints and scatters them
into columns.
CompiledConstraint is today’s Constraint plus ConstraintCovariance
minus the parameter splitting:
class CompiledConstraint:
y, y_err, x: ndarray # stacked, comparison space, all points
offsets: tuple[slice, ...]
active: ndarray
predictors: list[(slice, gather, Predictor)]
covariance: StructuredCovariance
likelihood: Likelihood; like_gather: ndarray
weight: float
log_jacobian: float
def ym(self, theta) -> ndarray
def log_likelihood(self, theta) -> float
def chi2(self, theta) -> float
def matrix(self, theta) -> ndarray # dense, active rows; display and tests
2.9 covariance.py — the structured covariance#
The three term kinds already describe a decomposition:
Σ = diag(D) + blockdiag(M_b) + U Uᵀ (+ dense fallback)
D: length-Nvector, the sum of squares of every"diag"term.M_b: one dense block per observation block, the sum of"matrix"terms whose support lies inside blockb.U:N × r, one column per"mode"term, scattered onto its support and zero elsewhere. A mode spanning several blocks (case A) is a column with entries in several blocks.A
"matrix"term that crosses block boundaries (a GP kernel over the union of two datasets) forces the dense path for that constraint.
With B = blockdiag(M_b + diag(D_b)) and per-block Cholesky L_b:
z = L⁻¹ r (block by block)
W = L⁻¹ U (block by block, N × r)
S = I_r + Wᵀ W (r × r)
d2 = zᵀz − (Wᵀz)ᵀ S⁻¹ (Wᵀz)
logdet Σ = Σ_b logdet B_b + logdet S
Cost O(Σ_b n_b³ + N r² + r³) instead of O(N³). Consequences:
All three “known limitations” of
design.mdare gone: no dense assembly for block-diagonal covariances, a low-rank path exists, and masking is slicing rows out ofD,U, andM_bbefore assembly.The block path is the only path.
uses_block_path,block_diagonal,choleskyversusblock_cholesky, and the dispatch instacked_distancedo not exist.Each of
D,U,M_bis a constant part (evaluated once at compile) plus a parametric part. When everything is constant the factors are cached.A constraint boundary is now about the likelihood functional and the weight, not about cost: a coupling mode across blocks is essentially free.
Bmust be positive definite. A block covered only by modes is singular inBeven whenΣis not; compile reports it with the comparison’s label and the remedies (reported terms, a noise term,statistical=True).
class StructuredCovariance:
def __init__(self, resolved_terms, offsets, active, blocks): ...
def factor_constant_parts(self) -> None
def distance(self, y, ym, theta) -> tuple[float, float] # (d2, logdet) on active rows
def matrix(self, y, ym, theta) -> ndarray # dense, active rows
2.10 diagnostics.py and predictive.py#
Harvested statistics with the entry points retargeted to (problem, samples), where samples has shape (n, ndim) in problem.index order.
That is what emcee’s get_chain(flat=True), dynesty’s samples_equal(),
and bbb’s InferenceData["theta"] all give. Column selection goes through
problem.columns(...); split_samples, n_model_params, theta_cols are
gone.
# diagnostics.py
predictive_draws(problem, samples, constraint=0, *, n_rep=1, rng=None, model_only=False)
coverage_curve(draws, y, levels=None); coverage_error(draws, y, levels=None)
sharpness(draws, percentiles=(16, 84), transform=None)
heldout_log_predictive(heldout_problem, samples) # Problem([fit.complement()], priors=...)
log_posterior_predictive(logp_samples, logw=None)
logz_summary(logz, logzerr); compare_logz(a, b, sigma=2.0)
# predictive.py
gp_posterior_predictive(kernel, theta, X_train, residuals, X_pred, *, train_noise_var=None, jitter=1e-10)
predictive_band(draws, levels=(16, 50, 84))
total_predictive_band(problem, term, predictor, x_pred, samples, *, noise_std=0.0,
train_noise_var=None, levels=(16, 84), n_draws=400, rng=None)
# term is the kernel Term; its columns and the predictor's come from problem.columns
3. Worked example: the α+Ca study shape#
Two datasets without reported errors, compared in log space, one noise magnitude shared between them (case B), one normalisation mode coupling them (case A), one latent scale per dataset, one held out below an angular cut, nested sampling, then held-out scoring.
import numpy as np, dynesty, rxmc as rx
from rxmc import terms as T, transforms as tf
from scipy import stats
d1 = rx.from_measurement(m1, reaction=reaction, quantity="dXS/dA")
d2 = rx.from_measurement(m2, reaction=reaction, quantity="dXS/dA")
omp = rx.reactions.ElasticXS("dXS/dA", central, spin_orbit, args_from_params, params=omp_params)
rho1 = rx.Parameter("log_rho_1", prior=stats.norm(0, 0.1))
rho2 = rx.Parameter("log_rho_2", prior=stats.norm(0, 0.1))
comp1 = rx.Comparison(d1, omp | tf.scale(rho1), space=tf.log)
comp2 = rx.Comparison(d2, omp | tf.scale(rho2), space=tf.log)
log_eps = rx.Parameter("log_eps", prior=stats.norm(-2, 1))
log_eta = rx.Parameter("log_eta", prior=stats.norm(-2, 1))
c = rx.Constraint(
comparisons=[comp1, comp2],
terms=[
T.noise(log_eps, on=comp1), # case B: one magnitude, two comparisons
T.noise(log_eps, on=comp2),
T.normalization(log_eta, on=[comp1, comp2]), # case A: one mode across both
],
likelihood=rx.StudentT(),
statistical=False, # no reported errors: the noise term is the diagonal
)
fit = c.masked_where(lambda x: x < cut)
held = fit.complement()
problem = rx.Problem([fit], priors=[(omp.params, stats.multivariate_normal(mu, cov))])
sampler = dynesty.NestedSampler(problem.log_likelihood, problem.prior_transform, problem.ndim)
sampler.run_nested()
res = sampler.results
samples = res.samples_equal()
heldout = rx.Problem([held], priors=problem.priors) # same Parameter objects
lp = rx.diagnostics.heldout_log_predictive(heldout, samples)
score = rx.diagnostics.log_posterior_predictive(lp)
logz = rx.diagnostics.logz_summary(res.logz[-1], res.logzerr[-1])
# to compare with a linear-space fit of the same data: logz_raw = logz + problem.log_jacobian()
The same problem under emcee:
import emcee
p0 = problem.sample_prior(32, rng=np.random.default_rng(1))
sampler = emcee.EnsembleSampler(32, problem.ndim, problem.log_posterior)
sampler.run_mcmc(p0, 5000)
samples = sampler.get_chain(discard=1000, thin=10, flat=True)
band = rx.predictive.predictive_band(
[omp.bind(x_fine, d1.meta)(*s[problem.columns(omp.params)]) for s in samples[::20]]
)
And under black-box-bayes, which needs a pickle and a six-line module:
# make_problem.py
import dill
with open("problem.pkl", "wb") as f:
dill.dump(problem, f)
# posterior.py
import dill
def init_posterior(path):
global P, NDIM, parameter_names
P = dill.load(open(path, "rb")); NDIM = P.ndim; parameter_names = P.names
def starting_location(n): return P.sample_prior(n)
def log_posterior(theta): return P.log_posterior(theta)
def log_likelihood(theta): return P.log_likelihood(theta)
def prior_transform(u): return P.prior_transform(u)
Compare with the current spelling of the same study: Observation(..., transform=log) per dataset, stacked_supports to place the terms,
per_observation_scaling([obs1, obs2]) with obs passed to three places,
Constraint(...), Evidence([...]), ParameterConfig twice with a
TruncatedNormalPrior because the MVN prior has no unit-cube map,
CalibrationConfig, and split_samples to read the chain back.
4. Capability map#
Every statistical capability a current notebook or test demonstrates, and its spelling in the new API. This table is the acceptance list for the rewrite: a capability is done when its row has a test.
capability (where it is demonstrated today) |
new spelling |
pinned by |
|---|---|---|
user-defined model |
|
test_model |
|
|
test_model |
statistical diagonal only; |
default |
test_problem |
unknown fractional / constant noise (systematic_err_demo, sampling_algos) |
|
test_terms::TestFactories |
inferred noise replacing reported statistics (prose today) |
|
test_constraint |
reported normalisation / offset as fixed modes (measurement_to_calibration) |
|
test_constraint::reported_terms, regression number |
free normalisation / offset nuisance (systematic_err_demo) |
|
test_terms |
fixed dense covariance; fixed diagonal (systematic_err_demo, normalization_inference gallery) |
|
test_terms::TestTermKinds |
case B: one parameter, two block-local terms (systematic_err_demo, correlated_observations) |
|
test_problem::sharing |
case A: one mode across blocks (correlated_observations) |
|
test_covariance::case_a, regression |
per-dataset Kennedy–O’Hagan scale ρᵢ (normalization_inference) |
|
test_model, test_constraint |
single global ρ (test only) |
|
test_model |
sampled mean discrepancy |
|
test_model (params order, shared parameter, |
multiplicative |
|
test_model ( |
hyperparameters shared across datasets, per-dataset values from |
one term per block with the same |
test_terms ( |
discrepancy correlated across energies: GP over (E, θ) (new) |
one |
test_covariance (dense fallback equals hand-built product kernel) |
unaccounted-for model error per data type, KDUQ (new, reference) |
|
test_terms (shared object gives one column per type) |
Peelle’s Pertinent Puzzle avoidance (new, reference) |
|
test_terms (data-built mode reproduces the |
stacking by leave-one-dataset-out (new, reference) |
|
test_diagnostics |
cut / modular posterior by multiple imputation (new, reference) |
stage-1 |
test_problem (stage-1 marginal unchanged) |
leave-one-experiment-out prediction (new, reference) |
block masks, |
test_diagnostics |
simulation-based calibration of the sampler (new, reference) |
|
test_problem (rank uniformity on the linear problem) |
emulator as |
recipe 32 |
test_terms (a term declaring model parameters receives them) |
MAP + Laplace (new, reference) |
|
test_problem |
global error scale and USU modes (new, reference) |
|
test_terms |
energy-dependent parameters (new, reference) |
per-block |
test_model |
discrepancy on a physical basis, Legendre (new, reference) |
|
test_terms |
correlated systematics between observables of one measurement (new, reference) |
two blocks, one constraint, spanning mode; recipe 37 |
test_covariance |
classic normal hierarchical model, BDA3 ch. 5 (new, reference) |
marginalised as |
test_problem (marginalised and non-centred agree on |
SafeBayes: learn the tempering exponent (new, reference) |
driver loop over |
test_problem ( |
hyperprior: per-dataset parameters with a sampled spread (new) |
joint block |
test_problem (children uncovered without the block; |
GP discrepancy in x / in momentum transfer with amplitude (gp_discrepancy, TestStudyForms) |
|
test_terms::TestStudyForms |
angle-growing noise, mode ∝ θ, |
same helpers with |
test_terms::TestStudyForms |
direct two-parameter |
|
test_terms |
Student-t with bounded ν; |
|
test_likelihood closed forms |
log-space comparison, delta-method errors, |
|
test_constraint::TestComparisonSpace |
masks, |
|
test_constraint::TestMask (partition, |
tempering, two spellings (overconfidence) |
|
test_problem |
joint MVN prior over the optical set (most notebooks) |
|
test_problem::priors |
truncated-normal / bounded independent priors (calibration_config_emcee_dynesty) |
|
test_problem::priors |
nested-sampling |
|
test_problem (round trip, finite at 0 and 1) |
emcee, dynesty, black-box-bayes drivers |
flat interface on |
test_problem::drivers |
EXFOR → dataset with unit conversion; per-angle Rutherford |
|
test_data (both conversion directions) |
elastic |
|
test_reactions |
(p,n) IAS channel (tests) |
|
test_reactions (Lane term) |
solver settings reach jitr |
|
test_reactions |
singular-covariance guard naming the dataset (measurement_to_calibration) |
compile-time in |
test_problem |
duplicate-name / same-object validation |
|
test_problem |
covariance heat-maps (normalization_inference, correlated_observations) |
|
test_covariance dense equality |
predictive draws, coverage, sharpness, evidence bookkeeping (tests) |
|
test_diagnostics |
GP conditioning; total predictive band (gp_discrepancy) |
|
test_predictive vs sklearn |
fine-grid plotting (every reaction notebook) |
|
test_model |
Four rows are new rather than ported. The sampled mean discrepancy: today a
model-side correction that depends on x cannot be written, because the
model transform sees only the prediction. The three hierarchical rows:
today a term cannot read its dataset’s metadata, kernel_term cannot share
hyperparameters between calls, and a prior cannot depend on a sampled
hyperparameter.
Dropped, deliberately: Walker, MetropolisHastingsSampler,
AdaptiveMetropolisSampler, BatchedAdaptiveMetropolisSampler, proposal,
marginal_log_likelihood, conditional_posterior, predict_parametric,
weighted_marginal_log_likelihood, CalibrationConfig, ParameterConfig,
Evidence, per_observation_scaling, identity keys, stacked_supports,
split_samples, likelihood_scaling, IndependentPrior,
TruncatedNormalPrior, the two Observation subclasses, n_dof.
5. Harvest table#
What comes across from src/rxmc on api_generalisation, by file.
Lift verbatim (about 1,100 lines)#
current file |
pieces |
destination |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Lift with edits#
current |
change |
|---|---|
|
→ |
|
→ |
|
→ compile error raised by |
|
take |
|
take |
|
→ per-slot map in |
|
one free |
|
→ |
Rewrite#
params.py (identity semantics, prior), the Term class body (state
removed), ConstraintCovariance → StructuredCovariance, constraint.py,
evidence.py + config.py → problem.py, physical_model.py →
model.py, the two observation classes → from_measurement + Model.bind.
Drop#
walker.py, param_sampling.py, metropolis_hastings.py,
adaptive_metropolis.py, proposal.py, IndependentPrior,
TruncatedNormalPrior, as_prior.
Tests to port by body#
The bodies below encode behaviour, not API, and port with renamed calls:
test_covariance.py:TestTermKinds,TestTermCoords,TestFactories(includingtest_old_observation_covariance_equivalence),TestKernelTerm,TestStudyForms(every α+Ca error-model form against a hand-built dense matrix),test_custom_term_direct.test_likelihood_model.py: the closed-form Student-t andChi2values.test_constraint.py:TestComparisonSpaceTransform(delta method,log_jacobian == -Σ log y,-inflikelihood and+infchi2 for a non-positive prediction),TestMask(complement partition,ll(fit) + ll(held) == ll(full), shared parameters),TestSingularCovarianceGuard,TestSharedParameterCaseB,TestStackedConstraint(case A differs from the independent spelling).test_observation.py:test_systematic_terms_propagated_by_delta_method, offset-then-normalisation order, zero magnitudes skipped.test_regression.py: all three pins, including1.195784087817536.test_model_comparison.py,test_predictive.py(GP matches sklearn’sGaussianProcessRegressor).test_reaction_observation.py: both Rutherford conversion directions,TestSolverSettingsForwarding,TestSharedUnits.test_reaction_models.py: finite non-negative cross sections through real jitr solves; the Lane-term note for the IAS test.test_config.py::test_black_box_bayes_interface,test_priors.py::TestUnitCubeClipping.
New tests the old suite could not have: dense-versus-structured equality on
every TestStudyForms form, with masks, with a cross-block kernel (dense
fallback), and with a mode-only block (singular B); a parameter shared
across two constraints; a dill round trip of an elastic Problem with
equal log_posterior; MVN prior_transform round trip and finiteness at
exactly 0 and 1; Constraint.weight scaling the likelihood only.
The ported bodies are unit tests of modules. The recipe tests under
test/recipes/ are the acceptance suite, and §9 lists which recipes each
milestone unlocks. Recipe tests use synthetic data and the generic Model
wherever the recipe does not require a reaction model, so they stay fast;
reaction recipes patch set_up_solver as the current
test_reaction_observation.py does, except for one real-solve smoke test.
6. Repository layout and budget#
rxmc/
__init__.py re-exports; nothing else
params.py ~40 transforms.py ~230 units.py ~60
data.py ~180 model.py ~120 terms.py ~380
likelihood.py ~90 constraint.py ~170 covariance.py ~260
problem.py ~320 diagnostics.py ~250 predictive.py ~200
reactions/
__init__.py
elastic.py ~170 ias.py ~120
test/ unit tests, one file per module, plus test_regression.py
test/recipes/ one file per recipe in docs/recipes.md (~38), the acceptance suite;
oracle.py holds the closed-form linear-Gaussian posterior used by the fast tier
examples/ 9 notebooks (§7)
docs/ design.md rewritten from this document once the code lands
Runtime dependencies: numpy, scipy, pint, jitr>=3.0,
exfor-tools. pandas and scikit-learn leave requirements.txt
(neither is imported; kernels stay duck-typed and sklearn moves to the
examples extra). Extras: examples (emcee, dynesty, corner, matplotlib,
scikit-learn, dill, jupyter), validation (examples + pytest, nbmake,
ruff, black, isort). Python ≥ 3.12. Validation is the current contract:
ruff/black/isort on src test, nbqa on examples, pytest test, then
pytest --nbmake examples. pyproject registers the slow marker and
deselects it by default (addopts = -m "not slow", §9).
7. Notebooks#
Nine notebooks, each naming the current one it inherits. Every notebook is driven by emcee or dynesty.
notebook |
inherits |
driver |
new content |
|---|---|---|---|
|
linear_calibration_demo |
emcee |
prior predictive, posterior, predictive band with |
|
systematic_err_demo |
emcee |
the five-model ladder; two-constraint section with case B via shared |
|
normalization_inference |
emcee |
ρᵢ as |
|
correlated_observations |
emcee |
case A vs B, toy and n+⁴⁰Ca |
|
gp_discrepancy |
emcee |
|
|
robust_likelihoods |
emcee |
Student-t vs Gaussian; ν bounded on the |
|
measurement_to_calibration + 30s_optical_potential_calibration + the tempering/coverage section of overconfidence |
dynesty |
|
|
new (the |
dynesty |
log space, |
|
new (recipes 24, 35, 38) |
dynesty |
hierarchy on the physics parameters; see below |
hierarchical_calibration in detail. The truth is
y = a0(E) + a1(E) x + a2(E) x², measured by J synthetic datasets at known
energies E_j (in meta) plus one held-out dataset at a new energy. The
true coefficient mappings a_k(E) are a smooth trend plus non-monotonic
bumps. Three fits of the same data:
the correct mapping form with global
φ;a misspecified smooth mapping
a_k(E; φ) = φ_k0 + φ_k1 Ewith globalφ;the same smooth mapping plus a per-dataset deviation vector in parameter space, non-centred
δ_j = τ ⊙ η_j,η_jk ~ N(0, 1),τ_k ~ HalfNormal, so the inter-dataset covariance is diagonal with a learned spread (a full covariance through an LKJ-style joint block is the named extension).
Mechanics: one Model per block closing over E_j (recipe 35). The
held-out block is fully masked, so its η_new is sampled from the prior
only, driven by τ; complement(), predictive_draws and
heldout_log_predictive then score the new energy with no extra code. The
notebook states this design property explicitly. Plots: the coefficient
mappings against E with the truth; per-dataset residuals for case 2;
empirical coverage curves in-sample and on the held-out energy for all
three cases; sharpness; the posterior of τ; the held-out log predictive
per case. Expected: case 1 covers in and out of sample; case 2
under-covers both and shows structured per-dataset residuals; case 3
recovers coverage with wider, longer-tailed bands at the new energy (a
scale mixture over τ), a τ posterior away from zero, and the best
held-out score of the misspecified pair. Caveat stated in the notebook:
with few datasets φ and δ_j trade off, and the hyperprior on τ is
what resolves it.
Dropped: sampling_algos (in-package samplers), calibration_config_emcee_dynesty
(every notebook is now this), overconfidence as a standalone.
8. Gaps: what exists nowhere today#
G1
ParameterIndexand compile (problem.py): first-seen slot assignment,on=resolution against comparisons and datasets,masks→active, name uniqueness, prior coverage. About 120 lines.G2 Prior assembly: marginal truncation to
bounds, uniform default, the joint-block protocol including hyperprior blocks, joint blocks,log_prior,prior_transform(rescaledppf; MVN whitening; other joints must supply their own),sample_prior. About 100 lines.G3
StructuredCovariance: new linear algebra. Verified against the dense matrix on everyTestStudyFormscase, with masks, with a cross-block kernel, and with a mode-only block. About 260 lines.G4 Stateless
Term,Comparison,Constraintmasks: includingreported_termsunder a comparison transform and the complement partition property.G5 Rutherford in
from_measurement: resolved, it is a closed form ofreaction.kinematics(Elab); no workspace needed.G6
dillpicklability: a test round-trips an elasticProblemand compareslog_posterior. If a jitr workspace does not pickle,Predictor.__reduce__rebuilds it from(model, x, meta); the factory closures interms.pypickle underdillas they are.G7 Analysis on
(problem, samples):predictive_draws,heldout_log_predictive,total_predictive_bandselecting columns viaproblem.columns.G8 The α+Ca and hierarchical notebooks and the bbb
posterior.pyshim.G9 Docs:
design.mdrewritten from this document; API reference regenerated; README quickstart in the new spelling.
9. Milestones#
Each step is testable on its own in the new repository. Every milestone lists the modules it delivers, the unit tests it ports, and the recipe tests it unlocks: a recipe test lands in the first milestone where every object it uses exists. A milestone is done when its unit tests and every recipe test it unlocks pass.
Bootstrap. A branch
rewriteof this repository, cut frommainafter the 0.x close-out (§11), developed in a git worktree at~/el/rxmc-ngwith its own virtual environment (jitr>=3.0from PyPI, Python ≥ 3.12). Its first commit removessrc/,test/andexamples/and keepsdocs/(this document andrecipes.mdare the plan of record) and the configuration. Files that move mostly intact (transforms,likelihood,units, the term factories) come over bygit mvin a commit of their own before any edit, so blame survives the harvest. Then: apyprojectin the current shape with theslowmarker registered and deselected by default; ruff, black and isort configuration carried over; the CI workflow (fast tier on pull requests,pytest -m slowand nbmake on a schedule); a trusted- publishing workflow that uploads to PyPI on tag push.params,transforms,units,likelihood,terms(verbatim harvest plus the statelessTerm). Ported:TestTermKinds,TestTermCoords,TestFactories,TestKernelTerm,TestStudyForms(term values against hand-built matrices), the closed-form Student-t andChi2,TestUnitCubeClipping,TestSharedUnits. Recipe tests unlocked: none end to end (there is noProblemyet). The term-level halves of recipes 19 (fixed-array shape and symmetry checks, a custom callable) and 27 (anormalizationmode evaluates fromc.ym, a data-built mode fromc.y) are written here as unit tests and reused by those recipe tests later.data(withoutfrom_measurement),model(generic andpolynomial),constraint(Comparison,Constraint, masks,reported_terms). Ported:TestComparisonSpaceTransformat block level, delta-methodreported_terms,TestMaskconstruction and thecomplementpartition of active sets, name and shape validation. Recipe tests unlocked: none (no likelihood without the covariance).covariance. New: dense-versus-structured equality on everyTestStudyFormsform, with masks, with a cross-block kernel (dense fallback), and with a mode-only block (singularBerror). No recipe tests yet.problem: index, compile, priors, flat interface; emcee and dynesty smoke tests on the linear problem;dillround trip. The acceptance suite starts here, all on synthetic data with generic models. Recipe tests unlocked:core: 1, 2, 4, 5, 6, 8, 9, 10, 11, 12, 13, 16, 19, 20, 21;
hierarchy and sharing: 22, 23, 24, 38;
driver loops needing only
log_likelihood,log_prior,sample_prior: 25 (SafeBayes), 26 (KDUQ weights), 27 (Peelle), 29 (cut posterior), 32 (an emulator term sees the model’s parameters), 33 (MAP and Laplace), 34, 35, 36.
Regression pins carried here:
1.195784087817536, the old-covariance equivalence,ll(fit) + ll(held) == ll(full), and “a parameter on a fully masked block is sampled from its prior”, which recipe 38 and the hierarchical notebook rely on.reactions/elastic,reactions/ias,from_measurement. Ported: both Rutherford conversion directions,TestSolverSettingsForwarding, real-solve smoke tests, the Lane-term IAS check. Recipe tests unlocked: 3, 14, 15, 37; the reaction variants of 7 and 22 (momentum-transfer coordinates, an energy-running amplitude) run against a patched solver.diagnostics,predictive. Ported: GP-versus-sklearn,predictive_drawscovariance recovery,heldout_log_predictive,logz_summaryandcompare_logz. Recipe tests unlocked: 7 (total_predictive_bandfinds the kernel columns itself), 17, 18, 28, 30, 31.CI wiring. The heading-to-file check between
recipes.mdandtest/recipes/;pytest testruns both suites; the fast tier must finish in a few minutes on a laptop (patched solvers, smallJandn).Notebooks 1–9. Each notebook names the recipes it is the tutorial for:
linear_calibration(1, 17);error_models(2, 4, 5, 19);normalization_and_covariance_structure(3, 6, 27);correlated_observations(5, 37);gp_discrepancy(7, 8, 36);robust_likelihoods(9, 34);measurement_to_calibration(12, 14, 15, 16, 21, 26);alpha_ca_error_model_comparison(10, 11, 13, 18, 25);hierarchical_calibration(22, 24, 35, 38, and 30 for the held-out energy). A recipe without a notebook is fine; a notebook must cite at least one recipe.design.mdand README rewritten from this document.
Fast and converged tiers#
Recipe tests must be fast in CI, but some expected behaviours only hold for a converged sampler. The resolution is two tiers with a strong preference for assertions that need no sampler at all.
Prefer sampler-free assertions. Most expected-behaviour bullets are structural or analytic: names and columns, a covariance equal to a hand-built matrix,
chi2identities,ll(fit) + ll(held) == ll(full), a mode built fromc.ym,prior_transformround trips, compile-time errors. These are exact and form the core of every recipe test.Linear-Gaussian oracle.
test/recipes/oracle.pycomputes the closed-form posterior and predictive of any recipe instantiated with a linear model and Gaussian terms. Tests comparelog_posteriorandpredictive_drawsstatistics against it without sampling. This covers the coverage-type claims of recipes 1, 6, 12, 17, 26 and the marginalised form of 38 exactly.Seeded short chains, qualitative assertions. Ordering claims (“case 2 under-covers and case 3 recovers”; “the Student-t covers the truth and the Gaussian does not”) use a seeded 16-walker, few-hundred-step emcee run on a toy problem and assert only the ordering, with a wide margin.
Converged tier. Tests marked
slowhold the numeric claims (coverage within 0.05 of nominal,τrecovered within its interval, evidence differences), record the seed, R-hat and effective sample size so a failure is diagnosable, and run the notebooks through nbmake. They are deselected by default; pull-request CI runs the fast tier and a scheduled job runspytest -m slow.Rules. The fast tier has a budget of a few minutes and zero tolerated flakiness. A flaky fast assertion is demoted to
slow, never loosened until it passes. Every recipe test file has at least one fast test; a slow test is optional and holds the numbers. Which tier pins each bullet is decided per recipe as the implementation lands.
10. Open questions#
Term-level partial masks. The factories accept
mask=for a partial support today.on=(block, point_mask)would make it a first-class support form; whether that is worth the extra spelling is a matter of taste. v0 keepsmask=on the factories.Workspace caching across datasets at one energy. Correct without it; a factor of a few in setup time with it. Not in v0.
Cross-constraint modes.
Uis per constraint so that constraints stay independent and weights stay meaningful. A mode that couples two constraints is, as today, a reason to merge them.Known non-goals. The closing section of
recipes.md, “What this API does not express”, lists the calibration classes the skeleton rules out (non-elliptical likelihoods, chain-dependent masks, per-point latents, per-point likelihood factors, mixture likelihoods) with the size of the addition each would need. Revisit when a study needs one.
11. Release path: from 0.x to 1.0 in the same repository#
The GitHub repository, its pull-request history, collaborator branches and
Pages documentation are kept. rxmc is not on PyPI, so the name is free
and version history there starts at 1.0.
Close out 0.x. Merge
api_generalisationintomainby pull request. Tag the mergev0.1.0and add a branchlegacy/0.xat the same commit: the last state of the old design, with this plan in its tree, reachable by name forever.Rewrite on a branch.
rewriteis cut from thatmain(§9, milestone 0). History stays linear; no orphan branch and no force push. The README onmaincarries a one-line banner pointing at the branch while the rewrite is in progress.Pre-releases by tag. setuptools_scm reads the version from tags. Tag
v1.0.0a1after milestone 4,v1.0.0b1after milestone 6,v1.0.0rc1after milestone 8; each tag push publishes to PyPI as a pre-release (installable withpip install --pre rxmc). Publishinga1early claims the PyPI name; try TestPyPI once first.Release. Pull request
rewriteintomain, ordinary merge, tagv1.0.0, GitHub Release, PyPI publish, Pages rebuild frommain. The README then notes that 0.x lives atv0.1.0andlegacy/0.x.Old branches. Collaborators’ branches are left alone; superseded ones may be deleted after 1.0.