Program types#

A tree or a source string is a genotype, and generating one is solver territory. Core’s job is narrower: declare the space through a signature, a primitive vocabulary and a depth budget; validate submitted values against that declaration; and carry them through every existing surface without ever generating or evaluating one.

Declaring a symbolic parameter#

.symbolic() takes a Signature, a primitive vocabulary and a maximum depth. Values have the shape {"ast": <node>, "source": <str>}, where "source" is optional.

import designspace as ds

SIGNATURE = ds.Signature({"step": int, "total": int}, float)
PRIMITIVES = ["cos", "pi", "/", ds.Primitive("*", 2)]

SCHEDULE = {
    "ast": {
        "op": "cos",
        "args": [
            {
                "op": "*",
                "args": [
                    {"op": "pi", "args": []},
                    {"op": "/", "args": [{"var": "step"}, {"var": "total"}]},
                ],
            }
        ],
    },
    "source": "cos(pi * (step / total))",
}

space = ds.space(
    ds.param("schedule").symbolic(SIGNATURE, PRIMITIVES, max_depth=4).default(SCHEDULE),
    ds.param("acceptance")
    .code(
        ds.Signature({"delta": float}, bool),
        description="Metropolis acceptance criterion",
        examples=[{"delta": -1.0}],
    )
    .default({"source": "delta < 0"}),
)
space.params["schedule"].domain.max_depth
4

.code() declares freeform source with the shape {"source": <str>}. Its description, constraints and examples are declared, serialized, fingerprinted metadata for a consumer’s own backend.

space.params["acceptance"].domain.description
'Metropolis acceptance criterion'

Structural validation#

Core checks the tree’s structure: the vocabulary this parameter declared, arity where a Primitive declares one, variable names drawn from signature.args, literal bounds, and depth within max_depth. It assigns no meaning to a primitive name and ships no evaluator.

space.validate({"schedule": SCHEDULE, "acceptance": {"source": "delta < 0"}}).valid
True

An operator outside the declared vocabulary is rejected:

space.validate_param("schedule", {"ast": {"op": "sin", "args": []}}).param_errors
(schedule: out_of_bounds (value={ast: {op: 'sin', args: []}}),)

So is a tree past the depth budget:

deep = {"ast": {"op": "pi", "args": []}}
for _ in range(5):
    deep = {"ast": {"op": "cos", "args": [deep["ast"]]}}
space.validate_param("schedule", deep).param_errors
(schedule: out_of_bounds (value={ast: {op: 'cos', args: [{op: 'cos', args: [{op: 'cos', args: [{op: 'cos', args: [{op: 'cos', args: [{op: 'pi', args: []}]}]}]}]}]}}),)

And a variable the signature does not declare:

space.validate_param("schedule", {"ast": {"var": "epoch"}}).param_errors
(schedule: out_of_bounds (value={ast: {var: 'epoch'}}),)

Open vocabulary, checked arity#

A bare string names a primitive with no arity attached, so it structurally accepts any number of arguments. ds.Primitive(name, arity) pins one, as an exact integer or a (lo, hi) range.

three_args = {"ast": {"op": "+", "args": [{"var": "step"}] * 3}}

open_arity = ds.space(ds.param("e").symbolic(SIGNATURE, ["+"], max_depth=2))
pinned = ds.space(
    ds.param("e").symbolic(SIGNATURE, [ds.Primitive("+", 2)], max_depth=2)
)
(
    open_arity.validate_param("e", three_args).valid,
    pinned.validate_param("e", three_args).valid,
)
(True, False)
assert open_arity.validate_param("e", three_args).valid
assert not pinned.validate_param("e", three_args).valid

Generativity#

.code() is always non-generative, since no sampler= form exists. .symbolic() is non-generative unless sampler= is given.

bare = ds.space(ds.param("e").symbolic(SIGNATURE, ["cos"], max_depth=2))
bare.has_nongenerative_params
True
try:
    bare.sample_one(seed=0)
except ds.SamplingError as exc:
    print(exc)
param 'e': non-generative 'symbolic' param has no sampler and no .default() to materialize from

A .default() satisfies that obligation, and so does freeze:

bare.freeze(e={"ast": {"op": "cos", "args": []}}).sample_one(seed=0)
{'e': {'ast': {'op': 'cos', 'args': []}}}

Supplying a sampler= makes the parameter generative:

generative = ds.space(
    ds.param("e").symbolic(
        SIGNATURE,
        ["cos"],
        max_depth=2,
        sampler=lambda rng: {"ast": {"op": "cos", "args": []}},
    )
)
generative.has_nongenerative_params, generative.sample_one(seed=0)
(False, {'e': {'ast': {'op': 'cos', 'args': []}}})

Unlike a .custom() parameter, a program parameter can be removed outright by .slice():

sliced = ds.space(
    ds.param("e").symbolic(SIGNATURE, ["cos"], max_depth=2),
    ds.param("x").real(0.0, 1.0),
).slice(e={"ast": {"op": "cos", "args": []}})
list(sliced.params)
['x']

Per-field opacity#

Core never calls Primitive.fn. Like validators and .symbolic()’s sampler, it rides the non-serializable set: to_json raises by default, and on_unserializable="mark" degrades just that one field in place rather than the whole parameter.

opaque = ds.space(
    ds.param("e").symbolic(
        SIGNATURE, ["cos"], max_depth=2, validators=[lambda ast: True]
    )
)
try:
    opaque.to_json()
except ds.SerializationError as exc:
    print(exc)
param 'e': symbolic() validators has no structural encoding; pass on_unserializable='mark' or 'drop'
opaque.to_json(on_unserializable="mark")["params"][0]["domain"]["validators"]
{'kind': 'opaque', '$opaque': True}

Where to go next#

Structural operations reshapes a space after it has been built.