BuildConstructors.jl is a small pattern for building Julia objects whose numerical
parameters need extra metadata: defaults, fixed/free state, bounds, uncertainties,
or names used by a fitting backend.
Trying to attach that metadata directly to user objects usually hits a wall. Some objects are immutable, some come from another package, some have no natural place for a default value, and some are not even parameterized in the way your workflow needs. The part that always works is to wrap the object construction instead:
- Store parameter descriptors in a constructor object.
- Collect or update the running parameters from that constructor.
- Call
build_model(constructor, pars)to create the real object.
The wrapped object can be anything: a distribution, a model, a callable, a nested
composition, or a domain-specific type from another package. BuildConstructors.jl
does not impose a dimensionality, a call signature, or a model interface. Those
choices stay with you.
using Pkg
Pkg.add("BuildConstructors")The essential pattern is small:
- Use parameter descriptors such as
Fixed,Running, or your ownAbstractParametersubtype. - Store those descriptors in an
AbstractConstructor. - Implement
build_model(constructor, pars).
Everything else is convenience:
| Layer | Essential? | Why it exists |
|---|---|---|
Fixed, Running, FlexibleParameter, AdvancedParameter |
Useful defaults | Common descriptor types for fixed/free parameters, defaults, bounds, and uncertainties. |
parameter_values, fix!, release!, update! |
Convenience | Recursive tools for collecting and mutating metadata in nested constructors. |
@with_parameters |
Convenience | Removes boilerplate when a constructor mostly maps parameter descriptors into a build_model body. |
serialize / deserialize / register! |
Optional | Save and restore constructor descriptions through JSON or database-like workflows. |
| PRB model constructors and loaders | Optional example | Domain-specific probability-model utilities built with the same general mechanism. |
If your object already has a perfect home for metadata, you may not need this package. It becomes useful when the object should remain clean, external, immutable, or domain-native, but your workflow still needs to know which numbers are fixed, running, bounded, initialized, or serializable.
Parameters are represented by small descriptor objects. A descriptor decides how a numerical value is obtained when the real model is built.
using BuildConstructors
Fixed(1.0) # always evaluates to 1.0
Running("scale") # reads `scale` from the supplied parameter valuesA constructor stores these descriptors instead of storing the numerical values directly:
using Distributions
struct ConstructorOfNormalModel{T1<:BuildConstructors.AbstractParameter,
T2<:BuildConstructors.AbstractParameter} <:
BuildConstructors.AbstractConstructor
description_of_μ::T1
description_of_σ::T2
end
function BuildConstructors.build_model(c::ConstructorOfNormalModel, pars)
μ = BuildConstructors.value(c.description_of_μ; pars)
σ = BuildConstructors.value(c.description_of_σ; pars)
return Normal(μ, σ)
end
c = ConstructorOfNormalModel(Fixed(0.0), Running("σ"))
parameter_values(c) # (σ = missing,)
model = build_model(c, (σ = 0.2,))This keeps the user object clean. Normal(0.0, 0.2) does not need to know that
σ was called "σ", was free in a fit, had a starting value, or came from a JSON
file. The constructor knows that, and the final object stays exactly the object you
wanted to build.
The package includes a few ready-to-use descriptors:
| Descriptor | Use |
|---|---|
Fixed(value) |
A constant value that is not collected as a named parameter. |
Running(name) |
A free parameter read from pars by name. |
FlexibleParameter(name, value) |
A parameter with a stored value that can be fixed or released. |
AdvancedParameter(name, value; boundaries, uncertainty) |
A parameter with a stored value, bounds, uncertainty, and fixed/free state. |
The same generic tools work recursively on constructors and nested constructors:
metadata = parameter_metadata(c)
parameter_values(c)
parameter_names(c)
running_names(c)
fixed_names(c)
parameter_uncertainties(c)
parameter_lower_boundaries(c)
parameter_upper_boundaries(c)
running_values(c)
fixed_values(c)
fix!(c, (:σ,))
release!(c, (:σ,))
update!(c, (σ = 0.25,))When names are duplicated, metadata preserves every entry, while projected collectors keep one key and use the last value.
You can define your own parameter descriptor by subtyping
BuildConstructors.AbstractParameter and implementing BuildConstructors.value.
Implement the other methods only if your descriptor needs to participate in fixing,
releasing, updating, or collection of metadata.
The main convention is:
build_model(constructor, pars)pars is deliberately unconstrained. It can be a NamedTuple, a
ComponentArray, or any object your parameter descriptors know how to read.
Likewise, build_model can return any Julia object. This is the central design
choice of the package: the constructor carries metadata and assembly logic, while
your returned object remains domain-native.
Nested construction is just ordinary Julia:
struct ConstructorOfScaled{C,T<:BuildConstructors.AbstractParameter} <:
BuildConstructors.AbstractConstructor
child::C
description_of_scale::T
end
function BuildConstructors.build_model(c::ConstructorOfScaled, pars)
child = build_model(c.child, pars)
scale = BuildConstructors.value(c.description_of_scale; pars)
return x -> scale * child(x)
endBecause the metadata collection methods walk over fields of
AbstractConstructors, running parameters inside child are collected together
with scale.
For many simple wrappers, the @with_parameters macro generates the constructor
type and build_model method for you:
using BuildConstructors
using Distributions
@with_parameters(Gauss; μ::P, σ::P, begin
Normal(μ, σ)
end)
c = ConstructorOfGauss(Fixed(0.0), Running("σ"))
model = build_model(c, (σ = 0.2,))The macro call has three parts:
- The model name,
Gauss. - A field list after the semicolon.
- A
begin ... endbody that returns the final object.
The generated type is named ConstructorOf{Name}. For Gauss, the macro creates
ConstructorOfGauss and a method equivalent to
build_model(c::ConstructorOfGauss, pars).
Field declarations have three forms, and the distinction is important:
| Form | Meaning |
|---|---|
field::P |
A parameter descriptor field, available in the body as the resolved value field. |
field::SomeType |
A constant field; in the body use bare field (bound from the constructor instance). |
field |
A parametric field (nested constructors, etc.); in the body use bare field. |
For field::P, the generated struct field is named description_of_field.
This keeps the constructor honest: it stores the parameter description, not the
current numeric value. During build_model, the macro inserts:
field = BuildConstructors.value(c.description_of_field; pars)For field::SomeType and plain field, the macro binds field = c.field before the body.
Every name in the field list is therefore available as a local variable in the body.
The name pars is separate from that list: it always refers to the second argument of
the generated build_model(c, pars), i.e. the caller-supplied parameter bundle. Forward
it unchanged when composing nested constructors (build_model(child, pars)) so inner
build_model methods see the same parameters.
For example:
@with_parameters(Scaled; child, scale::P, begin
child_model = build_model(child, pars)
x -> scale * child_model(x)
end)Here child can be another constructor, a callable, or any user object. scale
is a parameter descriptor, so the generated constructor is called as:
c = ConstructorOfScaled(child_constructor, Running("scale"))The generated field order is stable: plain parametric fields first, parameter descriptor fields second, and typed constant fields last. That means a mixed declaration such as:
@with_parameters(Windowed; model, μ::P, support::Tuple{Float64,Float64}, begin
truncated(build_model(model, pars), support[1] + μ, support[2] + μ)
end)is constructed as:
ConstructorOfWindowed(model, μ_descriptor, support)Use the macro when that generated shape is clear and useful. Write the constructor
and build_model by hand when you need a custom field order, extra validation,
special constructors, or a more explicit API.
Serialization is useful when constructor descriptions need to move through files,
databases, or fitting pipelines. The package provides serialize and deserialize
methods for its built-in descriptors and included example constructors. Custom
types can participate by defining their own methods and registering the type:
BuildConstructors.register!(ConstructorOfMyModel)Serialization is a bonus layer on top of the core pattern. You can use
constructors, parameter collection, fix!, release!, update!, and
build_model without using JSON at all.
The repository includes several constructors for probability-model workflows, including the physical-resolution-background composition used in the original application. They are examples of the same general mechanism rather than a restriction on what the package can build.
These examples and JSON/database helpers live in the PhysicsModelsExt package
extension. Install the weak dependencies in addition to BuildConstructors when
you want constructors such as ConstructorOfBW, ConstructorOfGaussian, or
load_prb_model_from_json:
using Pkg
Pkg.add([
"Distributions",
"DistributionsHEP",
"JSON",
"NumericalDistributions",
])Then load the extension dependencies before using the physics helpers:
using Distributions, DistributionsHEP, JSON, NumericalDistributions
using BuildConstructors
Phys = BuildConstructors.physics_models_extension()