Identity, serialization and solver hand-off#
Two questions face any consumer storing results: which space produced this number, and which point in it. This page answers both, then walks through the three shapes a solver hand-off takes. The running example is a NAS-shaped hyperparameter space.
import designspace as ds
space = ds.space(
ds.param("lr").real(1e-5, 1.0).log_scale(),
ds.param("weight_decay").real(1e-6, 1e-2).log_scale(),
ds.param("n_layers").integer(1, 5),
ds.param("width").integer(8, 256).log_scale().repeat(ds.param("n_layers")),
ds.param("optimizer").categorical("adam", "sgd"),
).forbid(ds.param("lr") > 0.5)
print(space)
Space: 5 params, 0 conditional, 1 constraints
lr real [1e-05, 1.0] log
weight_decay real [1e-06, 0.01] log
n_layers integer [1, 5]
width list count = n_layers, of integer [8, 256]
optimizer categorical {'adam', 'sgd'}
forbid lr > 0.5
Serialization#
to_json produces a plain dict, and Space.from_json reconstructs it.
doc = space.to_json()
sorted(doc)
['conditions', 'constraints', 'params', 'version']
doc["version"], len(doc["params"])
(1, 5)
version is the shared format integer, frozen since the space format was
fixed. The parameter entries carry the resolved declaration:
doc["params"][0]
{'path': 'lr',
'kind': 'real',
'domain': {'kind': 'real', 'lo': 1e-05, 'hi': 1.0},
'periodic': False,
'prior': {'kind': 'log'}}
restored = ds.Space.from_json(doc)
restored.fingerprint() == space.fingerprint()
True
A custom parameter needs a type_key to factory registry to reconstruct, since
core cannot know how to rebuild a user-defined type:
ds.Space.from_json(doc, custom_types={...}).
Fingerprints#
A fingerprint identifies a space. Equal fingerprints guarantee identical valid-configuration sets; unequal ones guarantee nothing, because identity is structural after desugaring rather than semantic.
space.fingerprint()
'1:full:203983b4d5e42ce9621e079ec29bd6bf07a3ef4e55b0f7088a489b3df9b7b5ba'
Two scopes are available. "full", the default, is document identity.
"sampling" covers only what fixes the feasible set, the measure and the chart
geometry, so a change to identity-level bookkeeping moves one and not the other.
tagged = space.meta(experiment="baseline")
(
tagged.fingerprint(scope="sampling") == space.fingerprint(scope="sampling"),
tagged.fingerprint(scope="full") == space.fingerprint(scope="full"),
)
(True, False)
Observation identity#
ds.config_hash is a configuration’s own stable key. The pair
(space.fingerprint(), config_hash(config, space)) identifies one observation
globally, and is what results should be keyed on.
config = space.sample_one(seed=0)
key = (space.fingerprint(), ds.config_hash(config, space))
key[1]
'b7e2ee4bb478c7893e5baa0002b5f465df7e07de5cf9935eb0180908183a6fc4'
The hash follows the configuration across a round-trip of the space:
assert ds.config_hash(config, space) == ds.config_hash(config, restored)
assert restored.validate(config).valid
Three shapes of hand-off#
designspace declares spaces. It does not search them, and it ships no
operators, no distances and no adapters. Pointing a solver at a Space is
therefore work for the consumer or the solver author, and it takes one of three
shapes.
Which shape applies depends on a fact about the solver rather than about the space: every solver defines the space it can work with. Base CMA-ES is ℝⁿ. Variants add integers and categoricals. SMAC and irace add conditionals. Others work on graphs.
Shape 1: interpret the Space directly#
A solver that understands the IR walks it. Topological order first, then activity from conditions, then the charts.
space.topological_order
['lr', 'weight_decay', 'n_layers', 'width', 'optimizer']
space.dependency_graph
{'lr': frozenset(),
'weight_decay': frozenset(),
'n_layers': frozenset(),
'width': frozenset({'n_layers'}),
'optimizer': frozenset()}
Every generative scalar parameter carries a chart, which is what gives a solver
type-appropriate perturbation with no per-type code: mutate in [0, 1], then
decode.
lr = space.params["lr"].chart
[round(lr.from_unit(u), 6) for u in (0.0, 0.5, 1.0)]
[1e-05, 0.003162, 1.0]
round(lr.to_unit(0.001), 4)
0.4
Capability negotiation is ordinary introspection. There is no protocol to implement: the solver checks what it needs and fails with its own message, since only it knows what it supports.
(
space.is_conditional,
space.has_variable_length,
space.is_finite,
space.has_nongenerative_params,
)
(False, True, False, False)
Shape 2: convert to a foreign representation#
Core ships no adapter for ConfigSpace or its kin, and takes no dependency on
one. The public, bidirectional IR is the socket: walk params, emit the foreign
declaration, and map back.
[(p, pd.type_kind) for p, pd in space.params.items()]
[('lr', 'real'),
('weight_decay', 'real'),
('n_layers', 'integer'),
('width', 'list'),
('optimizer', 'categorical')]
Shape 3: bridge with a Representation#
Where the solver’s genotype differs from the declared phenotype,
space.represent() builds the induced chart representation, derived
mechanically from the charts already on the declaration.
rep = space.represent()
rep.encoded
('lr', 'weight_decay', 'width')
n_layers is excluded and reported separately, because transport rewrites
conditions and constraints but never a count:
rep.excluded_by_prop
('n_layers',)
The target is an ordinary Space of unit-interval coordinates, so shape 1
applies to it unchanged. A bridge introduces no new vocabulary; it only moves
where shape 1 gets applied.
rep.target.params["lr"].domain
[0.0, 1.0]
genotype = rep.target.sample_one(seed=0)
genotype
{'lr': 0.6369616873214543,
'weight_decay': 0.2697867137638703,
'n_layers': 1,
'width': [0.016527635528529094],
'optimizer': 'sgd'}
rep.decode(genotype)
{'lr': 0.015304122616866646,
'weight_decay': 1.1999049779393521e-05,
'n_layers': 1,
'width': [8],
'optimizer': 'sgd'}
Decoding is guaranteed total: every genotype the target calls valid decodes to a phenotype the source calls valid.
phenotype = rep.decode(genotype)
assert space.validate(phenotype).param_errors == ()
assert rep.target.is_feasible(genotype) == space.is_feasible(phenotype)
The reverse round-trip is not a law, because an integer chart is many-to-one:
rep.encode(rep.decode(genotype)) == genotype
False
rep.check(n, seed) runs the conformance laws as a tool. It covers decode
totality, feasibility agreement, and, where invertible, the one-directional
round-trip. It returns a report and never raises.
result = rep.check(n=200, seed=1)
result.ok, result.n, result.failures
(True, 200, ())
Custom types negotiate per parameter#
A .custom() parameter is an open world, and it offers two independent
channels.
The generation ladder, where the richest available rung wins:
a native adapter that recognizes the type’s
type_key;a
Representationwhose target this solver can handle, with the geometry authored by the type author and the loss declared rather than silent;opaque
sample(rng), sufficient for random search and resampling moves.
The modeling channel, orthogonal to generation: properties() featurizes
values for surrogates and reporting whichever rung produced them, and
to_json/config_hash give observation identity. A type opaque to generation
can still be rich to modeling, because the two channels are independent.
Adapter conventions#
Strategy-entangled operations are the only things forced into adapters: crossover schemes, mutation policies, trust regions. When writing one:
key it by the same
type_keyused in serialization;give it the live
ParamTypeinstance and derive domain facts from it throughdescribe,validateandextract, instead of re-declaring them;pass it a
Representationinstead of embedding one;scope it per (capability, type). Scoping per (solver, type) multiplies adapters for no gain.
Where to go next#
The design notes cover the decisions behind these mechanisms, and the API reference documents every exported name.