Skip to content

math_spec.resolution

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

Parsers emit NameNode: a token, not yet a meaning. This module rewrites each one into a typed node (VariableNode / ParameterNode / DimensionNode / LookupNode, and ParameterComparisonNode / DimensionComparisonNode / ParameterDefinedNode on the where side), so the AST reaching either backend holds no unresolved names.

Doing this once here is what makes scoping identical across the lanes by construction rather than by test: a backend that resolves for itself is one that can build a model the other refuses. The name-resolution rules live in the language reference.

The namespace is flat and collisions are load errors; macro formals are the one scope, and may not collide with a declared dimension.

Namespace(variables, parameters, dimensions, lookups=None, dtypes=None) #

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]] | None = None,
    dtypes: Mapping[str, str] | None = None,
) -> None:
    self.variables = frozenset(variables)
    self.parameters = frozenset(parameters)
    self.dimensions = frozenset(dimensions)
    #: name -> declared dtype, for dimensions, parameters and label-space
    #: lookups alike. A where comparison is the one place a *literal*
    #: meets a declared type, and comparing the wrong one is silent: polars
    #: reads a datetime against an integer as an epoch offset and drops
    #: rows, and row absence is the structural zero. Empty when a caller
    #: builds a namespace by hand, which only widens what is accepted.
    self.dtypes: dict[str, str] = dict(dtypes or {})
    #: lookup name -> ``(over, into)``, both kinds in one store: ``into`` is
    #: ``None`` for a label space, which owns its values and targets
    #: nothing. That is the schema's own discriminator
    #: (:class:`~math_spec.model.LookupBlock` declares exactly one of
    #: ``into:`` and ``dtype:``), carried rather than re-encoded as two
    #: dicts — one fact, one home.
    self.lookups: dict[str, tuple[str, str | None]] = dict(lookups or {})

dimensions = frozenset(dimensions) instance-attribute #

dtypes = dict(dtypes or {}) instance-attribute #

lookups = dict(lookups or {}) 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.

Every name a file may use is declared in that file (hard rule 5), so the schema is the whole namespace and there is nothing to widen it with.

Source code in src/math_spec/resolution.py
@classmethod
def of(cls, schema: Model) -> Namespace:
    """Build the namespace of *schema*.

    Every name a file may use is declared in that file (hard rule 5), so
    the schema is the whole namespace and there is nothing to widen it
    with.
    """
    return cls(
        set(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()},
            # A targeted lookup's values are labels of its target, so the
            # target's dtype is what a literal is checked against.
            **{
                n: schema.dimensions[lk.into].dtype
                for n, lk in schema.lookups.items()
                if lk.into is not None and lk.into in schema.dimensions
            },
            **{n: lk.dtype for n, lk in schema.lookups.items() if lk.dtype is not None},
        },
    )

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 backend gets an AST.

validation.py runs the same path at load time, so a backend 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: Model, ns: Namespace, context: str) -> ExpressionNode:
    """Parse, expand and resolve *text* — the only way a backend gets an AST.

    ``validation.py`` runs the same path at load time, so a backend 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 neither backend 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 neither backend 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.

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.

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

    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.
    """
    before = len(errors)
    resolved = _resolve_where(node, ns, context, errors, self_variable)
    return None if len(errors) > before else resolved

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

Parse and resolve a where string; None stays None.

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) -> WhereNode | None:
    """Parse and resolve a where string; ``None`` stays ``None``.

    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))
    return resolved