Skip to content

math_spec.lowering

Lower a parsed YAML schema (typed AST) to a :class:~math_spec.program.Program.

One lowering, whatever builds the result: it reads the typed AST and emits declarations with names resolved and shapes fixed. It lives on the language side, so no consumer needs YAML knowledge and this module reaches no consumer — which is what makes two consumers agreeing about a file structural rather than careful.

Constructs with no lowering raise :class:~math_spec.errors.LanguageError naming the construct and its rewrite, never a pointer at some other implementation: a rejection here is a language gap (docs/about/roadmap.md) rather than a routing decision.

The rules a lowered program then carries:

  • a reduction over a dim the operand does not carry is an error, not a silent identity — math_spec.dimensions owns that rule and this module asks it;
  • a constraint is one rule carrying its own name, so a row is read back by the name the file writes, with no positional suffix to guess;
  • a file declares one objective, likewise one expression;
  • an objective is scalar, so every reduction in it is one the file wrote and nothing sums on its own behalf.

lower_program(schema) #

Compile a :class:_ExpandedSpec into a :class:Program.

Takes the expanded model rather than expanding one: a program is built from declarations, and _ExpandedSpec is the type that guarantees they are all there. Every caller already held one — the expansion is memoised on the model — so this moves no work, it only stops the guarantee being a convention four consumers happened to observe.

A domain: binary variable lowers with fixed 0/1 bounds, so the domain needs no separate carrier.

RAISES DESCRIPTION
LanguageError

A construct outside the streaming language, named with its rewrite.

Source code in src/math_spec/lowering.py
def lower_program(schema: _ExpandedSpec) -> program.Program:
    """Compile a :class:`_ExpandedSpec` into a :class:`Program`.

    Takes the expanded model rather than expanding one: a program is built from
    declarations, and `_ExpandedSpec` is the type that guarantees they are all
    there. Every caller already held one — the expansion is memoised on the
    model — so this moves no work, it only stops the guarantee being a
    convention four consumers happened to observe.

    A ``domain: binary`` variable lowers with fixed 0/1 bounds, so the domain
    needs no separate carrier.

    Raises:
        LanguageError: A construct outside the streaming language, named with
            its rewrite.
    """
    expanded = schema
    ns = Namespace.of(expanded)
    name_dims = _name_dims(expanded)
    derivations = {
        name: how
        for block, ex in expanded.expanded_piecewise.items()
        for name, how in derivations_of(block, ex).items()
    }
    parameters = {
        name: program.ParameterDeclaration(tuple(pdef.dims), pdef.dtype, derivations.get(name))
        for name, pdef in expanded.parameters.items()
    }

    variables = {}
    for vname, vdef in expanded.variables.items():
        variable_type = cast('program.VariableType', vdef.domain)
        if variable_type == 'binary':
            lower, upper = program.Constant(0.0), program.Constant(1.0)
        else:
            lower, upper = _bound_expression(vdef.bounds.lower), _bound_expression(vdef.bounds.upper)
        variables[vname] = program.VariableDeclaration(
            tuple(vdef.foreach),
            where=_mask(vdef.where, ns, name_dims, f"variable '{vname}'", self_variable=vname),
            lower=lower,
            upper=upper,
            variable_type=variable_type,
            absence=cast('program.VariableAbsence', vdef.absence),
        )

    constraints = {}
    for cname, cdef in expanded.constraints.items():
        where = _mask(cdef.where, ns, name_dims, f"constraint '{cname}'")
        ast = expression_of(cdef.expression, expanded, ns, f"constraint '{cname}'")
        if not isinstance(ast, ComparisonNode):
            raise LanguageError(
                f"constraint '{cname}': expression must contain exactly one "
                f'comparison operator (<=, >=, ==). Got: {cdef.expression!r}'
            )
        if ast.op not in _SENSES:
            raise LanguageError(f"constraint '{cname}': unsupported sense '{ast.op}'")
        lowering = _Lowering(expanded, f"constraint '{cname}'")
        constraints[cname] = program.ConstraintDeclaration(
            tuple(cdef.foreach),
            lhs=lowering.expr(ast.left),
            sense=ast.op,
            rhs=lowering.expr(ast.right),
            where=where,
        )

    objective = None
    if (odef := expanded.objective) is not None:
        ast = expression_of(odef.expression, expanded, ns, 'the objective')
        if isinstance(ast, ComparisonNode):
            raise LanguageError('the objective: expression must not contain a comparison operator')
        objective = program.ObjectiveDeclaration(
            odef.sense,
            _Lowering(expanded, 'the objective').expr(ast),
        )

    dimensions = {
        dname: program.DimensionDeclaration(
            tuple(
                program.LookupDeclaration(lname, lk.into, lk.dtype)
                for lname, lk in expanded.lookups.items()
                if lk.over == dname
            ),
            ddef.dtype,
        )
        for dname, ddef in expanded.dimensions.items()
    }
    sos = {
        sname: program.SosDeclaration(
            sdef.variable,
            sdef.over,
            sos_type=cast('Literal[1, 2]', sdef.type),
            big_m=sdef.big_m,
        )
        for sname, sdef in expanded.sos.items()
    }
    expressions = {name: _lower_expression(expanded, ns, name) for name in expanded.expressions}
    return program.Program(
        parameters=parameters,
        variables=variables,
        constraints=constraints,
        objective=objective,
        dimensions=dimensions,
        sos=sos,
        piecewise={name: declaration_of(ex) for name, ex in expanded.expanded_piecewise.items()},
        named_expressions=expressions,
    )

to_program(spec) #

spec as a :class:~math_spec.program.Program — the public door.

Takes whatever you have: a YAML path, the YAML itself, a mapping, a loaded model, or a program already. Idempotent, so a caller that does not know which it holds can call this and be sure.

Not memoised. :func:~math_spec.piecewise.expand_piecewise is, because validators reach for the expansion as well as consumers and the same model is expanded more than once on one pass; nothing has that shape here. Add it the day something lowers one model twice.

PARAMETER DESCRIPTION
spec

What to read the declarations from.

TYPE: str | Path | dict[str, Any] | Spec | Program

RETURNS DESCRIPTION
Program

Every declaration the file makes, with names resolved and shapes

Program

fixed.

RAISES DESCRIPTION
SchemaError

The file is not a valid model.

LanguageError

A construct outside the language, named with its rewrite.

Source code in src/math_spec/lowering.py
def to_program(spec: str | Path | dict[str, Any] | Spec | program.Program) -> program.Program:
    """*spec* as a :class:`~math_spec.program.Program` — the public door.

    Takes whatever you have: a YAML path, the YAML itself, a mapping, a loaded
    model, or a program already. Idempotent, so a caller that does not know
    which it holds can call this and be sure.

    Not memoised. :func:`~math_spec.piecewise.expand_piecewise` is, because
    validators reach for the expansion as well as consumers and the same model
    is expanded more than once on one pass; nothing has that shape here. Add it
    the day something lowers one model twice.

    Args:
        spec: What to read the declarations from.

    Returns:
        Every declaration the file makes, with names resolved and shapes
        fixed.

    Raises:
        SchemaError: The file is not a valid model.
        LanguageError: A construct outside the language, named with its
            rewrite.
    """
    if isinstance(spec, program.Program):
        return spec
    return lower_program(expand_piecewise(to_spec(spec)))