Skip to content

math_spec.resolution

Name resolution — the pass that reads the syntax tree into the program vocabulary.

The grammars emit bare names and calls; resolution builds the :mod:math_spec.program node each stands for, so every pass after — the dim rules, the degree rules, the typesetter, lowering — reads one vocabulary. This module holds the :class:Namespace a resolution reads names from and the doors lowering calls, one per kind of text; the walks are :class:~math_spec._expression_resolver.ExpressionResolver for arithmetic and :class:~math_spec._where_resolver.WhereResolver for a where string. The rules live in the language reference.

DeclarationKind = Literal['variable', 'parameter', 'dimension', 'relation'] module-attribute #

Namespace(schema) #

The declared names of one schema, by kind — the whole of what a file may name, read once.

A name has one kind: validation.py refuses one declared under two sections.

Source code in src/math_spec/resolution.py
def __init__(self, schema: Spec) -> None:
    #: The schema the names come from — what an expression is expanded and
    #: dim-checked against, since macros, named expressions and the dim
    #: rules read declarations the flat listing below does not carry.
    self.schema = schema
    self.variables = frozenset(schema.variables)
    self.parameters = frozenset(schema.parameters)
    self.dimensions = frozenset(schema.dimensions)
    #: The declared constraint names, off the flat namespace: a bare name
    #: never reaches them, so a model may name a constraint after a variable.
    #: Consulted only in ``dual()``'s argument position.
    self.constraints = frozenset(schema.constraints)
    #: name -> declared dtype, for dimensions, parameters and relations alike;
    #: what a where comparison checks its literal against.
    self.dtypes: dict[str, DeclaredDtype] = {
        **{p: pd.dtype for p, pd in schema.parameters.items()},
        **{d: dd.dtype for d, dd in schema.dimensions.items()},
    }
    #: relation name -> its columns and key, as declared.
    self.relations: dict[str, RelationDeclaration] = {
        n: RelationDeclaration(lk.pairs, lk.key_roles, lk.description) for n, lk in schema.relations.items()
    }
    #: parameter or variable name -> the dims it is read through —
    #: parameters by their ``dims``, variables by their frame. Stamped onto
    #: each leaf a where names, the way a relation leaf carries ``over``.
    self.leaf_dims: dict[str, tuple[str, ...]] = {
        **{p: tuple(pd.dims) for p, pd in schema.parameters.items()},
        **{v: tuple(vd.dims) for v, vd in schema.variables.items()},
    }
    #: named expression -> its resolved node, or ``None``, and its refusals;
    #: filled the first time anything reads the name.
    self._named: dict[str, tuple[Named | None, tuple[str, ...]]] = {}
    #: The named expressions waiting to be resolved, the one asked for
    #: first — each above the entries it reads, so it is a cycle's chain.
    self._loading: list[str] = []

constraints = frozenset(schema.constraints) instance-attribute #

dimensions = frozenset(schema.dimensions) instance-attribute #

dtypes = {**{p: pd.dtype for p, pd in schema.parameters.items()}, **{d: dd.dtype for d, dd in schema.dimensions.items()}} instance-attribute #

leaf_dims = {**{p: tuple(pd.dims) for p, pd in schema.parameters.items()}, **{v: tuple(vd.dims) for v, vd in schema.variables.items()}} instance-attribute #

parameters = frozenset(schema.parameters) instance-attribute #

relations = {n: RelationDeclaration(lk.pairs, lk.key_roles, lk.description) for n, lk in schema.relations.items()} instance-attribute #

schema = schema instance-attribute #

variables = frozenset(schema.variables) instance-attribute #

cycle(name, context, through=()) #

The refusal for reading name while it is being resolved, or None; through names the macros the read went through.

Source code in src/math_spec/resolution.py
def cycle(self, name: str, context: str, through: Iterable[str] = ()) -> str | None:
    """The refusal for reading *name* while it is being resolved, or ``None``; *through* names the macros the read went through."""
    if name not in self._loading:
        return None
    chain = ' -> '.join([*self._loading[self._loading.index(name) :], *through, name])
    return f'{context}: circular expression reference: {chain}'

kind(name) #

What name was declared as, or None where the file declares it nowhere.

Source code in src/math_spec/resolution.py
def kind(self, name: str) -> DeclarationKind | None:
    """What *name* was declared as, or ``None`` where the file declares it nowhere."""
    if name in self.variables:
        return 'variable'
    if name in self.parameters:
        return 'parameter'
    if name in self.dimensions:
        return 'dimension'
    if name in self.relations:
        return 'relation'
    return None

named(name, context) #

The expressions: entry name as the node that stands where its name is written.

Resolved under the entry's own context the first time it is asked for, and read from then on, so a fault in it is reported once.

RAISES DESCRIPTION
SchemaError

The entry reads itself, or does not load.

Source code in src/math_spec/resolution.py
def named(self, name: str, context: str) -> Named:
    """The ``expressions:`` entry *name* as the node that stands where its name is written.

    Resolved under the entry's own context the first time it is asked
    for, and read from then on, so a fault in it is reported once.

    Raises:
        SchemaError: The entry reads itself, or does not load.
    """
    if (refusal := self.cycle(name, context)) is not None:
        raise SchemaError(refusal)
    node, _ = self.named_entry(name)
    if node is None:
        msg = f"{context}: named expression '{name}' does not load. Its refusal is listed with it."
        raise SchemaError(msg)
    return node

named_entry(name) #

The expressions: entry name resolved, or None, with every refusal it earned.

The entries it reads are resolved before it, walked from a stack that holds the path of reads from name rather than by recursing into each, so a chain of entries however long costs no stack. An entry that reads one on the path is a cycle, which :meth:named refuses with that path when the resolution reaches the read.

Source code in src/math_spec/resolution.py
def named_entry(self, name: str) -> tuple[Named | None, tuple[str, ...]]:
    """The ``expressions:`` entry *name* resolved, or ``None``, with every refusal it earned.

    The entries it reads are resolved before it, walked from a stack that
    holds the path of reads from *name* rather than by recursing into
    each, so a chain of entries however long costs no stack. An entry
    that reads one on the path is a cycle, which :meth:`named` refuses
    with that path when the resolution reaches the read.
    """
    base = len(self._loading)
    self._loading.append(name)
    while len(self._loading) > base:
        top = self._loading[-1]
        if top in self._named:
            self._loading.pop()
            continue
        waiting = [n for n in self._references(top) if n not in self._named and n not in self._loading]
        if waiting:
            self._loading.append(waiting[0])
            continue
        errors: list[str] = []
        node = _named(top, self.schema.expressions[top], self, errors)
        self._named[top] = (node, tuple(errors))
        self._loading.pop()
    return self._named[name]

unknown(name, context, *, allow_dims, formals=()) #

The refusal for a name declared nowhere, listing what it could have been.

PARAMETER DESCRIPTION
name

The name the file wrote.

TYPE: str

context

The declaration it was found in.

TYPE: str

allow_dims

Whether a dimension would have been accepted there. It marks a where string, which reads a relation as readily as a parameter, so the listing carries the relations too; an expression, where a relation is not a value, lists the variables instead.

TYPE: bool

formals

A macro's formals, listed first when there are any.

TYPE: Iterable[str] DEFAULT: ()

Source code in src/math_spec/resolution.py
def unknown(self, name: str, context: str, *, allow_dims: bool, formals: Iterable[str] = ()) -> str:
    """The refusal for a *name* declared nowhere, listing what it could have been.

    Args:
        name: The name the file wrote.
        context: The declaration it was found in.
        allow_dims: Whether a dimension would have been accepted there. It marks a
            where string, which reads a relation as readily as a parameter, so the
            listing carries the relations too; an expression, where a relation is not a
            value, lists the variables instead.
        formals: A macro's formals, listed first when there are any.
    """
    shown: list[tuple[str, Iterable[str]]] = [('Formals', formals)] if formals else []
    shown += (
        [('Parameters', self.parameters), ('Dimensions', self.dimensions), ('Relations', self.relations)]
        if allow_dims
        else [('Variables', self.variables), ('Parameters', self.parameters)]
    )
    listing = '\n'.join(f'  {kind}: {sorted(names)}' for kind, names in shown)
    return f"{context}: '{name}' not found.\n{listing}\nCheck for typos, or ensure '{name}' is declared."

unknown_constraint(name, context, *, formals=()) #

The refusal for a dual(name) naming no constraint — nor, inside a template, a formal.

Source code in src/math_spec/resolution.py
def unknown_constraint(self, name: str, context: str, *, formals: Iterable[str] = ()) -> str:
    """The refusal for a ``dual(name)`` naming no constraint — nor, inside a template, a formal."""
    also = ' or a formal of this macro' if formals else ''
    return (
        f"{context}: dual({name}): '{name}' is not a declared constraint{also}.\n"
        f'  Constraints: {sorted(self.constraints)}\n'
        f"Check for typos, or declare '{name}' under 'constraints:'."
    )

mask_of(node) #

The mask a declaration carries for a resolved where: None where there is none, or where every row passes.

Source code in src/math_spec/resolution.py
def mask_of(node: Predicate | None) -> Mask | None:
    """The mask a declaration carries for a resolved where: ``None`` where there is none, or where every row passes."""
    if node is None or (isinstance(node, BooleanLiteral) and node.value):
        return None
    return Mask(node)

remainder(masks) #

The region left over: where not one of masks holds.

The otherwise arm's own mask, built rather than written. cases: carries at least one case, so there is no vacuous truth to spell.

Source code in src/math_spec/resolution.py
def remainder(masks: Iterable[Mask]) -> Mask:
    """The region left over: where not one of *masks* holds.

    The ``otherwise`` arm's own mask, built rather than written. ``cases:``
    carries at least one case, so there is no vacuous truth to spell.
    """
    first, *rest = masks
    left = ~first
    for mask in rest:
        left = left & ~mask
    return left

resolve_constraint_text(text, ns, context, errors) #

Parse, expand, resolve and degree-check one constraint string: exactly one comparison, a variable on a side (#1171).

RETURNS DESCRIPTION
tuple[Expression, ComparisonOperator, Expression] | None

The two sides and the sense between them, or None once anything

tuple[Expression, ComparisonOperator, Expression] | None

failed, the problem appended to errors.

Source code in src/math_spec/resolution.py
def resolve_constraint_text(
    text: str, ns: Namespace, context: str, errors: list[str]
) -> tuple[Expression, ComparisonOperator, Expression] | None:
    """Parse, expand, resolve and degree-check one constraint string: exactly one comparison, a variable on a side (#1171).

    Returns:
        The two sides and the sense between them, or ``None`` once anything
        failed, the problem appended to *errors*.
    """
    ast = _parsed(text, ns, context, errors)
    if ast is None:
        return None
    if not isinstance(ast, ComparisonNode):
        errors.append(
            f'{context}: expression must contain exactly one comparison operator (<=, >=, ==).\nGot: {text!r}'
        )
        return None
    found = len(errors)
    resolver = ExpressionResolver(ns, context, errors)
    left, right = resolver.build(ast.left), resolver.build(ast.right)
    if len(errors) > found or left is None or right is None:
        return None
    if any(_over_the_ceiling(side, context, errors, ceiling=2) for side in (left, right)):
        return None
    if not (carries_variable(left) or carries_variable(right)):
        errors.append(
            f'{context}: neither side of the comparison carries a variable, so the row decides nothing.\n'
            f'Got: {text!r}\n'
            f'A constraint is a claim about a decision, and a comparison of numbers and parameters '
            f'is settled before the solve — no consumer builds a row for it. Name the variable it should '
            f'bound, or state the fact under `assumptions:`, where the consumer binding the data checks it.'
        )
        return None
    return left, ast.op, right

resolve_expression(node, ns, context, errors, *, formals=frozenset()) #

Build the program tree node stands for, checking every name and operator call shape on the way.

RETURNS DESCRIPTION
Expression | None

The tree, or None once anything failed — appending to errors

Expression | None

rather than raising, so a caller collecting problems across a whole

Expression | None

schema reports them together. Also None, with nothing appended,

Expression | None

where a name in formals stands under node: a macro template is

Expression | None

checked by the rules a call site is before anything calls it, and

Expression | None

only the call site that binds its formals has a tree to build.

Source code in src/math_spec/resolution.py
def resolve_expression(
    node: ArithmeticNode,
    ns: Namespace,
    context: str,
    errors: list[str],
    *,
    formals: frozenset[str] = frozenset(),
) -> Expression | None:
    """Build the program tree *node* stands for, checking every name and operator call shape on the way.

    Returns:
        The tree, or ``None`` once anything failed — appending to *errors*
        rather than raising, so a caller collecting problems across a whole
        schema reports them together. Also ``None``, with nothing appended,
        where a name in *formals* stands under *node*: a macro template is
        checked by the rules a call site is before anything calls it, and
        only the call site that binds its formals has a tree to build.

    """
    before = len(errors)
    resolved = ExpressionResolver(ns, context, errors, formals=formals).build(node)
    return None if len(errors) > before else resolved

resolve_expression_text(text, ns, context, errors, *, ceiling) #

Parse, expand, resolve and degree-check one expression string that stands for a value.

ceiling is the degree the position honours, and None for an expressions: entry's body: what the math admits (:func:~math_spec.degree.check_expression) is a rule about the position that reads it, so it fires on the expanded tree of every objective and piecewise link, and not where an entry is declared. A constraint is :func:resolve_constraint_text's.

RETURNS DESCRIPTION
Expression | None

The typed tree, or None once anything failed, the problem appended

Expression | None

to errors.

Source code in src/math_spec/resolution.py
def resolve_expression_text(
    text: str, ns: Namespace, context: str, errors: list[str], *, ceiling: int | None
) -> Expression | None:
    """Parse, expand, resolve and degree-check one expression string that stands for a value.

    *ceiling* is the degree the position honours, and ``None`` for an
    ``expressions:`` entry's body: what the math admits
    (:func:`~math_spec.degree.check_expression`) is a rule about the position
    that *reads* it, so it fires on the expanded tree of every objective and
    piecewise link, and not where an entry is declared. A constraint is
    :func:`resolve_constraint_text`'s.

    Returns:
        The typed tree, or ``None`` once anything failed, the problem appended
        to *errors*.
    """
    ast = _parsed(text, ns, context, errors)
    if ast is None:
        return None
    if isinstance(ast, ComparisonNode):
        errors.append(f'{context}: expression must not contain a comparison operator.\nGot: {text!r}')
        return None
    resolved = resolve_expression(ast, ns, context, errors)
    if resolved is None or ceiling is None:
        return resolved
    return None if _over_the_ceiling(resolved, context, errors, ceiling=ceiling) else resolved

resolve_where(node, ns, context, errors, self_variable=None) #

Rewrite a parsed where AST into typed predicates, folded as :class:~math_spec.program.Mask folds.

RETURNS DESCRIPTION
Predicate | None

The typed tree — a mask admitting every row or none comes back as the

Predicate | None

one BooleanLiteral — or None once anything failed, with the

Predicate | None

problems appended to errors.

Source code in src/math_spec/resolution.py
def resolve_where(
    node: Predicate | UnresolvedWhereNode,
    ns: Namespace,
    context: str,
    errors: list[str],
    self_variable: str | None = None,
) -> Predicate | None:
    """Rewrite a parsed where AST into typed predicates, folded as :class:`~math_spec.program.Mask` folds.

    Returns:
        The typed tree — a mask admitting every row or none comes back as the
        one ``BooleanLiteral`` — or ``None`` once anything failed, with the
        problems appended to *errors*.
    """
    before = len(errors)
    resolved = WhereResolver(ns, context, errors, self_variable).where(node)
    return None if len(errors) > before else Mask(cast('Predicate', resolved)).root

resolve_where_text(text, ns, context, errors, self_variable=None) #

Parse and resolve one where string as :func:resolve_where does, a parse failure appended to errors.

RETURNS DESCRIPTION
Predicate | None

None where there is no mask to read, and where reading it failed.

Source code in src/math_spec/resolution.py
def resolve_where_text(
    text: str | None,
    ns: Namespace,
    context: str,
    errors: list[str],
    self_variable: str | None = None,
) -> Predicate | None:
    """Parse and resolve one where string as :func:`resolve_where` does, a parse failure appended to *errors*.

    Returns:
        ``None`` where there is no mask to read, and where reading it failed.
    """
    if text is None:
        return None
    try:
        node = parse_where(text)
    except ValueError as e:
        errors.append(f'{context}: {e}')
        return None
    return resolve_where(node, ns, context, errors, self_variable)