Structured values#
Graphs, layouts, schedules and pipelines are values whose content is a structure rather than a number. Three mechanisms declare one, and they trade off the same quantity: the more of the structure a custom type owns, the less of it the library can see.
The question that selects one is where the invariant lives.
mechanism |
shape |
applies when |
|---|---|---|
parametric family |
a choice over named structures |
the structure is nameable |
primitive decomposition |
element lifts with per-element constraints |
constraints are local to elements |
custom type |
a constructive sampler owns the value |
invariants are global, or rejection is hostile |
A parametric family#
Where the structures of interest have names, enumerate them. The space stays entirely primitive, so every parameter retains a chart, a prior, and full introspection.
>>> import designspace as ds
>>> space = ds.space(
... ds.param("topology").choice(
... ring=ds.space(ds.param("size").integer(3, 12)),
... star=ds.space(ds.param("leaves").integer(2, 10)),
... ),
... )
>>> ds.variant(space.sample_one(seed=0), "topology")
'star'
A parametric family costs the least of the three, and “the structure is nameable” holds more often than it first appears. A handful of named topologies usually covers the region a modeller cares about.
Primitive decomposition#
Where the structure is a collection of similar elements, lift a template over a count. The count can itself be a parameter, which is what makes the structure variable-length.
>>> space = ds.space(
... ds.param("n_layers").integer(1, 3),
... ds.param("layer").space(
... ds.param("units").integer(8, 64),
... ).repeat(ds.param("n_layers")),
... )
>>> config = space.sample_one(seed=0)
>>> config
{'n_layers': 2, 'layer': [{'units': 23}, {'units': 10}]}
Elements get indexed instance paths, which is how a per-element constraint or an error message names one:
>>> sorted(ds.flatten(config, space))
['layer', 'layer[0].units', 'layer[1].units', 'n_layers']
Primitive decomposition applies where the constraints needed are local, meaning they talk about one element, or about an element and its neighbour. A static count extends the reach: with the length known at resolution, the unrolled pairwise constraints can be machine-generated instead of written out.
The limit is rejection. Dense combinatorial constraints over elements, such as pairwise distinctness or conflict sets near a packing limit, collapse the acceptance rate. That is the signal to move to a custom type; see rejection.
A custom type#
Where the invariant is global, covering connectivity, pairwise spacing, or a feasibility property no per-element rule expresses, a custom type constructs the value instead of declaring it. The type carries its own sampler, so every value it returns is already valid.
The judgment a custom type demands is where to draw the ownership boundary. The rule: parameters coupled to the constructive invariant go inside the type, and independent payloads stay outside as primitive parameters.
That boundary matters because everything moved inside loses its chart and its prior. A graph’s edge set belongs inside, since getting it right is the whole job of the constructive sampler. A per-node learning rate is an independent payload, and keeping it primitive keeps it log-scalable, introspectable, and perturbable in u-space.
A property-driven lift count aligns the two:
>>> class GraphType:
... type_key = "graph"
...
... def validate(self, value):
... return len(value["nodes"]) >= 1
...
... def to_json(self, value):
... return value
...
... def from_json(self, data):
... return data
...
... def describe(self):
... return {"kind": "path"}
...
... def sample(self, rng):
... n = int(rng.integers(2, 5))
... return {"nodes": list(range(n)),
... "edges": [[i, i + 1] for i in range(n - 1)]}
...
... def properties(self):
... return {"n_nodes": int}
...
... def extract(self, value, prop):
... return len(value["nodes"])
>>> space = ds.space(
... ds.param("graph").custom(GraphType()),
... ds.param("node_lr").real(1e-4, 1e-1).repeat(ds.param("graph").prop("n_nodes")),
... )
>>> config = space.sample_one(seed=0)
>>> len(config["graph"]["nodes"]) == len(config["node_lr"])
True
Because sample cannot construct a disconnected graph, connectivity never
appears as a constraint and never contributes to rejection. That is what
“constructive” means here, and why a custom type holds up where rejection does
not.
.prop() reads a named property off the custom value, and using it as a count
keeps the payload list exactly as long as the structure the type built. A type
aligned this way must define a canonical ordering that is stable under JSON
round-trips. Without one, node_lr[2] names a different node after a save and
reload.
Limits of the expression language#
Value-dependent indexing (islands[edges[k].src]) and quantification over
dynamic ranges are permanently outside the expression language. Relational
semantics belong to a custom type or to the consumer.
Generative reparameterization is preferable to a measure-zero constraint. A
simplex declared as “n reals that sum to 1” has probability zero of ever being
sampled. Declared by stick-breaking it is primitive, chart-covered, and always
valid, with the manifold geometry carried in an Encoding instead of in a
constraint that rejection can never satisfy.
Custom types and representations#
A custom type that other parameters depend on through .prop() cannot later be
bridged away from custom with a Representation without dangling them. That
cuts against the custom-type advice above, which steers exactly the
bridge-worthy structures toward carrying properties.
The library offers no resolution here; the tension belongs to the modeller. Where a type is expected to be bridged later, either keep prop-driven alignment out of the space, or supply a bridge whose target is another custom type exposing the same properties.