Skip to content

math_spec.degree

Degree — the clause of the expressive ceiling that is a scope choice.

Decidable on the resolved core AST with no data bound, which is what makes lps.check() a real gate rather than a syntax pass, and the one admissibility rule that is a scope choice rather than a consequence of streaming (docs/about/ceiling.md).

Degree 2 in the math, degree 1 in what stands beside it. An objective and a constraint both take variable * variable; a bound, a named expression and a piecewise: link do not — each of those is read affinely by something downstream.

Where a quadratic model can land is a different axis, declared by each consumer and answered by check(model, sink=...). This module says what is sayable and stops there; refusing degree 2 outright was letting one library's limits read as a rule about math.

A degree-2 product has a second rule: at most one factor may be a sum of terms. sum(x, over=i) * sum(y, over=j) is every term of one against every term of the other, a cross join whose size the file states nowhere — the one shape "bilinear" hides that the ceiling doc genuinely excludes, and the boundary linopy's own * draws, which is what keeps hard rule 3 structural rather than lucky. Factors carrying different dims are not that: x[i] * y[j] broadcasts, and x[i] * y[j] * a[i, j] joins through a declared table.

That is why it lives here and not in lowering.py: degree is a property of the language, so both lanes give the same verdict and the same sentence, as they do for dim sets and the closed operator set. Stated once, every consumer asks — a copy in a lane is the spelling no differential test covers.

A divisor's shape is decided here too. A quotient is built as multiplication by one reciprocal factor, and an addition is what makes a variable-free expression more than one — so a sum divisor is refused, and refused at load, where the sentence can name the rewrite. Not a degree question (x / (a + b) is affine) but the same node, the same verdict owed to both lanes, and the same answer with no data bound.

Deliberately narrow: :func:check_binary decides a binary operator node, the only place degree can be lost, and :func:check_expression is that decision over a whole expression — for the formulations, which judge a link before there is a declaration to name in the error.

ARITHMETIC_OPERATORS = frozenset({'+', '-', '*', '/', '**'}) module-attribute #

carries_variable(node) #

Whether node contains a decision variable.

A structural question over the resolved AST — no data, no plan. A NameNode reaching here is a resolution bug rather than a false negative, so it is refused rather than silently answered.

Source code in src/math_spec/degree.py
def carries_variable(node: ExpressionNode) -> bool:
    """Whether *node* contains a decision variable.

    A structural question over the resolved AST — no data, no plan. A
    ``NameNode`` reaching here is a resolution bug rather than a false
    negative, so it is refused rather than silently answered.
    """
    if isinstance(node, VariableNode):
        return True
    if isinstance(node, (NumberNode, ParameterNode, DimensionNode, LookupNode, EdgeNode)):
        return False
    if isinstance(node, NameListNode):
        msg = (
            f'NameListNode({list(node.names)!r}) reached the degree check. A bracketed list is '
            f'consumed by its kwarg during resolution (docs/about/architecture.md hard rule 1).'
        )
        raise AssertionError(msg)
    if isinstance(node, KeywordNode):
        msg = (
            f'KeywordNode({node.value!r}) reached the degree check. A quoted keyword is '
            f'consumed by its kwarg during resolution (docs/about/architecture.md hard rule 1).'
        )
        raise AssertionError(msg)
    if isinstance(node, NameNode):
        msg = (
            f'NameNode({node.name!r}) reached the degree check. Expressions must go '
            f'through resolution.expression_of() first (docs/about/architecture.md hard rule 1).'
        )
        raise AssertionError(msg)
    if isinstance(node, (UnaryOperatorNode, BinaryOperatorNode, ComparisonNode, FunctionCallNode, CasesNode)):
        return any(carries_variable(c) for c in children(node))
    assert_never(node)

check_binary(node, context=None, *, ceiling=1) #

Check that node stays inside the degree its position allows.

Callers want the raise, not the answer — the same shape as dimensions.dims_of being asked for its verdict.

PARAMETER DESCRIPTION
node

The product, quotient or sum to judge.

TYPE: BinaryOperatorNode

context

What to name in the message — the declaration being lowered.

TYPE: str | None DEFAULT: None

ceiling

The highest degree this position can honour — 2 in an objective, 1 everywhere else, and the module docstring is why.

TYPE: int DEFAULT: 1

RAISES DESCRIPTION
LanguageError

A product of two variable-carrying factors where the position allows only degree 1 or where both factors are sums of terms, a power over anything carrying a variable, a divisor carrying a variable or adding, or an operator the language does not have.

Source code in src/math_spec/degree.py
def check_binary(node: BinaryOperatorNode, context: str | None = None, *, ceiling: int = 1) -> None:
    """Check that *node* stays inside the degree its position allows.

    Callers want the *raise*, not the answer — the same shape as
    ``dimensions.dims_of`` being asked for its verdict.

    Args:
        node: The product, quotient or sum to judge.
        context: What to name in the message — the declaration being lowered.
        ceiling: The highest degree this position can honour — 2 in an
            objective, 1 everywhere else, and the module docstring is why.

    Raises:
        LanguageError: A product of two variable-carrying factors where the
            position allows only degree 1 or where both factors are sums of
            terms, a power over anything carrying a variable, a divisor carrying
            a variable or adding, or an operator the language does not have.
    """
    where = f'{context}: ' if context else ''
    if node.op not in ARITHMETIC_OPERATORS:
        raise LanguageError(
            f"{where}operator '{node.op}' is not in the language. Write the product "
            f'out — `x * x` for a square — or precompute the factor as a parameter. '
            f'A variable base above degree 2 has no rewrite at all, and one whose '
            f'exponent is data has no degree until the data arrives '
            f'(see docs/about/ceiling.md).'
        )
    if node.op == '**':
        if carries_variable(node):
            raise LanguageError(_a_variable_under_a_power_message(where))
        if _adds(node.left) or _adds(node.right):
            raise LanguageError(
                f'{where}a base and an exponent must each be a single Constant/Parameter factor, '
                f'not a sum — addition does not distribute over `**`, so `(1 + rate) ** period` is '
                f'refused where `growth ** period` is not. Bind the factor itself.'
            )
    if node.op == '/' and carries_variable(node.right):
        raise LanguageError(
            f'{where}the divisor contains variables, which is not affine. '
            f'Divide by a parameter, or precompute the reciprocal as one.'
        )
    if node.op == '/' and _adds(node.right):
        raise LanguageError(
            f'{where}a divisor must be a single Constant/Parameter factor, '
            f'not a sum — rewrite as multiplication by a precomputed parameter'
        )
    if node.op != '*' or not (carries_variable(node.left) and carries_variable(node.right)):
        return
    if ceiling < 2:
        raise LanguageError(_degree_two_here_message(where))
    if (degree := _degree(node)) > ceiling:
        raise LanguageError(_above_the_ceiling_message(where, degree))
    _check_single_term_factor(node, where)

check_expression(node, context, *, ceiling=1) #

Apply :func:check_binary everywhere in node.

Lowering asks per node as it descends, because it is already walking. A caller that only wants the verdict on an expression it holds asks here, and gets the identical sentence — which is the point: piecewise: judges its link expressions this way so that p * p is refused against the link the user wrote, not against curve_link0, the declaration the expansion went on to generate.

Degree only, deliberately. What a plan node can represent is a different question and a consuming lane's to ask; a formulation runs in lanes that build no plan at all.

Source code in src/math_spec/degree.py
def check_expression(node: ExpressionNode, context: str, *, ceiling: int = 1) -> None:
    """Apply :func:`check_binary` everywhere in *node*.

    Lowering asks per node as it descends, because it is already walking. A
    caller that only wants the verdict on an expression it holds asks here,
    and gets the identical sentence — which is the point: ``piecewise:``
    judges its link expressions this way so that ``p * p`` is refused against
    *the link the user wrote*, not against ``curve_link0``, the declaration
    the expansion went on to generate.

    Degree only, deliberately. What a plan node can represent is a different
    question and a consuming lane's to ask; a formulation runs in lanes that
    build no plan at all.
    """
    if isinstance(node, BinaryOperatorNode):
        check_binary(node, context, ceiling=ceiling)
    for child in children(node):
        check_expression(child, context, ceiling=ceiling)

is_quadratic(node) #

Whether node multiplies two variable-carrying operands.

What :func:check_binary refuses at ceiling=1, asked of a whole expression rather than of one node — by a consumer that has to build the thing rather than judge it, and cannot build this one. Whether an expression is quadratic is a fact about the expression; what that costs a consumer is the consumer's own to declare.

A second home reads the same question off the plan rather than the AST. Two, because the two representations are different types and the lanes share neither.

Source code in src/math_spec/degree.py
def is_quadratic(node: ExpressionNode) -> bool:
    """Whether *node* multiplies two variable-carrying operands.

    What :func:`check_binary` refuses at ``ceiling=1``, asked of a whole
    expression rather than of one node — by a consumer that has to *build* the
    thing rather than judge it, and cannot build this one. Whether an
    expression is quadratic is a fact about the expression; what that costs a
    consumer is the consumer's own to declare.

    A second home reads the same question off the *plan* rather than the AST.
    Two, because the two representations are different types and the lanes
    share neither.
    """
    if (
        isinstance(node, BinaryOperatorNode)
        and node.op == '*'
        and carries_variable(node.left)
        and carries_variable(node.right)
    ):
        return True
    return any(is_quadratic(child) for child in children(node))