Source code for examodels.recipe

"""Recipes: a core written against placeholders, given its data later.

A `Core` is normally built from data you already have, so the model and its data
are finished together. Ask for placeholders instead and the two come apart:

    core = exa.Core(nargs=2)
    N, x0 = core.args
    x = core.add_var(N, start=x0)
    core.add_obj(lambda i: (x[i] - 1)**2, over=exa.srange(0, N))

    model = exa.Model(core, 10, [0.0] * 10)      # one value per placeholder
    other = exa.Model(core, 50, [0.0] * 50)      # the same core, again

`exa.recipe(nargs=2)` is the same thing in one line, for when unpacking reads
better than a separate `.args`:

    core, N, x0 = exa.recipe(nargs=2)

The reason the concept exists is ahead-of-time compilation: it needs the
*structure* to become code while the *data* stays a run-time input. Writing a
model against placeholders is that separation, so a recipe that builds and
instantiates can be compiled without the author reasoning about what survives
trimming.

A placeholder is used *as the value it stands for* — `N` is the number of
variables, not a namespace to reach into. Arithmetic on it is deferred:
`N - 1` and `srange(0, N)` describe what to compute, and compute it when the
model is built.

What a placeholder cannot do is take part in Python control flow. Its value does
not exist while the model is being written, so `len(N)`, `int(N)`, `if N > 3`
and iteration all raise — anything of that kind belongs in the data you pass at
instantiation, or is computed by the caller beforehand.
"""

from . import _bridge as _b


class Arg:
    """A stand-in for one of the values supplied when the model is built.

    Obtained from `Core(nargs=...)`, never constructed directly. Supports the
    arithmetic needed to describe sizes and index sets; everything else raises,
    because the value is not known yet.
    """

    __slots__ = ("_jl",)

    def __init__(self, jl):
        self._jl = jl

    # ── arithmetic, deferred ────────────────────────────────────────────────
    #
    # Each operation builds a backend node rather than a number. The reflected
    # forms matter as much as the direct ones: `2 * N` is as natural to write as
    # `N * 2`, and a size expression like `3 * N - 1` uses both.

    def _op(self, name, other, flip=False):
        a, b = (_unwrap(other), self._jl) if flip else (self._jl, _unwrap(other))
        return Arg(_b.ops[name](a, b))

    def __add__(self, o):
        return self._op("+", o)

    def __radd__(self, o):
        return self._op("+", o, flip=True)

    def __sub__(self, o):
        return self._op("-", o)

    def __rsub__(self, o):
        return self._op("-", o, flip=True)

    def __mul__(self, o):
        return self._op("*", o)

    def __rmul__(self, o):
        return self._op("*", o, flip=True)

    def __truediv__(self, o):
        return self._op("/", o)

    def __rtruediv__(self, o):
        return self._op("/", o, flip=True)

    def __neg__(self):
        return Arg(_b.ops["-"](0, self._jl))

    def __pow__(self, n):
        return Arg(_b.ops["^"](self._jl, _unwrap(n)))

    # ── what a placeholder deliberately refuses ─────────────────────────────
    #
    # Each of these has a value only after instantiation. Raising here names the
    # problem where it is made; allowing them would produce a model whose shape
    # silently depended on a value nobody had supplied yet.

    def _refuse(self, what):
        raise TypeError(
            f"cannot {what} a placeholder: its value is not known until the model "
            f"is built. Compute it before building and pass it as one of the "
            f"arguments to `Model(core, ...)`, or use it symbolically — `N - 1` "
            f"and `srange(0, N)` are fine."
        )

    def __len__(self):
        self._refuse("take len() of")

    def __int__(self):
        self._refuse("convert")

    def __index__(self):
        self._refuse("index with")

    def __iter__(self):
        self._refuse("iterate")

    def __bool__(self):
        self._refuse("branch on")

    def __lt__(self, o):
        self._refuse("compare")

    __le__ = __gt__ = __ge__ = __lt__

    def __repr__(self):
        return f"<placeholder {_b.typestr(self._jl)}>"


class SRange:
    """A half-open index set whose bounds may be placeholders — `srange(0, N)`.

    Half-open like `range`, so `srange(0, N)` is `N` indices starting at 0. It
    exists because `range` requires integers: the moment a bound is a
    placeholder, Python cannot build the set, and the backend has to be told the
    bounds instead.
    """

    __slots__ = ("start", "stop")

    def __init__(self, start, stop):
        self.start, self.stop = start, stop

    def _jl(self):
        """The backend's index set: half-open here, inclusive there."""
        return _b.arg_range(_unwrap(self.start), _unwrap(self.stop))

    def __len__(self):
        raise TypeError(
            "an srange has no length until the model is built — its bounds are "
            "placeholders. Pass it as `over=` and let the backend size it."
        )

    def __repr__(self):
        return f"srange({self.start!r}, {self.stop!r})"


[docs] def srange(start, stop=None): """`srange(stop)` or `srange(start, stop)` — half-open, like `range`. Use it wherever an index set involves a placeholder; plain `range` stays correct everywhere else, and reads better. """ return SRange(0, start) if stop is None else SRange(start, stop)
def is_placeholder(x): """Whether `x` is a placeholder or an expression over one.""" return isinstance(x, Arg) def _unwrap(x): """The backend value behind a placeholder, or `x` itself.""" return x._jl if isinstance(x, Arg) else x
[docs] def recipe(nargs=1, **kwargs): """A `Core` and its placeholders in one go — `core, N = exa.recipe()`. Identical to `Core(nargs=...)` followed by unpacking `.args`; which reads better depends on the model, so both spellings exist. >>> import examodels as exa >>> core, n = exa.recipe() >>> x = core.add_var(n, start=0.0) >>> _ = core.add_obj(lambda i: (x[i] - 1.0) ** 2, over=exa.srange(0, n)) >>> exa.Model(core, 5).nvar 5 >>> exa.Model(core, 50).nvar 50 """ from .core import Core core = Core(nargs=nargs, **kwargs) return (core, *core.args)