Parameter Schemas in Depth
You met parameter structs and HOLOSCAN_PARAM_SCHEMA in
Typed parameters.
This page goes deeper: the complete field<> policy taxonomy, where each
policy takes effect — bind-time checks, live updates, snapshots and
persistence, or external tuning — and how a schema’s identity and version make
your parameters a stable contract rather than an incidental struct layout.
Everything here builds on the same authoring shape: a plain aggregate struct
with default member initializers, one HOLOSCAN_PARAM_SCHEMA declaration beside
it (same namespace, so argument-dependent lookup finds it), and an O(1),
allocation-free read via context.params<T>() inside compute().
Complete C++ example: examples/parameter_schemas/cpp
- The full set of
field<>policies — mutability, constraints, string and container bounds, custom validators, nesting, visibility, persistence, and documentation metadata. - Where each policy marker takes effect — bind-time constraint checks, live generation updates, snapshot/persistence projections, or the external tuning catalog — and which explicit opt-in gates each engine.
- How
param_schema(id, version)gives a parameter aggregate a stable identity independent of its C++ layout. - How parameter generations fit in, and where to read about live updates.
The policy taxonomy
Every parameter is declared with one field<&Params::member>("name") clause,
followed by a fluent chain of policies. Policies compose freely within a chain;
each one records intent, adds a constraint, or attaches metadata.
The member pointer is checked at compile time: field<&T::m> must name a real
member of a supported parameter type, and invalid policy combinations fail at
compile time rather than at run time.
Mutability
Mutability markers are last-one-wins within a chain: the final marker in the chain is the declared policy.
Value constraints
String and container bounds
.max_utf8_bytes(n) is a byte bound, not a character bound. A 64-byte
limit admits 64 ASCII characters but as few as 16 four-byte code points.
Size your bounds for the encoded byte length of realistic values, and pair
the bound with .encoding(kUtf8) — a byte bound on a string without the
UTF-8 encoding policy is rejected when the schema is lowered.
Custom validation
.validator<fn>("stable.id") attaches a process-local predicate — a
noexcept callable returning bool for a const Field& — plus a stable
string identity. The identity travels with the schema even where the callable
cannot (across processes and tooling), so external consumers can tell that
a named validation applies without being able to run it.
The callable must return exactly bool and be noexcept; both are enforced
with compile-time checks. Keep validators side-effect-free — they exist to
answer “is this value admissible?”, nothing more.
Nesting
.nested<NestedParams>() folds another declared parameter aggregate’s schema
into the parent field. The nested type must match the field type exactly and
must itself have a HOLOSCAN_PARAM_SCHEMA declaration; both are
static_assert-checked. The nested schema’s ID, version, and hashes
participate in the parent’s identity, so a nested aggregate stays a stable,
versioned unit rather than dissolving into loose fields.
Visibility and persistence
A .sensitive() field cannot be a dependency of a derived Tensor output-pool
capacity. That compile-time resource decision must read the generation-zero
value and publish concrete allocation geometry in the execution plan, which
would violate the field’s redaction contract. Use a separate non-sensitive,
compile-time or .recompile_required() capacity Parameter instead.
Documentation and migration
Where each policy takes effect
Every policy above is authored the same way, but they act at different points of the pipeline: at bind time when a value is admitted into a generation, in the live-update engines, in snapshot/persistence projections, or in the external tuning catalog. All of those engines ship today; the update engines sit behind explicit plan opt-ins.
The projection outcome for a field follows a fixed precedence: transient → sensitive → internal → profile → codec. A transient field is dropped before sensitivity is considered, a sensitive field is redacted before internality, and profile and codec rules apply last.
.sensitive() is redaction, not security. Snapshot, archive, and
projection redaction keeps a marked value out of saved and external
surfaces, but the value still lives in process memory and remains reachable
from your own code. Do not treat .sensitive() as protection for secrets at
rest or in transit.
The engines behind these markers ship with distinct availability:
- Runtime updates are compiled into Core, but
CompileOptions.parameters.runtime_updatesresolves its default (kProfileDefault) to disabled — a plan must opt in explicitly, and only the Standard and Research profiles admit active mutation. - Lifecycle-mutable updates are gated by
CompileOptions::lifecycle_transactions(the restart machinery), not by the runtime-updates policy. - Snapshot capture and projection/redaction are Core facilities, exposed
by
RunSession::capture_params()andParamControlHandle::capture(); they do not depend on optional parameter-persistence tooling. - Archive persistence through
params::save/params::loadbelongs to theparams_iolibrary, which ships withholoscan-core. - The web tuner is a separate experimental build component: Linux-only,
loopback-only, and built by default (
HOLOSCAN_BUILD_EXPERIMENTAL_PARAMS_WEB_TUNER) on top of the always-builtparamslibrary. It writes only admitted, adapter-eligible tuning fields.
A practical consequence: declare intent honestly. A .runtime_mutable() field
becomes live-tunable when its plan opts in, while a .sensitive() field is
redacted by Core snapshot/projection paths. params_io is needed only to save
or load archives.
Schema identity and versioning
The first clause of every HOLOSCAN_PARAM_SCHEMA is
holoscan::param_schema(stable_id, version):
stable_idis a globally unique, reverse-DNS-style string that names the schema independently of the C++ type name, namespace, or library it lives in. Renaming or moving the struct does not change the schema’s identity — only editing the ID does.versionis your declared compatibility counter. Bump it when the schema’s meaning changes: a field added or removed, a constraint tightened, a semantic shift in what a field means.
On top of the declared pair, the framework derives structural and contract hashes from the field layout and policies, so drift between “what the ID/version claim” and “what the fields actually are” is detectable. Nested schemas contribute their own ID, version, and hashes to the parent’s identity.
Two rules of thumb:
- Never reuse a
(stable_id, version)pair for a different shape. Tooling and configuration keyed by that pair will misinterpret values. - Use
.alias("old_name")for field renames within a version’s migration window — the old name stays accepted on input while the new canonical name is the identity going forward.
The silent-opaque footgun applies here too. If HOLOSCAN_PARAM_SCHEMA is
missing or placed in the wrong namespace, the type silently degrades to
opaque mode — no declared schema, no identity, no validation — instead of
failing to compile. You can check at compile time:
holoscan::param_schema_availability<T>() reports kOpaqueLocal for a type
with no declared schema. Keep the macro immediately beside the struct.
Generations, briefly
Every activation reads its parameters from one immutable generation.
context.params<T>() returns a const T& pinned to that generation for the
entire callback, and context.params_generation() returns its identity.
Generation 0 is the plan’s compile-resolved initial aggregate: defaults,
optionally replaced by an explicit typed OperatorHandle::set_params()
baseline, then sparsely patched by an admitted graph-owned parameter
transaction. It stays the only generation until an admitted update publishes; a
new value always arrives as a new generation observed by a later
activation — never as a mid-callback mutation. The full model — update seams,
lifecycle-mutable publication, observing generation changes, and ordering
guarantees — is covered in
Runtime parameter updates.
Parameters that size Tensor pools
A compile-time or .recompile_required() integral scalar or bounded integral
vector may feed a TensorByteSpanExpression declared by a runtime-pooled Tensor
output. For example,
parameter_contiguous_byte_span(param_ref<&P::shape>()) reduces a bounded shape
to a contiguous slot byte capacity using the output’s exact dtype and rank.
parameter_byte_span(param_ref<&P::bytes>()) uses a positive scalar byte count
directly.
These expressions are evaluated only while compiling a plan. A deployment can
bind a different generation-zero value with set_params() or a graph-owned
configuration transaction and compile a new plan with different finite pool
geometry. This compiles a new execution plan and rematerializes its resources
without rebuilding or relinking the application binary. The Operator’s setup
declaration remains unchanged. The value is not a live update: an already
materialized pool never grows or changes capacity between activations.
Use .max_elements(rank) on a vector shape field and declare the same exact
rank on the Tensor output. Compilation rejects an empty shape, a length
mismatch, a zero extent, an unsupported element type, or overflow rather than
guessing a capacity. See
Tensors in depth
for allocation-plan evidence and the scalar input-bound form.
Recap
field<>policies fall into seven groups: mutability, value constraints, string/container bounds, custom validators, nesting, visibility/persistence, and documentation/migration metadata.- Constraints (
range,allowed_values, encodings, validators, nesting) are enforced at bind time; mutability, tuning, persistence, and sensitivity markers are enforced by their shipped engines — live updates behind the explicit runtime-updates or lifecycle-transactions plan opt-in; snapshot capture and projection/redaction in Core; archive save/load through the core-bundledparams_iolibrary. param_schema(stable_id, version)decouples a schema’s identity from its C++ spelling; bump the version on meaning changes and use.alias()for field renames.- A missing or misplaced
HOLOSCAN_PARAM_SCHEMAsilently yields an opaque type; verify withparam_schema_availability<T>(). - Parameters are read per-activation from one immutable generation; updates
arrive as future generations — through the next compute boundary for
.runtime_mutable()fields or a lifecycle restart transaction for.lifecycle_mutable(stage)fields — never as mid-callback mutations. - Compile-time and
.recompile_required()fields may size finite Tensor pool slots without changingsetup(); a new value needs a new execution plan and rematerialized resources, not a rebuilt or relinked binary or a live update to an existing pool.
Next: Lifecycle callbacks.