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. Done once here, every consumer scopes identically by construction. The rules live in the language reference; the namespace is flat, and macro formals are the one scope.

Namespace(variables, parameters, dimensions, lookups, dtypes, leaf_dims) #

The declared names of one schema, by kind.

Flat by construction: :meth:kind is a single lookup, not an ordered walk through several stores.

Source code in src/math_spec/resolution.py
def __init__(
    self,
    variables: Iterable[str],
    parameters: Iterable[str],
    dimensions: Iterable[str],
    lookups: Mapping[str, tuple[str, str | None]],
    dtypes: Mapping[str, str],
    leaf_dims: Mapping[str, tuple[str, ...]],
) -> None:
    self.variables = frozenset(variables)
    self.parameters = frozenset(parameters)
    self.dimensions = frozenset(dimensions)
    #: name -> declared dtype, for dimensions, parameters and lookups alike;
    #: what a where comparison checks its literal against.
    self.dtypes: dict[str, str] = dict(dtypes)
    #: lookup name -> ``(over, into)``; ``into`` is ``None`` for a label
    #: space, which owns its values.
    self.lookups: dict[str, tuple[str, str | None]] = dict(lookups)
    #: 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 lookup leaf carries ``over``.
    self.leaf_dims: dict[str, tuple[str, ...]] = dict(leaf_dims)

dimensions = frozenset(dimensions) instance-attribute #

dtypes = dict(dtypes) instance-attribute #

leaf_dims = dict(leaf_dims) instance-attribute #

lookups = dict(lookups) instance-attribute #

parameters = frozenset(parameters) instance-attribute #

variables = frozenset(variables) instance-attribute #

groupable() #

The lookups a by= may name: name -> the dimension it maps into.

A label space is absent, which is what makes naming one in a by= answerable with the promotion rewrite rather than "no such lookup".

Source code in src/math_spec/resolution.py
def groupable(self) -> dict[str, str]:
    """The lookups a ``by=`` may name: name -> the dimension it maps into.

    A label space is absent, which is what makes naming one in a ``by=``
    answerable with the promotion rewrite rather than "no such lookup".
    """
    return {n: into for n, (_, into) in self.lookups.items() if into is not None}

into_of(lookup) #

The dimension lookup's values are labels of, None for a label space.

Source code in src/math_spec/resolution.py
def into_of(self, lookup: str) -> str | None:
    """The dimension *lookup*'s values are labels of, ``None`` for a label space."""
    return self.lookups[lookup][1]

kind(name) #

'variable' | 'parameter' | 'dimension' | 'lookup' | None.

Source code in src/math_spec/resolution.py
def kind(self, name: str) -> str | None:
    """``'variable'`` | ``'parameter'`` | ``'dimension'`` | ``'lookup'`` | ``None``."""
    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.lookups:
        return 'lookup'
    return None

of(schema) classmethod #

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

A targeted lookup's values are labels of its target, so its dtype is the target's.

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.

    A targeted lookup's values are labels of its target, so its dtype is
    the target's.
    """
    return cls(
        schema.variables,
        schema.parameters,
        schema.dimensions,
        {n: (lk.over, lk.into) for n, lk in schema.lookups.items()},
        {
            **{p: pd.dtype for p, pd in schema.parameters.items()},
            **{d: dd.dtype for d, dd in schema.dimensions.items()},
            **{n: schema.dimensions[lk.into].dtype for n, lk in schema.lookups.items() if lk.into is not None},
            **{n: lk.dtype for n, lk in schema.lookups.items() if lk.dtype is not None},
        },
        {
            **{p: tuple(pd.dims) for p, pd in schema.parameters.items()},
            **{v: tuple(vd.foreach) for v, vd in schema.variables.items()},
        },
    )

over_of(lookup) #

The dimension lookup maps out of, whichever kind it is.

Source code in src/math_spec/resolution.py
def over_of(self, lookup: str) -> str:
    """The dimension *lookup* maps out of, whichever kind it is."""
    return self.lookups[lookup][0]

expression_of(text, schema, ns, context) #

Parse, expand and resolve text — the only way a consumer gets an AST.

validation.py runs the same path at load time, so a consumer calling this gets a typed tree off a result already known to be clean, without duplicating the pass.

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) -> ExpressionNode:
    """Parse, expand and resolve *text* — the only way a consumer gets an AST.

    ``validation.py`` runs the same path at load time, so a consumer calling
    this gets a *typed* tree off a result already known to be clean, without
    duplicating the pass.

    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

resolve_expression(node, ns, context, errors) #

Rewrite every NameNode under node to a typed node.

Operator call shapes are checked here too (operators.call_shape_error). Arity is a language rule, and this is the pass every consumer goes through, so no consumer has to state a signature a second time.

RETURNS DESCRIPTION
ExpressionNode | None

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

ExpressionNode | None

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

ExpressionNode | None

whole schema reports them together.

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

    Operator *call shapes* are checked here too (``operators.call_shape_error``).
    Arity is a language rule, and this is the pass every consumer goes through,
    so no consumer has to state a signature a second time.

    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)
    if isinstance(node, ComparisonNode):
        resolved: ExpressionNode = ComparisonNode(
            node.op,
            _resolve_arith(node.left, ns, context, errors),
            _resolve_arith(node.right, ns, context, errors),
        )
    else:
        resolved = _resolve_arith(node, ns, context, errors)
    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.

Both parameters and dimensions are legal here — a where-string is a predicate over the frame, and the frame carries its own coordinates. What is not legal is an unknown name: read as "scalar False" it would mask every row out and produce an empty model in silence. The result is folded at this one door, so every reader of a resolved tree — the prover, the program, a typeset page — gets the same predicate by construction.

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

    Both parameters and dimensions are legal here — a where-string is a
    predicate over the frame, and the frame carries its own coordinates. What
    is *not* legal is an unknown name: read as "scalar False" it would mask
    every row out and produce an empty model in silence. The result is folded
    at this one door, so every reader of a resolved tree — the prover, the
    program, a typeset page — gets the same predicate by construction.
    """
    before = len(errors)
    resolved = _resolve_where(node, ns, context, errors, self_variable)
    return None if len(errors) > before else _fold(cast('WhereNode', resolved))

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

Parse and resolve one mask, appending each problem to errors.

The error-collecting twin of :func:where_of, for the load-time pass that reports every problem in a file at once — and the one door validation reads a where string through, so the parser stays this module's business. Returns 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,
) -> WhereNode | None:
    """Parse and resolve one mask, appending each problem to *errors*.

    The error-collecting twin of :func:`where_of`, for the load-time pass that
    reports every problem in a file at once — and the one door validation
    reads a where string through, so the parser stays this module's business.
    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: :func:resolve_where folds, so a mask that admits every row is dropped here and one that admits none arrives as a mask over BooleanLiteralNode(False). A Mask is the only shape a resolved where travels in past resolution, so every reader — a program, a typeset page — gets the same predicate.

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: :func:`resolve_where`
    folds, so a mask that admits every row is dropped here and one that admits
    none arrives as a mask over ``BooleanLiteralNode(False)``. A ``Mask`` is
    the only shape a resolved where travels in past resolution, so every
    reader — a program, a typeset page — gets the same predicate.

    Raises:
        LanguageError: Listing every problem the predicate has.
    """
    if text is None:
        return None
    errors: list[str] = []
    resolved = resolve_where(parse_where(text), ns, context, errors, self_variable)
    if errors:
        raise LanguageError('\n'.join(errors))
    assert resolved is not None
    if isinstance(resolved, BooleanLiteralNode) and resolved.value:
        return None
    return Mask(resolved)