Skip to content

math_spec.resolution

Name resolution — the pass that makes the core AST fully typed.

Parsers emit unresolved names; this module rewrites each into the typed node its kind asks for, so the AST reaching a consumer holds none. 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: model.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
    variables = {**schema.variables, **schema.given.variables}
    self.variables = frozenset(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, **schema.given.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) 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 variables.items()},
    }

constraints = frozenset({**schema.constraints, **schema.given.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 variables.items()}} instance-attribute #

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

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

schema = schema instance-attribute #

variables = frozenset(variables) instance-attribute #

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

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:' if this file builds the row, "
        f"or under 'given: constraints:' if it reads the dual of a row another model builds."
    )

Resolved(expressions, variables, constraints, objective, relations, assumptions, piecewise) dataclass #

Every expression and where string of one schema, typed once at load.

:func:~math_spec.validation.validate_expressions builds it, and every reader after — the dim rules, lowering, the typesetter — walks these trees rather than parsing, expanding and resolving the text again. Each mapping is keyed as the schema's own section is. A where the file did not write, or one every row passes, is None.

ATTRIBUTE DESCRIPTION
expressions

Each expressions: entry as the node its name expands to — a plain entry a :class:~math_spec._expression_parser.DefinitionNode carrying its name over its body, a cased one a :class:~math_spec._expression_parser.CasesNode with every arm's when typed. Every entry either names is inlined where it stood, so a walk over one sees the whole chain.

TYPE: dict[str, CasesNode | DefinitionNode]

variables

Each variable's where.

TYPE: dict[str, Mask | None]

constraints

Each constraint's comparison and where.

TYPE: dict[str, ResolvedConstraint]

objective

The objective's expression, None where the file declares none.

TYPE: ArithmeticNode | None

relations

Each relation's columns and key, as declared — the one copy, which every :class:~math_spec.program.Direction and :class:~math_spec.program.Partition in the trees holds.

TYPE: dict[str, RelationDeclaration]

assumptions

Each assumptions: entry's predicate and the mask it is checked under.

TYPE: dict[str, ResolvedAssumption]

piecewise

Each piecewise: block's link expressions, in link order.

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

assumptions instance-attribute #

constraints instance-attribute #

expressions instance-attribute #

objective instance-attribute #

piecewise instance-attribute #

read_by_the_math cached property #

The named expressions the math reads: every entry the objective, a constraint or a curve reaches, transitively.

Read off those three positions alone: a bound and a where name no entry. The rest of the expressions: section is read back after a solve and never fed to one (:attr:~math_spec.program.ExpressionDeclaration.in_math). A curve counts because it states rows, so the answer does not move when the curve is written out (:meth:~math_spec.model.Spec.expand).

relations instance-attribute #

variables instance-attribute #

ResolvedAssumption #

Bases: NamedTuple

One assumption's typed halves: the predicate it states, and the mask it is checked under.

description is the sentence a refusal quotes where one was written or a method implied one, and None where the name is the whole of what a reader is told.

description = None class-attribute instance-attribute #

holds instance-attribute #

where instance-attribute #

ResolvedConstraint #

Bases: NamedTuple

One constraint's typed halves: the comparison it states, and the mask it holds under.

expression instance-attribute #

where instance-attribute #

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)

names_in(value) #

The names a relation kwarg carries: one bare, several bracketed, none otherwise.

Source code in src/math_spec/resolution.py
def names_in(value: ArithmeticNode) -> tuple[str, ...]:
    """The names a relation kwarg carries: one bare, several bracketed, none otherwise."""
    if isinstance(value, NameNode):
        return (value.name,)
    return value.names if isinstance(value, NameListNode) else ()

resolve_expression(node, ns, context, errors) #

Rewrite every NameNode under node to a typed node, checking operator call shapes on the way.

RETURNS DESCRIPTION
ParsedNode | None

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

ParsedNode | None

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

ParsedNode | None

whole schema reports them together.

Source code in src/math_spec/resolution.py
def resolve_expression(
    node: ParsedNode,
    ns: Namespace,
    context: str,
    errors: list[str],
) -> ParsedNode | None:
    """Rewrite every ``NameNode`` under *node* to a typed node, checking operator call shapes on the way.

    Returns:
        The typed tree, or ``None`` once anything failed — appending to
        *errors* rather than raising, so a caller collecting problems across a
        whole schema reports them together.
    """
    before = len(errors)
    resolved = _Resolver(ns, context, errors).expression(node)
    return None if len(errors) > before 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 = _Resolver(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)