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(variables, parameters, dimensions, relations, dtypes, leaf_dims, constraints) #

The declared names of one schema, by kind.

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

Source code in src/math_spec/resolution.py
def __init__(
    self,
    variables: Iterable[str],
    parameters: Iterable[str],
    dimensions: Iterable[str],
    relations: Mapping[str, RelationDeclaration],
    dtypes: Mapping[str, DeclaredDtype],
    leaf_dims: Mapping[str, tuple[str, ...]],
    constraints: Iterable[str],
) -> None:
    self.variables = frozenset(variables)
    self.parameters = frozenset(parameters)
    self.dimensions = frozenset(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(constraints)
    #: name -> declared dtype, for dimensions, parameters and relations alike;
    #: what a where comparison checks its literal against.
    self.dtypes: dict[str, DeclaredDtype] = dict(dtypes)
    #: relation name -> its columns and key, as declared.
    self.relations: dict[str, RelationDeclaration] = dict(relations)
    #: 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, ...]] = dict(leaf_dims)

constraints = frozenset(constraints) instance-attribute #

dimensions = frozenset(dimensions) instance-attribute #

dtypes = dict(dtypes) instance-attribute #

leaf_dims = dict(leaf_dims) instance-attribute #

parameters = frozenset(parameters) instance-attribute #

relations = dict(relations) 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

of(schema) classmethod #

Build the namespace of schema, the whole of what a file may name.

Source code in src/math_spec/resolution.py
@classmethod
def of(cls, schema: Spec) -> Namespace:
    """Build the namespace of *schema*, the whole of what a file may name."""
    return cls(
        schema.variables,
        schema.parameters,
        schema.dimensions,
        {n: RelationDeclaration(lk.pairs, lk.key_roles) for n, lk in schema.relations.items()},
        {
            **{p: pd.dtype for p, pd in schema.parameters.items()},
            **{d: dd.dtype for d, dd in schema.dimensions.items()},
        },
        {
            **{p: tuple(pd.dims) for p, pd in schema.parameters.items()},
            **{v: tuple(vd.dims) for v, vd in schema.variables.items()},
        },
        schema.constraints,
    )

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) 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]

constraints instance-attribute #

expressions instance-attribute #

objective instance-attribute #

read_by_the_math cached property #

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

Read off those two positions alone: a bound and a where name no entry, and a piecewise link's expression reaches here through the constraints its expansion emitted. The rest of the expressions: section is read back after a solve and never fed to one (:attr:~math_spec.program.ExpressionDeclaration.in_math).

relations instance-attribute #

variables 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 #

expression_of(text, schema, ns, context) #

Parse, expand and resolve text in one call, raising rather than collecting.

A declaration's tree is on :class:Resolved; this is for a text that is not one.

RAISES DESCRIPTION
LanguageError

Listing every problem the text has.

Source code in src/math_spec/resolution.py
def expression_of(text: str, schema: Spec, ns: Namespace, context: str) -> ParsedNode:
    """Parse, expand and resolve *text* in one call, raising rather than collecting.

    A declaration's tree is on :class:`Resolved`; this is for a text that is
    not one.

    Raises:
        LanguageError: Listing every problem the text has.
    """
    errors: list[str] = []
    resolved = resolve_expression(parse_and_expand(text, schema, context), ns, context, errors)
    if errors:
        raise LanguageError('\n'.join(errors))
    assert resolved is not None
    return resolved

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)

where_of(text, ns, context, self_variable=None) #

Parse and resolve a where string into the :class:~math_spec.program.Mask a declaration carries.

None for no mask, however the file spelled it: a mask that admits every row is dropped, and one that admits none arrives as a mask over BooleanLiteral(False).

RAISES DESCRIPTION
LanguageError

Listing every problem the predicate has.

Source code in src/math_spec/resolution.py
def where_of(text: str | None, ns: Namespace, context: str, self_variable: str | None = None) -> Mask | None:
    """Parse and resolve a where string into the :class:`~math_spec.program.Mask` a declaration carries.

    ``None`` for no mask, however the file spelled it: a mask that admits every
    row is dropped, and one that admits none arrives as a mask over
    ``BooleanLiteral(False)``.

    Raises:
        LanguageError: Listing every problem the predicate has.
    """
    errors: list[str] = []
    resolved = resolve_where_text(text, ns, context, errors, self_variable)
    if errors:
        raise LanguageError('\n'.join(errors))
    return mask_of(resolved)