Skip to content

math_spec.validation

Load-time validation: the front door, and the pass that decides every expression.

to_spec(model) #

Load and validate a model definition — the language's front door.

Everything decidable without data is decided here: schema shape, every expression and where string, every macro template, and every declaration a formulation emits.

PARAMETER DESCRIPTION
model

A YAML path — a :class:~pathlib.Path, or a str with no newline in it — the YAML text itself as a str with one, a mapping, or a loaded :class:Spec.

TYPE: str | Path | Mapping[str, object] | Spec

RETURNS DESCRIPTION
Spec

The schema as the file declares it, piecewise: intact.

RAISES DESCRIPTION
LanguageError

Anything the language does not accept, a text that is not a mapping of sections included.

FileNotFoundError

A str with no newline that names no file.

Source code in src/math_spec/validation.py
def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec:
    """Load and validate a model definition — the language's front door.

    Everything decidable without data is decided here: schema shape, every
    expression and where string, every macro template, and every declaration a
    formulation emits.

    Args:
        model: A YAML path — a :class:`~pathlib.Path`, or a ``str`` with no
            newline in it — the YAML text itself as a ``str`` with one, a
            mapping, or a loaded :class:`Spec`.

    Returns:
        The schema *as the file declares it*, ``piecewise:`` intact.

    Raises:
        LanguageError: Anything the language does not accept, a text that is
            not a mapping of sections included.
        FileNotFoundError: A ``str`` with no newline that names no file.
    """
    if isinstance(model, (list, tuple)):
        msg = 'a model is one file, one dict or one Spec, never a list of them; merge the declarations into one dict.'
        raise SchemaError(msg)
    if isinstance(model, Spec):
        return model
    return Spec.model_validate(model if isinstance(model, Mapping) else read_model(model))

validate_expressions(schema) #

Validate and resolve every expression and where string in schema, once for every reader.

What is checked:

  • the expression parses, and constraints hold exactly one comparison where objectives hold none;
  • every referenced name resolves, and every operator is a built-in whose dimension arguments name declared dimensions;
  • where strings parse and resolve — an unknown name there is an error, not a silently-empty mask;
  • macro formals may shadow model names but not a declared dimension, since over=snapshot under a formal snapshot cannot say which it means;
  • every dim rule (dimensions.check_schema), once names resolve.

A piecewise: block's links are resolved here too, so the typesetter reads the curve a file states without expanding it.

RETURNS DESCRIPTION
Resolved

Every declaration's typed tree — what the dim rules, lowering and the

Resolved

typesetter read instead of resolving the text again.

RAISES DESCRIPTION
SchemaError

Listing every problem found, one per line.

DimensionError

The first dim rule a declaration breaks, once every name resolves.

Source code in src/math_spec/validation.py
def validate_expressions(schema: Spec) -> Resolved:
    """Validate and resolve every expression and where string in *schema*, once for every reader.

    What is checked:

    - the expression parses, and constraints hold exactly one comparison where
      objectives hold none;
    - every referenced name resolves, and every operator is a built-in whose
      dimension arguments name declared dimensions;
    - where strings parse *and* resolve — an unknown name there is an error,
      not a silently-empty mask;
    - macro formals may shadow model names but not a declared dimension, since
      ``over=snapshot`` under a formal ``snapshot`` cannot say which it means;
    - every dim rule (``dimensions.check_schema``), once names resolve.

    A ``piecewise:`` block's links are resolved here too, so the typesetter
    reads the curve a file states without expanding it.

    Returns:
        Every declaration's typed tree — what the dim rules, lowering and the
        typesetter read instead of resolving the text again.

    Raises:
        SchemaError: Listing every problem found, one per line.
        DimensionError: The first dim rule a declaration breaks, once every
            name resolves.
    """
    ns = Namespace(schema)
    errors: list[str] = []

    for mname, macro in schema.macros.items():
        context = f"Macro '{mname}'"
        formals = frozenset((*macro.args, *macro.kwargs))
        try:
            body_ast = expand(parse_template(mname, macro, context), ns, context, shadow=formals)
        except ValueError as e:
            errors.append(prefixed(context, e))
            continue
        errors.extend(
            f"{context}: formal '{f}' collides with declared dimension '{f}'. "
            f'Rename the formal — a dimension name inside a template is '
            f'ambiguous with the dimension itself.'
            for f in sorted(formals & ns.dimensions)
        )
        resolve_expression(body_ast, ns, context, errors, formals=formals)

    expressions: dict[str, CasesNode | DefinitionNode] = {}
    for ename in schema.expressions:
        node, refusals = ns.named_entry(ename)
        errors.extend(refusals)
        if node is not None:
            expressions[ename] = node
    if errors:
        raise SchemaError('\n'.join(errors))

    variables = {
        vname: mask_of(resolve_where_text(vdef.where, ns, f"Variable '{vname}'", errors, self_variable=vname))
        for vname, vdef in schema.variables.items()
    }

    constraints: dict[str, ResolvedConstraint] = {}
    for cname, cdef in schema.constraints.items():
        context = f"Constraint '{cname}'"
        where = resolve_where_text(cdef.where, ns, context, errors)
        expression = _check_expression(cdef.expression, ns, context, errors, comparison=True, ceiling=2)
        if expression is not None:
            constraints[cname] = ResolvedConstraint(expression, mask_of(where))

    objective = None
    if schema.objective is not None:
        objective = _check_expression(
            schema.objective.expression, ns, 'The objective', errors, comparison=False, ceiling=2
        )

    assumptions: dict[str, ResolvedAssumption] = {}
    for aname, adef in schema.assumptions.items():
        if (assumption := _assumption(aname, adef, ns, errors)) is not None:
            assumptions[aname] = assumption

    for block, pw in schema.piecewise.items():
        for aname, assumed in assumptions_of(block, pw).items():
            entry = AssumptionBlock(holds=assumed.holds, where=assumed.where, description=assumed.description)
            if (assumption := _assumption(aname, entry, ns, errors)) is not None:
                assumptions[aname] = assumption

    piecewise = {}
    for pname, pdef in schema.piecewise.items():
        links = [
            _check_expression(link.expression, ns, f"piecewise '{pname}' link {i}", errors, comparison=False, ceiling=1)
            for i, link in enumerate(pdef.links)
        ]
        if all(link is not None for link in links):
            piecewise[pname] = tuple(link for link in links if link is not None)

    if errors:
        raise SchemaError('\n'.join(errors))

    resolved = Resolved(expressions, variables, constraints, objective, ns.relations, assumptions, piecewise)
    check_schema(schema, resolved)
    return resolved