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
    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) 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[CasesNode | DefinitionNode | None, tuple[str, ...]]] = {}
    #: The named expressions being resolved, outermost first — 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) for n, lk in schema.relations.items()} instance-attribute #

schema = schema instance-attribute #

variables = frozenset(schema.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

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) -> CasesNode | DefinitionNode:
    """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 name in self._loading:
        chain = ' -> '.join([*self._loading[self._loading.index(name) :], name])
        msg = f'{context}: circular expression reference: {chain}'
        raise SchemaError(msg)
    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.

Source code in src/math_spec/resolution.py
def named_entry(self, name: str) -> tuple[CasesNode | DefinitionNode | None, tuple[str, ...]]:
    """The ``expressions:`` entry *name* resolved, or ``None``, with every refusal it earned."""
    if name not in self._named:
        errors: list[str] = []
        self._loading.append(name)
        try:
            node = _named(name, self.schema.expressions[name], self, errors)
        finally:
            self._loading.pop()
        self._named[name] = (node, tuple(errors))
    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:'."
    )

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, *, formals=frozenset()) #

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

A name in formals stays bare, so a macro template is checked by the rules a call site is, before anything calls it.

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],
    *,
    formals: frozenset[str] = frozenset(),
) -> ParsedNode | None:
    """Rewrite every ``NameNode`` under *node* to a typed node, checking operator call shapes on the way.

    A name in *formals* stays bare, so a macro template is checked by the
    rules a call site is, before anything calls it.

    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, formals=formals).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)