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 mapping, or a loaded :class:Spec.

TYPE: str | Path | dict[str, Any] | Spec

RETURNS DESCRIPTION
Spec

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

RAISES DESCRIPTION
LanguageError

Anything the language does not accept.

Source code in src/math_spec/validation.py
def to_spec(model: str | Path | dict[str, Any] | 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 mapping, or a loaded :class:`Spec`.

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

    Raises:
        LanguageError: Anything the language does not accept.
    """
    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, dict) else read_yaml(Path(model)))

validate_expressions(schema) #

Validate and resolve every expression and where string in schema.

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.
RAISES DESCRIPTION
SchemaError

Listing every problem found, one per line.

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

    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.

    Raises:
        SchemaError: Listing every problem found, one per line.
    """
    ns = Namespace.of(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), schema, 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)
        )
        _check_template_names(body_ast, context, ns, formals, errors)

    for ename, block in schema.expressions.items():
        context = f"Named expression '{ename}'"
        if not block.cases:
            assert block.expression is not None
            _check_expression(block.expression, schema, ns, context, errors, comparison=False, ceiling=None)
            continue
        found = len(errors)
        masks: dict[str, WhereNode] = {}
        for case_name, case in block.cases.items():
            arm_context = case_context(ename, case_name)
            if (mask := resolve_where_text(case.when, ns, arm_context, errors)) is not None:
                if isinstance(mask, BooleanLiteralNode):
                    errors.append(_constant_arm(arm_context, value=mask.value))
                else:
                    masks[case_name] = mask
            _check_expression(case.expression, schema, ns, arm_context, errors, comparison=False, ceiling=None)
        assert block.otherwise is not None
        _check_expression(
            block.otherwise, schema, ns, case_context(ename, None), errors, comparison=False, ceiling=None
        )
        if len(errors) == found:
            errors.extend(f'{context}: {problem}' for problem in overlapping(masks, ns.dtypes))

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

    for cname, cdef in schema.constraints.items():
        context = f"Constraint '{cname}'"
        resolve_where_text(cdef.where, ns, context, errors)
        _check_expression(cdef.expression, schema, ns, context, errors, comparison=True, ceiling=2)

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

    if errors:
        raise SchemaError(_once(errors))

    check_schema(schema)