Skip to content

math_spec.piecewise

Expand piecewise: blocks into plain variables and constraints.

A block becomes ordinary affine declarations before anything reads the model, under names prefixed with the block's own; what each method emits is tabled in docs/reference/language/piecewise.md. Everything the language decides about a block against its model is decided once, in :func:check, and the emitters write rows from the facts it settled. A refusal names the link or key the file wrote rather than an emitted declaration.

ASSUMED = ('complete', 'increasing', 'curvature', 'breakpoints', 'contiguous') module-attribute #

GateRow = tuple[str, str | None, str] module-attribute #

Curve(name, block, rows, mask, reads, gates, names) dataclass #

One piecewise: block held to its model: every fact the rows it writes read.

:func:check is the only thing that builds one, so holding one is the proof that the block is inside the language, and nothing after it decides anything.

ATTRIBUTE DESCRIPTION
name

The block's key.

TYPE: str

block

The block as written.

TYPE: PiecewiseBlock

rows

Each link's row frame, in link order — dims:, or its refinement through the link's relation.

TYPE: tuple[tuple[str, ...], ...]

mask

The block's where:, as each shape of row reads it.

TYPE: CurveMask

reads

Whether each walked link reads the where: through its relation, by link (:func:walk_reads).

TYPE: dict[str, bool]

gates

The rows the weights sum under, one or two.

TYPE: tuple[GateRow, ...]

names

Every name the expansion writes.

TYPE: Names

block instance-attribute #

frame property #

The dims the block builds one curve per coordinate of, in the order dims: writes them.

gates instance-attribute #

mask instance-attribute #

name instance-attribute #

names instance-attribute #

reads instance-attribute #

rows instance-attribute #

CurveMask(block, resolved) #

A block's where:, as each shape of row the expansion writes reads it.

Built from the block and its resolved mask, to answer the one question the expansion asks of it: whether it reads the breakpoint dim. Such a where is ragged — it says how far each curve runs, not only which curves exist — so a row over the frame and the breakpoint dim takes it as written, the rows on a curve's edges shift it, and a row over the frame alone, which cannot read that dim, takes the count of breakpoints it admits. A where over the frame alone reaches every row as written.

Source code in src/math_spec/piecewise.py
def __init__(self, block: PiecewiseBlock, resolved: Mask | None) -> None:
    self.text = block.where
    self.along = block.along
    self.dims: frozenset[str] = resolved.dims if resolved is not None else frozenset()

along = block.along instance-attribute #

dims = resolved.dims if resolved is not None else frozenset() instance-attribute #

exists property #

What a row over the frame alone takes: the where, or the count of breakpoints a ragged one admits.

frame property #

The where as a row over the frame and the breakpoint dim conjoins it onto a position test — none where ragged.

ragged property #

Whether the where reads the breakpoint dim, and so says how far each curve runs.

text = block.where instance-attribute #

edge(end) #

The first or last breakpoint of each curve: where the mask holds and does not one step outward.

The vacated edge of a shift in a where is false, which is what makes the head and the tail of the axis their own edge.

Source code in src/math_spec/piecewise.py
def edge(self, end: Literal['first', 'last']) -> str:
    """The first or last breakpoint of each curve: where the mask holds and does not one step outward.

    The vacated edge of a ``shift`` in a ``where`` is false, which is what
    makes the head and the tail of the axis their own edge.
    """
    if not self.ragged:
        return f'position({self.along}) == {0 if end == "first" else -1}'
    return f'{self._atom} AND NOT {self._shifted(1 if end == "first" else -1)}'

interior() #

Where a breakpoint has one on either side: the rows a claim about a bend is true of.

Source code in src/math_spec/piecewise.py
def interior(self) -> str:
    """Where a breakpoint has one on either side: the rows a claim about a bend is true of."""
    if not self.ragged:
        return f'position({self.along}) > 0 AND position({self.along}) != -1'
    return f'{self._atom} AND {self._shifted(1)} AND {self._shifted(-1)}'

neighbours() #

Where a breakpoint and the one before it are both there: the rows a claim about a segment is true of.

Source code in src/math_spec/piecewise.py
def neighbours(self) -> str:
    """Where a breakpoint and the one before it are both there: the rows a claim about a segment is true of."""
    if not self.ragged:
        return f'position({self.along}) > 0'
    return f'{self._atom} AND {self._shifted(1)}'

Names(name, weights, convexity, chord, domain_lo, domain_hi, links, sos) dataclass #

Every name one block's expansion writes, spelled once for the emitters 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. sos holds the names a method that states a set writes through :func:math_spec.sos.emit.

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 #

name instance-attribute #

reused property #

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

sos instance-attribute #

ungated property #

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

weights instance-attribute #

of(name, links) 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, links: Iterable[str]) -> Names:
    """The names block *name* writes, a link's row named after the link."""
    return cls(
        name=name,
        weights=f'{name}_lam',
        convexity=f'{name}_convexity',
        chord=f'{name}_chord',
        domain_lo=f'{name}_domain_lo',
        domain_hi=f'{name}_domain_hi',
        links=tuple(f'{name}_{link}' for link in links),
        sos=Emitted.of(name, 2),
    )

assumptions_of(name, block, where, reads) #

What block 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; reads says, by link, whether that link reads the where: through its relation (:func:walk_reads). 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.

Read off the block rather than off an expansion, so a model states what it assumes whether or not its curves have been written out. Each condition is a where string over the parameters the file declared: the expansion writes them 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, block: PiecewiseBlock, where: CurveMask, reads: Mapping[str, bool]
) -> dict[str, AssumptionBlock]:
    """What *block* 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; *reads* says, by link, whether that link reads the
    ``where:`` through its relation (:func:`walk_reads`). 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.

    Read off the block rather than off an expansion, so a model states what it
    assumes whether or not its curves have been written out. Each condition is
    a where string over the parameters the file declared: the expansion writes
    them 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 = block.along
    mask = where.text if where.ragged else None
    rewrite = (
        f'Bind the rows, or narrow where: {mask!r} to where the curve runs.'
        if mask is not None
        else 'Bind the rows, or declare where: to say how far the curve runs.'
    )
    assumed: dict[str, AssumptionBlock] = {}
    if values := [link.values for link in block.links.values() if not link.walks]:
        assumed[f'{name}_complete'] = AssumptionBlock(
            holds=' AND '.join(dict.fromkeys(values)),
            where=where.text,
            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 key, link in block.links.items():
        if link.walks:
            assumed[f'{name}_{key}_complete'] = AssumptionBlock(
                holds=link.values,
                where=through(where.text, link, reads=True) if reads[key] else _all_of(link.by, where.text),
                description=f"piecewise '{name}' link '{key}': every breakpoint the curve runs through needs a row "
                f"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(block)
    if curvature is not None:
        x, y = (link.values for link in block.curve)
        assumed[f'{name}_increasing'] = AssumptionBlock(
            holds=f'{_neighbour(x, d, 1)} < {x}',
            where=_all_of(where.frame, where.neighbours()),
            description=f"piecewise '{name}': method: {block.method} requires strictly increasing breakpoints "
            f"in '{x}' along '{d}'",
        )
        assumed[f'{name}_curvature'] = _bends(name, block, where, x, y, curvature)
    if block.method == 'lp':
        assumed[f'{name}_breakpoints'] = AssumptionBlock(
            holds=f'count({mask or block.curve[0].values}, over={d}) >= 2',
            where=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({where.edge("first")}, over={d}) == 1',
            where=where.exists,
            description=f"piecewise '{name}': where: {mask!r} must mark a consecutive run of at least one "
            f'breakpoint per curve — {_GAP[block.method]}.',
        )
    return assumed

check(schema, name, block) #

block held to everything the language decides about it against its model, as the facts its rows read.

What the block names by key — dimensions, parameters, relations, the gate — and the names it writes are checked as the file loads, with every other cross-declaration rule (:class:~math_spec.model.Spec); the link expressions and the where are typed with the rest of the model (:attr:~math_spec.model.Spec.resolved), so a fault in one is named against the link there. What is left is what needs those typed forms: each link's row, that each link's expression and values fit it, and that the where: fits dims:.

RAISES DESCRIPTION
PiecewiseExpansionError

A link that does not fit its row, or a where outside dims:.

Source code in src/math_spec/piecewise.py
def check(schema: Spec, name: str, block: PiecewiseBlock) -> Curve:
    """*block* held to everything the language decides about it against its model, as the facts its rows read.

    What the block names by key — dimensions, parameters, relations, the gate
    — and the names it writes are checked as the file loads, with every other
    cross-declaration rule (:class:`~math_spec.model.Spec`); the link
    expressions and the where are typed with the rest of the model
    (:attr:`~math_spec.model.Spec.resolved`), so a fault in one is named
    against the link there. What is left is what needs those typed forms:
    each link's row, that each link's expression and values fit it, and that
    the ``where:`` fits ``dims:``.

    Raises:
        PiecewiseExpansionError: A link that does not fit its row, or a where
            outside ``dims:``.
    """
    ctx = f"piecewise '{name}'"
    nodes, where = schema.resolved.piecewise[name]
    links = tuple(block.links.items())
    expressions = tuple(
        dims_of(node, schema, f"{ctx} link '{key}'") for node, (key, _) in zip(nodes, links, strict=True)
    )
    rows = tuple(_row(schema, block, link) for _, link in links)
    _links_fit(block, expressions, rows, ctx)
    _values_fit(schema, block, rows, ctx)
    mask = CurveMask(block, where)
    _where_fits(block, mask, ctx)
    reads = walk_reads(schema, name, block, mask)
    return Curve(name, block, rows, mask, reads, _gate_rows(schema, block), Names.of(name, block.links))

declaration_of(block, where=None) #

The curve of one expanded block, as a program carries it.

PARAMETER DESCRIPTION
block

The block as written.

TYPE: PiecewiseBlock

where

Which coordinates of the frame have a curve, lowered — and so which ones what the block assumes is asked at.

TYPE: Mask | None DEFAULT: None

Source code in src/math_spec/piecewise.py
def declaration_of(block: PiecewiseBlock, where: Mask | None = None) -> PiecewiseDeclaration:
    """The curve of one expanded block, as a program carries it.

    Args:
        block: The block as written.
        where: Which coordinates of the frame have a curve, lowered — and so
            which ones what the block assumes is asked at.
    """
    return PiecewiseDeclaration(
        along=block.along,
        method=block.method,
        breakpoints=tuple(link.values for link in block.links.values()),
        where=where,
    )

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).

RAISES DESCRIPTION
PiecewiseExpansionError

A block :func:check refuses.

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).

    Raises:
        PiecewiseExpansionError: A block :func:`check` refuses.
    """
    if not schema.piecewise:
        return schema

    raw = schema.model_dump()
    raw.setdefault('variables', {})
    raw.setdefault('constraints', {})
    for name, block in schema.piecewise.items():
        _write(raw, check(schema, name, block))
    raw['piecewise'].clear()
    for name, block in schema.piecewise.items():
        if block.method == 'adjacency':
            emit(raw, name)
    expanded = Spec.model_validate(raw)
    expanded._expanded_piecewise = dict(schema.piecewise)
    del expanded.resolved  # validation typed the rows before the records were here, and a block's own where is on one
    return expanded

through(text, link, *, reads) #

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

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

walk_reads(schema, name, block, where) #

Whether each walked link reads the block's where: through its relation, by link.

A walked row is over the dims the walk produces, where a mask over the ones it consumes cannot be read as written. Read through the relation it can, as at reads it, when the mask carries every dim the walk consumes or joins on. A mask carrying none of them is over dims the row keeps, and reads as written.

RAISES DESCRIPTION
PiecewiseExpansionError

A mask carrying some of the dims a walk reads through and not the rest.

Source code in src/math_spec/piecewise.py
def walk_reads(schema: Spec, name: str, block: PiecewiseBlock, where: CurveMask) -> dict[str, bool]:
    """Whether each walked link reads the block's ``where:`` through its relation, by link.

    A walked row is over the dims the walk produces, where a mask over the
    ones it consumes cannot be read as written. Read through the relation it
    can, as ``at`` reads it, when the mask carries every dim the walk consumes
    or joins on. A mask carrying none of them is over dims the row keeps, and
    reads as written.

    Raises:
        PiecewiseExpansionError: A mask carrying some of the dims a walk
            reads through and not the rest.
    """
    carried = where.dims - {block.along}
    reads: dict[str, bool] = {}
    for key, link in block.links.items():
        if not link.walks:
            continue
        needed = _walk_reads(schema, link)
        if (partial := sorted(needed - carried)) and needed & carried:
            raise PiecewiseExpansionError(
                f"piecewise '{name}' link '{key}': where {block.where!r} carries "
                f"{sorted(needed & carried)} and not {partial}, and the link reads the curve through '{link.by}' "
                f'at all of {sorted(needed)}. Carry all of them in the where, so the row reads it through the '
                f'relation, or none, so the row reads it as written.'
            )
        reads[key] = bool(needed & carried)
    return reads