Skip to content

math_spec.piecewise

Expand piecewise: blocks into plain variables and constraints.

A block becomes ordinary affine declarations when a caller asks :meth:~math_spec.model.Spec.expand for them, under names prefixed with the block's own; what each method emits is tabled in docs/reference/language/piecewise.md. Every rule a block is held to is decided as the model loads, before its rows are written: the names it references in :func:~math_spec.validation.reference_errors, its links and its where: as lowering types them, and the fit of each link's row to its expression, its values and the mask in :func:declaration_of. A refusal names the link or key the file wrote rather than an emitted declaration.

Emitted(name, lam, convexity, set, chord, domain_lo, domain_hi, links, assumptions) dataclass #

Every name one block's expansion may write, spelled once for the emitter and the collision check.

Every name is reserved whichever method the block declares: which method writes which is the method's business, and a collision is the file's either way. set holds the names a method that states a set writes through :func:math_spec.sos.emit.

assumptions instance-attribute #

by_kind property #

Each name by the kind of declaration it would collide with.

chord instance-attribute #

convexity instance-attribute #

domain_hi instance-attribute #

domain_lo instance-attribute #

lam instance-attribute #

name instance-attribute #

reused property #

Each link row whose name the block's own rows or variables already take.

rows property #

Every constraint the block writes for itself, its link rows aside.

set instance-attribute #

ungated property #

The second gate row, where the gate variable does not exist.

of(name, curve) classmethod #

The names block name writes, a link's row named after the link.

Source code in src/math_spec/piecewise.py
@classmethod
def of(cls, name: str, curve: PiecewiseDeclaration) -> Emitted:
    """The names block *name* writes, a link's row named after the link."""
    return cls(
        name,
        f'{name}_lam',
        f'{name}_convexity',
        sos.Emitted.of(name, 2),
        f'{name}_chord',
        f'{name}_domain_lo',
        f'{name}_domain_hi',
        tuple(f'{name}_{link.name}' for link in curve.links),
        (
            *(f'{name}_{what}' for what in _ASSUMED),
            *(f'{name}_{link.name}_complete' for link in curve.links if link.walks),
        ),
    )

written(method, *, ungated) #

The variables and constraints :meth:~math_spec.model.Spec.expand declares for a block of method.

ungated is :func:leaves_ungated of the block's gate. The set a sos2 or adjacency block states is written out too, since expand() writes every set.

Source code in src/math_spec/piecewise.py
def written(self, method: PiecewiseMethod, *, ungated: bool) -> tuple[str, ...]:
    """The variables and constraints :meth:`~math_spec.model.Spec.expand` declares for a block of *method*.

    *ungated* is :func:`leaves_ungated` of the block's gate. The set a
    ``sos2`` or ``adjacency`` block states is written out too, since
    ``expand()`` writes every set.
    """
    if method == 'lp':
        return (self.chord, self.domain_lo, self.domain_hi)
    convexity = (self.convexity, self.ungated) if ungated else (self.convexity,)
    restriction = (self.set.seg, self.set.pick, self.set.link) if method in ('sos2', 'adjacency') else ()
    return (self.lam, *convexity, *self.links, *restriction)

assumptions_of(name, curve, where) #

What block name assumes of its numbers, by the name the document prints and a refusal quotes.

Every curve assumes its breakpoints are there: a missing parameter row is not absence, it is a zero, so an undeclared breakpoint sits the curve on the origin rather than shortening it. A walked link's breakpoints are over its own rows, so each is asked of the rows that link reads the curve at, under a name of its own. A curve has an x-axis only where two links tie it, so the increasing condition — and the shape it is checked with — exist only there; lp alone needs a segment to state a line for; a ragged where: must mark one run.

Each condition is a where string over the parameters the file declared, so where is the block's where: as the file wrote it — a typed mask has no text — and curve says how each row reads it. The expansion writes the conditions into assumptions:, and a model that still declares the block derives the same text at load. Each is asked only where a curve runs, so the where: goes into every one of them: a model written out and read back holds the data to what the block did.

Source code in src/math_spec/piecewise.py
def assumptions_of(name: str, curve: PiecewiseDeclaration, where: str | None) -> dict[str, AssumptionBlock]:
    """What block *name* assumes of its numbers, by the name the document prints and a refusal quotes.

    Every curve assumes its breakpoints are there: a missing parameter row is
    not absence, it is a zero, so an undeclared breakpoint sits the curve on
    the origin rather than shortening it. A walked link's breakpoints are over
    its own rows, so each is asked of the rows that link reads the curve at,
    under a name of its own. A curve has an x-axis only where two links tie
    it, so the increasing condition — and the shape it is checked with —
    exist only there; ``lp`` alone needs a segment to state a line for; a
    ragged ``where:`` must mark one run.

    Each condition is a where string over the parameters the file declared,
    so *where* is the block's ``where:`` as the file wrote it — a typed mask
    has no text — and *curve* says how each row reads it. The expansion
    writes the conditions into ``assumptions:``, and a model that still
    declares the block derives the same text at load. Each is asked only
    where a curve runs, so the ``where:`` goes into every one of them: a
    model written out and read back holds the data to what the block did.
    """
    d = curve.along
    mask, frame, exists = _masks(where, d, ragged=curve.ragged)
    if mask is not None:
        rewrite = f'Bind the rows, or narrow where: {mask!r} to where the curve runs.'
    elif where is not None:
        rewrite = f"Bind the rows, or let where: {where!r} test '{d}' too, to say how far each curve runs."
    else:
        rewrite = 'Bind the rows, or declare where: to say how far the curve runs.'
    assumed: dict[str, AssumptionBlock] = {}
    if values := [link.values for link in curve.links if not link.walks]:
        assumed[f'{name}_complete'] = AssumptionBlock(
            holds=' AND '.join(dict.fromkeys(values)),
            where=where,
            description=f"piecewise '{name}': every breakpoint the curve runs through needs a row in "
            f'{_quoted(values)} — a missing row is read as a zero rather than as a shorter curve, so it sits '
            f'the curve on the origin. {rewrite}',
        )
    for link in curve.links:
        if link.walks:
            assumed[f'{name}_{link.name}_complete'] = AssumptionBlock(
                holds=link.values,
                where=through(where, link) if link.reads else _all_of(link.by, where),
                description=f"piecewise '{name}' link '{link.name}': every breakpoint the curve runs through needs a "
                f"row in '{link.values}' at every row the link reads the curve at — a missing row is read as a zero "
                f'rather than as a shorter curve, so it sits that row on the origin. {rewrite}',
            )
    curvature = _curvature_required(curve)
    if curvature is not None:
        x, y = (link.values for link in curve.curve)
        assumed[f'{name}_increasing'] = AssumptionBlock(
            holds=f'{_back(x, d, 1)} < {x}',
            where=_all_of(frame, _neighbours(d, mask)),
            description=f"piecewise '{name}': method: {curve.method} requires strictly increasing breakpoints "
            f"in '{x}' along '{d}'",
        )
        assumed[f'{name}_curvature'] = _bends(name, curve, x, y, curvature, mask=mask, frame=frame, exists=exists)
    if curve.method == 'lp':
        assumed[f'{name}_breakpoints'] = AssumptionBlock(
            holds=f'count({mask or curve.curve[0].values}, over={d}) >= 2',
            where=exists,
            description=f"piecewise '{name}': method: lp needs at least two breakpoints per curve — the method "
            f'*is* its segment lines, so a curve with no segment states nothing and leaves the bounded link on '
            f'its own bound. Use method: adjacency, sos2 or convex, which pin it to the points it does have.',
        )
    if mask is not None:
        assumed[f'{name}_contiguous'] = AssumptionBlock(
            holds=f'count({_edge(d, mask, "first")}, over={d}) == 1',
            where=exists,
            description=f"piecewise '{name}': where: {mask!r} must mark a consecutive run of at least one "
            f'breakpoint per curve — {_GAP[curve.method]}.',
        )
    return assumed

declaration_of(schema, name, pw, links, walks, where) #

Block name as the program carries it, with links, walks and where typed, every fit rule decided.

A walk reads the curve's weights at the block's own dims, so it consumes dims of dims:, joins on dims of dims:, and produces dims of its own. Each link's row is dims:, or its refinement through the link's walk; its expression carries exactly that row, its values parameter varies along it and the breakpoint dim and nothing else, and the where: tests dims: and the breakpoint dim alone. A walked row reads the where through its relation when the mask carries a dim the walk consumes. Decided here, on the link the file wrote, rather than on the emitted declarations, whose refusal would name <block>_lam — a variable the author never wrote.

RAISES DESCRIPTION
DimensionError

A walk that does not fit dims:, a link that does not fit its row, a where outside dims:, or a mask carrying part of what a walk reads through.

Source code in src/math_spec/piecewise.py
def declaration_of(
    schema: Spec,
    name: str,
    pw: PiecewiseBlock,
    links: tuple[Expression, ...],
    walks: dict[str, Direction],
    where: Mask | None,
) -> PiecewiseDeclaration:
    """Block *name* as the program carries it, with *links*, *walks* and *where* typed, every fit rule decided.

    A walk reads the curve's weights at the block's own dims, so it consumes
    dims of ``dims:``, joins on dims of ``dims:``, and produces dims of its
    own. Each link's row is ``dims:``, or its refinement through the link's
    walk; its expression carries exactly that row, its values parameter
    varies along it and the breakpoint dim and nothing else, and the
    ``where:`` tests ``dims:`` and the breakpoint dim alone. A walked row
    reads the where through its relation when the mask carries a dim the
    walk consumes. Decided here, on the link the file wrote, rather than on
    the emitted declarations, whose refusal would name ``<block>_lam`` — a
    variable the author never wrote.

    Raises:
        DimensionError: A walk that does not fit ``dims:``, a link that does
            not fit its row, a where outside ``dims:``, or a mask carrying
            part of what a walk reads through.
    """
    ctx = f"piecewise '{name}'"
    for key, walk in walks.items():
        _walk_fits(f"{ctx} link '{key}'", pw, walk)
    rows = {key: _row(schema, pw, walks.get(key)) for key in pw.links}
    for node, (key, row) in zip(links, rows.items(), strict=True):
        _link_fits(ctx, key, pw, dims_of(node, schema, f"{ctx} link '{key}'"), row)
    for (key, link), row in zip(pw.links.items(), rows.values(), strict=True):
        _values_fit(schema, ctx, key, pw, link, row)
    _where_fits(ctx, pw, where, walks)
    carried = (where.dims if where is not None else frozenset()) - {pw.along}
    typed = tuple(
        Link(
            key,
            node,
            link.values,
            rows[key],
            link.sign,
            link.by,
            _named(link.over),
            _named(link.into),
            _reads(ctx, key, pw, walks.get(key), carried),
        )
        for node, (key, link) in zip(links, pw.links.items(), strict=True)
    )
    return PiecewiseDeclaration(
        pw.along, typed, pw.method, tuple(pw.dims), where, activity=pw.activity, description=pw.description
    )

expand_piecewise(schema) #

schema with every piecewise: block written out — schema itself where it declares none.

A method: adjacency block states its restriction as the set method: sos2 states, and then that set is written out here too: the binaries are what the method is, so the model that comes back carries no set of its own (:func:math_spec.sos.emit is where they are spelled). Each block's rows and names are read off the program schema lowered to.

Source code in src/math_spec/piecewise.py
def expand_piecewise(schema: Spec) -> Spec:
    """*schema* with every ``piecewise:`` block written out — *schema* itself where it declares none.

    A ``method: adjacency`` block states its restriction as the set
    ``method: sos2`` states, and then that set is written out here too: the
    binaries are what the method *is*, so the model that comes back carries no
    set of its own (:func:`math_spec.sos.emit` is where they are spelled).
    Each block's rows and names are read off the program *schema* lowered to.
    """
    if not schema.piecewise:
        return schema
    program = schema.program
    raw = schema.model_dump()
    raw.setdefault('variables', {})
    raw.setdefault('constraints', {})
    for name, pw in schema.piecewise.items():
        _Block(schema, raw, name, pw, program.piecewise[name]).expand()
    raw['piecewise'].clear()
    for name, pw in schema.piecewise.items():
        if pw.method == 'adjacency':
            sos.emit(raw, name)
    return Spec.model_validate(raw)

leaves_ungated(gate) #

Whether a curve gated by gate runs ungated where the gate does not exist, which takes a second convexity row.

A masked gate is absent off its mask, and there the curve sums to 1; absence: zero reads the gate as 0 there instead, which one row states.

Source code in src/math_spec/piecewise.py
def leaves_ungated(gate: VariableBlock | VariableDeclaration | None) -> bool:
    """Whether a curve gated by *gate* runs ungated where the gate does not exist, which takes a second convexity row.

    A masked gate is absent off its mask, and there the curve sums to 1;
    ``absence: zero`` reads the gate as 0 there instead, which one row states.
    """
    return gate is not None and gate.where is not None and gate.absence != 'zero'

lp_domain_refusal(name, pw, links) #

The refusal for a method: lp curve whose x-link carries no variable, or None.

The method bounds the curve's domain with two rows comparing the x-link against the first and the last breakpoint, and a row with no variable decides nothing. Decided on the link the file wrote, rather than on the row the expansion would write under a name the file never declared.

Source code in src/math_spec/piecewise.py
def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> str | None:
    """The refusal for a ``method: lp`` curve whose x-link carries no variable, or ``None``.

    The method bounds the curve's domain with two rows comparing the x-link
    against the first and the last breakpoint, and a row with no variable
    decides nothing. Decided on the link the file wrote, rather than on the
    row the expansion would write under a name the file never declared.
    """
    x = pw.curve[0]
    i, key = next((i, key) for i, (key, link) in enumerate(pw.links.items()) if link is x)
    if carries_variable(links[i]):
        return None
    return (
        f"piecewise '{name}' link '{key}': method: lp bounds the curve's domain by rows comparing this link's "
        f'expression against its first and last breakpoint, and {x.expression!r} carries no variable, so those rows '
        f'decide nothing. Name a variable in the link, or use method: convex, sos2 or adjacency, whose weights pin '
        f'the domain themselves.'
    )

Block name's link expressions typed, in link order, or None once one failed, its refusal appended.

A link is read affinely, so it is held to degree 1 where it is read.

Source code in src/math_spec/piecewise.py
def resolve_links(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> tuple[Expression, ...] | None:
    """Block *name*'s link expressions typed, in link order, or ``None`` once one failed, its refusal appended.

    A link is read affinely, so it is held to degree 1 where it is read.
    """
    links = [
        resolve_expression_text(link.expression, ns, f"piecewise '{name}' link '{key}'", errors, ceiling=1)
        for key, link in pw.links.items()
    ]
    if any(link is None for link in links):
        return None
    return tuple(link for link in links if link is not None)

resolve_walks(name, pw, ns, errors) #

Block name's walks by link key, each read as at reads its relation, or None once one failed.

The expansion writes a walked row as at(<block>_lam, by=, over=, into=), so a walk is held to every rule that call is held to, and refused here on the link the file wrote. Each refusal is appended to errors.

Source code in src/math_spec/piecewise.py
def resolve_walks(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> dict[str, Direction] | None:
    """Block *name*'s walks by link key, each read as ``at`` reads its relation, or ``None`` once one failed.

    The expansion writes a walked row as ``at(<block>_lam, by=, over=,
    into=)``, so a walk is held to every rule that call is held to, and
    refused here on the link the file wrote. Each refusal is appended to
    *errors*.
    """
    walks: dict[str, Direction] = {}
    failed = False
    for key, link in pw.links.items():
        if not link.walks:
            continue
        assert link.by is not None
        resolver = ExpressionResolver(ns, f"piecewise '{name}' link '{key}'", errors)
        if (problem := resolver.not_a_relation(link.by, 'at', 'by')) is not None:
            errors.append(problem)
            failed = True
        elif (direction := resolver.direction(link.by, 'at', _named(link.over), _named(link.into))) is None:
            failed = True
        else:
            walks[key] = direction
    return None if failed else walks

through(text, link) #

text as link's row reads it: through the link's relation where the row reads the where so, else as written.

Source code in src/math_spec/piecewise.py
def through(text: str | None, link: Link) -> str | None:
    """*text* as *link*'s row reads it: through the link's relation where the row reads the where so, else as written."""
    if text is None or not link.reads:
        return text
    return f'at({text}, by={link.by}, over={_columns(link.over)}, into={_columns(link.into)})'