Skip to content

Python API#

This page documents every name that import math_spec exports, grouped by task.

Loading#

The module math_spec.program holds the node and declaration classes of a loaded model. Reading a loaded model documents them.

math_spec.to_spec(model) #

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

Everything decidable without data is decided here: schema shape, every rule one declaration is held to against the others, every expression and where string, and every macro template.

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
    rule one declaration is held to against the others, every expression and
    where string, and every macro template.

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

math_spec.Spec #

Bases: _StrictBlock

The declared math — one YAML file, or one dict, validated. Nothing here has seen data.

A Spec that exists has passed the whole language: constructing one by any route — to_spec, :meth:model_validate, the constructor — runs every load-time check, expression pass included, and raises :class:~math_spec.errors.LanguageError on a model the language refuses. Holding one is the proof, so nothing downstream checks it again.

The API is the eleven declaration sections plus version and description, three ways back out — :meth:to_dict for the model as data, :meth:to_yaml for the file a reviewer reads, :meth:expand for the same math with its formulations written out — and :attr:program, the model typed, which every reader after load walks. Everything else on this class is pydantic's, not a contract this package keeps.

assumptions = {} class-attribute instance-attribute #

constraints = {} class-attribute instance-attribute #

description = None class-attribute instance-attribute #

dimensions = {} class-attribute instance-attribute #

expressions = {} class-attribute instance-attribute #

macros = {} class-attribute instance-attribute #

objective = None class-attribute instance-attribute #

parameters = {} class-attribute instance-attribute #

piecewise = {} class-attribute instance-attribute #

program cached property #

This model typed, section for section — what every reader after load walks.

Computing it is the expression pass, so a model the language refuses raises here; loading forces it, so every ask on a model in hand is the one object. It mirrors the model: a piecewise: block still in it is a curve under program.piecewise and a sos: block a set under program.sos, and :meth:expand is what writes either out as rows, so a consumer building rows reads spec.expand(...).program and refuses a block it does not take.

relations = {} class-attribute instance-attribute #

sos = {} class-attribute instance-attribute #

variables = {} class-attribute instance-attribute #

version = 0 class-attribute instance-attribute #

expand(*kinds) #

This model with its formulations written out as plain variables and constraints.

A formulation states rows rather than being one — piecewise: states a curve, sos: states which members of a family may be nonzero — and expanding one writes those rows under names prefixed with the block's own, then drops the block. The math is the same afterwards, and so is the data that binds it: neither a set nor a curve emits a parameter, and a curve's rows sit on where predicates over the file's own.

PARAMETER DESCRIPTION
kinds

Which formulations to write out — 'piecewise', 'sos', or none of them for every one. They go in :data:FORMULATIONS order whatever order they are asked in, because a method: sos2 curve emits a set and no set emits a curve.

TYPE: Formulation DEFAULT: ()

RETURNS DESCRIPTION
Spec

The model those blocks wrote out, or this one where it declares

Spec

none of them. It is a model like any other: :meth:to_yaml writes

Spec

it, and the file binds the same data as the one it came from.

RAISES DESCRIPTION
ValueError

kinds names something that is not a formulation.

Source code in src/math_spec/model.py
def expand(self, *kinds: Formulation) -> Spec:
    """This model with its formulations written out as plain variables and constraints.

    A formulation states rows rather than being one — ``piecewise:`` states
    a curve, ``sos:`` states which members of a family may be nonzero — and
    expanding one writes those rows under names prefixed with the block's
    own, then drops the block. The math is the same afterwards, and so is
    the data that binds it: neither a set nor a curve emits a parameter,
    and a curve's rows sit on ``where`` predicates over the file's own.

    Args:
        kinds: Which formulations to write out — ``'piecewise'``,
            ``'sos'``, or none of them for every one. They go in
            :data:`FORMULATIONS` order whatever order they are asked in,
            because a ``method: sos2`` curve emits a set and no set emits a
            curve.

    Returns:
        The model those blocks wrote out, or this one where it declares
        none of them. It is a model like any other: :meth:`to_yaml` writes
        it, and the file binds the same data as the one it came from.

    Raises:
        ValueError: *kinds* names something that is not a formulation.
    """
    wanted = _formulations(kinds)
    from math_spec.piecewise import expand_piecewise
    from math_spec.sos import expand_sets

    expanded = expand_piecewise(self) if 'piecewise' in wanted else self
    if 'sos' in wanted and expanded.sos:
        expanded = expand_sets(expanded)
    return expanded

model_validate(obj, *, strict=None, extra=None, from_attributes=None, context=None, by_alias=None, by_name=None) classmethod #

Validate a mapping, raising this package's exception tree rather than pydantic's.

__init__ is not wrapped the same way, because defining one makes pydantic run every after-validator twice.

Source code in src/math_spec/model.py
@classmethod
@override
def model_validate(
    cls,
    obj: object,
    *,
    strict: bool | None = None,
    extra: ExtraValues | None = None,
    from_attributes: bool | None = None,
    context: object = None,
    by_alias: bool | None = None,
    by_name: bool | None = None,
) -> Self:
    """Validate a mapping, raising this package's exception tree rather than pydantic's.

    ``__init__`` is not wrapped the same way, because defining one makes
    pydantic run every after-validator twice.
    """
    try:
        return super().model_validate(
            obj,
            strict=strict,
            extra=extra,
            from_attributes=from_attributes,
            context=context,
            by_alias=by_alias,
            by_name=by_name,
        )
    except ValidationError as exc:
        raise schema_error(exc) from None

to_dict() #

The model as plain data. to_spec(m.to_dict()) reproduces it.

Source code in src/math_spec/model.py
def to_dict(self) -> dict[str, object]:
    """The model as plain data. ``to_spec(m.to_dict())`` reproduces it."""
    return self.model_dump()

to_yaml() #

The file a reviewer reads — including for a model that never had one.

Source code in src/math_spec/model.py
def to_yaml(self) -> str:
    """The file a reviewer reads — including for a model that never had one."""
    import yaml

    return yaml.safe_dump(self.to_dict(), sort_keys=False, allow_unicode=True)

Typesetting#

math_spec.to_latex(model, **options) #

Render model as LaTeX (amsmath align). See :func:typeset.

Source code in src/math_spec/typesetting/__init__.py
def to_latex(model: str | Path | Mapping[str, object] | Spec | Program, **options: Unpack[_Options]) -> str:
    """Render *model* as LaTeX (amsmath ``align``). See :func:`typeset`."""
    return typeset(model, 'latex', **options)

math_spec.to_typst(model, **options) #

Render model as Typst. See :func:typeset.

Source code in src/math_spec/typesetting/__init__.py
def to_typst(model: str | Path | Mapping[str, object] | Spec | Program, **options: Unpack[_Options]) -> str:
    """Render *model* as Typst. See :func:`typeset`."""
    return typeset(model, 'typst', **options)

math_spec.to_markdown(model, **options) #

Render model as GitHub-flavoured Markdown. See :func:typeset.

Source code in src/math_spec/typesetting/__init__.py
def to_markdown(model: str | Path | Mapping[str, object] | Spec | Program, **options: Unpack[_Options]) -> str:
    """Render *model* as GitHub-flavoured Markdown. See :func:`typeset`."""
    return typeset(model, 'markdown', **options)

math_spec.typeset(model, fmt, *, symbols=None, standalone=False, legend=True, numbered=True, inline_expressions=False) #

Render model's math in fmt.

PARAMETER DESCRIPTION
model

Anything :func:math_spec.to_spec accepts, or a :class:~math_spec.program.Program. A loaded model or a program is rendered as it stands, so printing one model in several formats reads and checks the file once rather than once per format, and a curve prints as the curve it states. Pass spec.expand() for the rows a solver holds instead.

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

fmt

What spells the math — a key of :data:FORMATS.

TYPE: FormatName

symbols

How names print, as a :class:SymbolTable, a path or a mapping. Names it does not carry are derived, and it must be written in fmt's notation.

TYPE: str | Path | Mapping[str, object] | SymbolTable | None DEFAULT: None

standalone

Emit a compilable document rather than a fragment.

TYPE: bool DEFAULT: False

legend

Prepend the sets/parameters/variables table. The model's own description: opens the document either way — it is what the file says it is, not a symbol table.

TYPE: bool DEFAULT: True

numbered

Number the equations.

TYPE: bool DEFAULT: True

inline_expressions

Substitute each plain named expression into the equations that use it, rather than printing its symbol there and its definition once. A cased expression is a definition either way: its block is taller than the line it would sit in.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
str

The rendered text.

RAISES DESCRIPTION
ValueError

fmt names no format.

LanguageError

A model that does not compile; it does not print.

SchemaError

A symbol table entry naming nothing in the model, or a table written in a notation fmt does not read.

Source code in src/math_spec/typesetting/__init__.py
def typeset(
    model: str | Path | Mapping[str, object] | Spec | Program,
    fmt: FormatName,
    *,
    symbols: str | Path | Mapping[str, object] | SymbolTable | None = None,
    standalone: bool = False,
    legend: bool = True,
    numbered: bool = True,
    inline_expressions: bool = False,
) -> str:
    """Render *model*'s math in *fmt*.

    Args:
        model: Anything :func:`math_spec.to_spec` accepts, or a
            :class:`~math_spec.program.Program`. A loaded model or a program
            is rendered as it stands, so printing one model in several formats
            reads and checks the file once rather than once per format, and a
            curve prints as the curve it states. Pass ``spec.expand()`` for the rows a solver holds
            instead.
        fmt: What spells the math — a key of :data:`FORMATS`.
        symbols: How names print, as a :class:`SymbolTable`, a path or a
            mapping. Names it does not carry are derived, and it must be
            written in *fmt*'s notation.
        standalone: Emit a compilable document rather than a fragment.
        legend: Prepend the sets/parameters/variables table. The model's own
            ``description:`` opens the document either way — it is what the
            file says it is, not a symbol table.
        numbered: Number the equations.
        inline_expressions: Substitute each plain named expression into the equations that
            use it, rather than printing its symbol there and its definition
            once. A cased expression is a definition either way: its block is
            taller than the line it would sit in.

    Returns:
        The rendered text.

    Raises:
        ValueError: *fmt* names no format.
        LanguageError: A model that does not compile; it does not print.
        SchemaError: A symbol table entry naming nothing in the model, or a
            table written in a notation *fmt* does not read.
    """
    walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions)
    program, format_ = walk.program, walk.format

    rendered = [
        format_.section(title, format_.equations(lines, numbered=numbered))
        for title, lines in walk.equations()
        if lines
    ]

    blocks = [format_.note(format_.escape(program.description))] if program.description else []
    if legend:
        explained, noticed = Legend(program, walk.symbols, format_), notice(program)
        blocks += [
            format_.section(title, format_.glossary(entries))
            for title, entries in explained.glossaries(noticed, walk.defined())
        ]
        blocks += [format_.note(text) for text in explained.convention_notes()]
        blocks += [format_.note(text) for text in explained.translation_notes(noticed)]
        blocks += [format_.note(text) for text in explained.position_notes(noticed)]
    return format_.document([*blocks, *rendered], standalone=standalone)

math_spec.typeset_declaration(model, name, fmt, *, symbols=None, inline_expressions=True) #

Render one declaration as the bare line the document prints for it.

The line the whole-model render prints for it — a named expression's definition, a constraint, an assumption, a piecewise: curve, or a variable's domain, quantifier included — with no document, label, equation number or math delimiters around it, for a math context the caller lays out: a docstring, a table cell. A line on its own has no Definitions section beside it, so the plain named expressions it uses are substituted unless inline_expressions says otherwise; a cased one prints by symbol, and a second call with its name prints its block.

PARAMETER DESCRIPTION
model

Anything :func:math_spec.to_spec accepts, or a :class:~math_spec.program.Program.

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

name

A named expression, constraint, assumption, piecewise: block or variable the model declares.

TYPE: str

fmt

What spells the math — a key of :data:FORMATS.

TYPE: FormatName

symbols

How names print; see :func:typeset.

TYPE: str | Path | Mapping[str, object] | SymbolTable | None DEFAULT: None

inline_expressions

Substitute the plain named expressions the line uses, so it stands on its own; False prints their symbols, as the document does. A plain expression asked for by name prints its definition either way.

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
str

The line, math only.

RAISES DESCRIPTION
ValueError

fmt names no format.

LanguageError

A model that does not compile; it does not print.

SchemaError

name is declared as none of the five, or as two — a constraint may share a variable's name; or a symbol table entry names nothing in the model.

Source code in src/math_spec/typesetting/__init__.py
def typeset_declaration(
    model: str | Path | Mapping[str, object] | Spec | Program,
    name: str,
    fmt: FormatName,
    *,
    symbols: str | Path | Mapping[str, object] | SymbolTable | None = None,
    inline_expressions: bool = True,
) -> str:
    """Render one declaration as the bare line the document prints for it.

    The line the whole-model render prints for it — a named expression's
    definition, a constraint, an assumption, a ``piecewise:`` curve, or a
    variable's domain, quantifier included —
    with no document, label, equation number or math delimiters around it, for
    a math context the caller lays out: a docstring, a table cell. A line on
    its own has no Definitions section beside it, so the plain named
    expressions it uses are substituted unless *inline_expressions* says otherwise; a cased
    one prints by symbol, and a second call with its name prints its block.

    Args:
        model: Anything :func:`math_spec.to_spec` accepts, or a :class:`~math_spec.program.Program`.
        name: A named expression, constraint, assumption, ``piecewise:``
            block or variable the model declares.
        fmt: What spells the math — a key of :data:`FORMATS`.
        symbols: How names print; see :func:`typeset`.
        inline_expressions: Substitute the plain named expressions the line uses, so it
            stands on its own; ``False`` prints their symbols, as the document
            does. A plain expression asked for by name prints its definition
            either way.

    Returns:
        The line, math only.

    Raises:
        ValueError: *fmt* names no format.
        LanguageError: A model that does not compile; it does not print.
        SchemaError: *name* is declared as none of the five, or as two — a
            constraint may share a variable's name; or a symbol table entry
            names nothing in the model.
    """
    walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions)
    return walk.format.equation(walk.line(name))

math_spec.FORMATS = {'latex': LatexFormat(), 'markdown': MarkdownFormat(), 'typst': TypstFormat()} module-attribute #

math_spec.SymbolTable(notation, indices=dict(), sets=dict(), names=dict()) dataclass #

How a reader wants the model to print — notation only, kept out of the model.

Every entry is a spelling, printed verbatim. notation: says which language they are written in, and a render in the other one refuses::

notation: latex
dimensions:
  snapshot: {index: t, set: "\\mathcal{T}"}
  plant:    {index: n}
names:
  marginal_cost: "c^{\\mathrm{marg}}"

An entry naming nothing in the model is an error naming the near miss.

ATTRIBUTE DESCRIPTION
notation

The language the entries are written in; :meth:load lower-cases it.

TYPE: Notation

indices = field(default_factory=dict) class-attribute instance-attribute #

names = field(default_factory=dict) class-attribute instance-attribute #

notation instance-attribute #

sets = field(default_factory=dict) class-attribute instance-attribute #

checked_against(program) #

Reject entries naming nothing in program or in what its formulations state, with the near miss.

A name a piecewise: or sos: block emits counts as declared, so one table spells both readings of a model: the blocks as the file states them, and the rows :meth:~math_spec.model.Spec.expand writes out.

Source code in src/math_spec/typesetting/symbols.py
def checked_against(self, program: Program) -> SymbolTable:
    """Reject entries naming nothing in *program* or in what its formulations state, with the near miss.

    A name a ``piecewise:`` or ``sos:`` block emits counts as declared, so
    one table spells both readings of a model: the blocks as the file states
    them, and the rows :meth:`~math_spec.model.Spec.expand` writes out.
    """
    dims = set(program.dimensions)
    everything = dims | _declared(program) | _emitted(program)
    errors = [
        *(_unknown_entry(d, 'dimensions', dims) for d in {*self.indices, *self.sets} - dims),
        *(_unknown_entry(n, 'names', everything - dims) for n in set(self.names) - everything),
    ]
    if errors:
        raise SchemaError('\n'.join(sorted(errors)))
    return self

load(source) classmethod #

A table from a YAML path or the mapping it parses to.

RAISES DESCRIPTION
SchemaError

An unknown section, a section or a dimension that is not a mapping, or a notation: that is missing or not latex/typst.

Source code in src/math_spec/typesetting/symbols.py
@classmethod
def load(cls, source: str | Path | Mapping[str, object]) -> SymbolTable:
    """A table from a YAML path or the mapping it parses to.

    Raises:
        SchemaError: An unknown section, a section or a dimension that is
            not a mapping, or a ``notation:`` that is missing or not
            ``latex``/``typst``.
    """
    raw = dict(source) if isinstance(source, Mapping) else read_yaml(Path(source))
    unknown = set(raw) - {'notation', 'dimensions', 'names'}
    if unknown:
        msg = f'symbol table: unknown section(s) {sorted(unknown)}. Valid sections: notation, dimensions, names.'
        raise SchemaError(msg)
    if 'notation' not in raw:
        msg = "symbol table: 'notation:' is required — latex or typst, the language the entries are written in."
        raise SchemaError(msg)
    notation = str(raw['notation']).lower()
    if notation not in NOTATIONS:
        msg = f'symbol table: unknown notation {raw["notation"]!r}. Valid notations: latex, typst.'
        raise SchemaError(msg)

    indices: dict[str, str] = {}
    sets: dict[str, str] = {}
    for dim, spec in _section(raw, 'dimensions').items():
        if not isinstance(spec, Mapping):
            msg = f"symbol table: dimension '{dim}' must be a mapping like {{index: t, set: '\\\\mathcal{{T}}'}}"
            raise SchemaError(msg)
        extra = set(spec) - {'index', 'set'}
        if extra:
            msg = f"symbol table: dimension '{dim}' has unknown key(s) {sorted(extra)}. Valid keys: index, set."
            raise SchemaError(msg)
        if 'index' in spec:
            indices[dim] = str(spec['index'])
        if 'set' in spec:
            sets[dim] = str(spec['set'])

    return cls(
        notation=cast('Notation', notation),
        indices=indices,
        sets=sets,
        names={k: str(v) for k, v in _section(raw, 'names').items()},
    )

Advice#

math_spec.advice #

Advice — what is decidable without data and is a note rather than a refusal.

One door, :func:advice, over every pass of that kind.

advice(model) #

Everything the language advises about model — never an error, decidable without data.

PARAMETER DESCRIPTION
model

A YAML path, a mapping, a loaded :class:Spec, or a :class:Program, read as it arrived. A piecewise: or sos: block is read as the rows it states, so the answer is the one its expansion gets, with nothing expanded.

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

RETURNS DESCRIPTION
Advice

The never-an-axis advice in declaration order, then the unboundedness

...

advice; str() of each is its sentence.

Source code in src/math_spec/advice.py
def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[Advice, ...]:
    """Everything the language advises about *model* — never an error, decidable without data.

    Args:
        model: A YAML path, a mapping, a loaded :class:`Spec`, or a
            :class:`Program`, read as it arrived. A ``piecewise:`` or ``sos:``
            block is read as the rows it states, so the answer is the one its
            expansion gets, with nothing expanded.

    Returns:
        The never-an-axis advice in declaration order, then the unboundedness
        advice; ``str()`` of each is its sentence.
    """
    program = model if isinstance(model, Program) else to_spec(model).program
    return tuple(_never_an_axis(program) + unbounded_notes(program))

math_spec.Advice(kind, subject, text) dataclass #

One thing the language advises about a file it accepts.

Never an error: each is what a half-written model looks like too. A consumer prints it, or filters on kind and subject; the text is the language's, so no consumer writes its own.

ATTRIBUTE DESCRIPTION
kind

The pass that said it.

TYPE: AdviceKind

subject

The declaration it is about — a dimension name, a variable name.

TYPE: str

text

The sentence, naming the rewrite.

TYPE: str

kind instance-attribute #

subject instance-attribute #

text instance-attribute #

math_spec.AdviceKind = Literal['never-an-axis', 'unbounded'] module-attribute #

Errors#

math_spec.MathSpecError #

Bases: ValueError

Base class for every error this package raises on purpose.

math_spec.LanguageError #

Bases: MathSpecError

The model is not sayable in the language, or does not obey its rules.

math_spec.SchemaError #

Bases: LanguageError

What a load refuses: an unknown key, a bad dtype, a duplicate YAML key, an unparseable or unresolvable expression.

math_spec.DimensionError #

Bases: LanguageError

A dim-set rule was violated. Raised at load time, before any data.

Names#

math_spec.BUILTIN_NAMES = frozenset(BUILTINS) module-attribute #

math_spec.did_you_mean(name, known, *, label='Declared') #

The repair clause for an unrecognised name: the near miss, or the set.

Source code in src/math_spec/errors.py
def did_you_mean(name: str, known: Iterable[str], *, label: str = 'Declared') -> str:
    """The repair clause for an unrecognised name: the near miss, or the set."""
    candidates = sorted(known)
    near = difflib.get_close_matches(name, candidates, n=1, cutoff=0.6)
    if near:
        return f"Did you mean '{near[0]}'?"
    return f'{label}: {", ".join(candidates) or "nothing"}.'