Skip to content

math_spec.typeset.symbols

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

Derivation aims at unambiguous, not beautiful: it runs with no setup, so it has to be right rather than elegant. :class:SymbolTable is where a reader makes it conventional, in a file of its own — presentation is not language, so it never becomes keys on Model. What a declaration is travels the other way: description: is a key on the declaration, because it is the model talking about itself rather than a reader choosing notation.

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

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

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

Presentation is not language: nothing here changes what the file means, no lane reads it, and a model with no table still renders. Notation is all it carries — what a declaration is is the model's own description:, which travels with the declaration and reaches every consumer.

Every entry is a spelling, printed verbatim — nothing parses or translates notation. 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}}"

Deliberately strict — an unrecognised name is an error naming the near miss, the failure mode of a silent typo being a symbol that never applies and a reader who never finds out.

ATTRIBUTE DESCRIPTION
notation

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

TYPE: str

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, with the near miss.

Source code in src/math_spec/typeset/symbols.py
def checked_against(self, schema: Buildable) -> SymbolTable:
    """Reject entries naming nothing in *schema*, with the near miss."""
    dims = set(schema.dimensions)
    everything = dims | set(schema.parameters) | set(schema.variables) | printed_expressions(schema)
    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 malformed dimension, or a notation: that is missing or not latex/typst.

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

    Raises:
        SchemaError: An unknown section, a malformed dimension, 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 ('latex', 'typst'):
        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 (raw.get('dimensions') or {}).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=notation,
        indices=indices,
        sets=sets,
        names={k: str(v) for k, v in (raw.get('names') or {}).items()},
    )

Symbols(schema, fmt, table, chosen=frozenset()) #

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

Assignment order is load-bearing. Name symbols settle before dimension indices, so an index can be kept off a letter a variable owns — derived independently, a model with a dimension plant and a variable p renders p_{t,p} and no reader can tell which p is which. Only single-letter name symbols are kept off the index letters, a \mathit{load} never colliding with a t.

Which now means variables, since a parameter is upright: a dimension may take p beside a parameter p, because \mathrm{p} and p are not the same symbol on the page. The guard shrank to exactly the collisions that are still collisions.

RAISES DESCRIPTION
SchemaError

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

Source code in src/math_spec/typeset/symbols.py
def __init__(
    self, schema: Buildable, fmt: Format, table: SymbolTable, chosen: frozenset[str] = frozenset()
) -> 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)
    printed = printed_expressions(schema)
    # quantities only — see `_derive_name_symbol` for why an axis is not a
    # head a qualifier may hang off. A cased expression is one of them: it
    # is a quantity the file names, which is why it prints at all.
    declared = frozenset({*schema.parameters, *schema.variables, *printed})
    # *chosen* is the cased expressions that reach a variable, which
    # `typeset` works out because it has the namespace to resolve an arm
    # with. Everything else the file names is given: a parameter, and a
    # cased expression whose every arm is one.

    #: Names whose symbol came from the table rather than the derivation.
    #: The convention note quotes only the others: a table is printed
    #: verbatim and is the author's to write, so a symbol it supplies is
    #: not one the note governs — the homepage's own table maps two
    #: parameters to italic symbols, and the note quoting one of those
    #: contradicted itself on the page.
    self.overridden = frozenset(table.names) & {*schema.parameters, *schema.variables}
    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 schema.variables and name not in chosen)
        for name in (*schema.parameters, *schema.variables, *printed)
    }
    spoken_for = {s for s in self.name.values() if len(s) == 1}

    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)

index = {} instance-attribute #

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

overridden = frozenset(table.names) & {*schema.parameters, *schema.variables} instance-attribute #

set = {} instance-attribute #

printed_expressions(schema) #

The named expressions that reach the page under their own name.

A named expression is substituted where it is used, so it normally prints nothing a symbol could stand for. A cased one is the exception: its value is defined by region, which reads as a definition of its own and is referred to by name from the equations that use it.

Source code in src/math_spec/typeset/symbols.py
def printed_expressions(schema: Buildable) -> frozenset[str]:
    """The named expressions that reach the page under their own name.

    A named expression is substituted where it is used, so it normally prints
    nothing a symbol could stand for. A **cased** one is the exception: its
    value is defined by region, which reads as a definition of its own and is
    referred to by name from the equations that use it.
    """
    return frozenset(name for name, block in schema.expressions.items() if block.cases)