Skip to content

math_spec.typesetting.symbols

Which symbol each declared name prints as, and the sidecar that overrides it.

This module decides which symbol a name gets; a :class:~math_spec.typesetting.format.Format decides how it is written.

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(schema) #

Reject entries naming nothing in schema 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. The expansion is built only where an entry needs it.

Source code in src/math_spec/typesetting/symbols.py
def checked_against(self, schema: Spec) -> SymbolTable:
    """Reject entries naming nothing in *schema* 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. The
    expansion is built only where an entry needs it.
    """
    dims = set(schema.dimensions)
    everything = dims | _declared(schema)
    if set(self.names) - everything:
        everything |= _declared(schema.expand())
    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()},
    )

Symbols(schema, fmt, table) #

How every declared name prints: overrides first, derivation for the rest.

Name symbols settle before dimension indices, so an index is kept off a single letter a variable owns — a dimension plant beside a variable p would otherwise render p_{t,p}. A parameter is upright, so \mathrm{p} beside an index p is not a collision.

RAISES DESCRIPTION
SchemaError

If table is written in a notation fmt does not read.

Source code in src/math_spec/typesetting/symbols.py
def __init__(self, schema: Spec, fmt: Format, table: SymbolTable) -> None:
    if table.notation != fmt.notation:
        msg = (
            f'symbol table: written in {table.notation}, but this is a {fmt.notation} render '
            f'and nothing translates between notations — write a {fmt.notation} table.'
        )
        raise SchemaError(msg)
    chosen = frozenset(schema.variables) | chosen_expressions(schema)
    names = (*schema.parameters, *schema.variables, *schema.expressions)
    declared = frozenset(names)

    #: Names the table spelled; the convention note quotes only derived symbols.
    self.overridden = frozenset(table.names) & declared
    self.name: dict[str, str] = {
        name: table.names[name]
        if name in table.names
        else _derive_name_symbol(name, declared, fmt, given=name not in chosen)
        for name in names
    }
    spoken_for = {s for s in self.name.values() if len(s) == 1}

    #: Each constraint's symbol, the subscript ``dual(c)`` prints λ against.
    #: Off the flat namespace, like the constraints themselves — a model may
    #: name a constraint after a variable, so this is its own map rather than
    #: an entry in :attr:`name`. Given structure, so upright unless a table
    #: overrides it.
    self.constraint: dict[str, str] = {
        name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt, given=True)
        for name in schema.constraints
    }

    self.index: dict[str, str] = {}
    self.set: dict[str, str] = {}
    taken_index, taken_set = set(spoken_for), set()
    for dim in schema.dimensions:
        overridden = dim in table.indices
        letter = table.indices[dim] if overridden else _first_free(_index_candidates(dim), taken_index)
        taken_index.add(letter)
        self.index[dim] = letter if len(letter) <= 1 or overridden else fmt.upright(letter)
        upper = _first_free(_set_candidates(dim, letter), taken_set)
        taken_set.add(upper)
        self.set[dim] = table.sets[dim] if dim in table.sets else fmt.script(upper)

constraint = {name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt, given=True) for name in schema.constraints} instance-attribute #

index = {} instance-attribute #

name = {name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt, given=name not in chosen) for name in names} instance-attribute #

overridden = frozenset(table.names) & declared instance-attribute #

set = {} instance-attribute #

chosen_expressions(schema) #

The named expressions the solver decides, rather than is handed.

A when does not move one: a variable there asks whether the variable exists, which the model settles when it is built. Only a value reaching a variable does — through another named expression too, since expansion inlines those where the name stood. A dual moves one for the same reason a variable does: the solve settles it, and no data hands it over.

Source code in src/math_spec/typesetting/symbols.py
def chosen_expressions(schema: Spec) -> frozenset[str]:
    """The named expressions the solver decides, rather than is handed.

    A ``when`` does not move one: a variable there asks whether the variable
    *exists*, which the model settles when it is built. Only a value reaching a
    variable does — through another named expression too, since expansion
    inlines those where the name stood.
    A ``dual`` moves one for the same reason a variable does: the solve settles
    it, and no data hands it over.
    """
    return frozenset(
        name
        for name, node in schema.resolved.expressions.items()
        if degree.carries_variable(node) or degree.calls_dual(node)
    )