math_spec.program
The program: what a file declares, with names resolved and shapes fixed.
A :class:Program is a complete declarative description of a mathematical
program — every declaration a file makes, and no data in it at all. Data is
bound against these declarations by whatever builds the model;
:func:~math_spec.lowering.to_program is what produces one from a spec.
It is the second public state, and the one a consumer reads. A
:class:~math_spec.model.Spec is what the file says; a program is what it
means, with macros expanded, names typed, operators resolved to nodes and
every dim rule already checked. Consumers dispatch on these nodes and read
them; nothing here is built by hand, so what ships beside the nodes is the
walk (:func:children), not builders. A program is trusted by construction:
:func:~math_spec.lowering.to_program is the only thing that builds one, and
nothing checks one assembled by hand. The language's refusals happen at load,
where the file and its author are, and a program put together some other way
is outside that guarantee rather than inside a pass restating it.
What a consumer needs from this module falls in three, and only the middle one has to be called to be got right:
- Types to match on — every node and declaration class, the
:data:
ExpressionNodeunion, and theLiteralvocabularies. A backend dispatches on these and calls none of them. - Rules to call — :func:
childrenand :func:fan_in. Neither is visible in a node's own structure, so a consumer deriving them derives them wrongly the day a node is added. - Questions over the walk — :func:
walkand the filters beside it. Each is a line a consumer could write; they are here so two consumers cannot write it differently.
A mask arrives as a :class:Mask: the language's own resolved where node
(the :data:WhereNode vocabulary below) as its root, with the questions
the language answers about it carried beside it — one home, so two consumers
cannot come to disagree about what a comparison is or which dims a mask
restricts. Its literals are already decided: a declaration's mask admitting
every row arrives as None, one admitting none with
BooleanLiteralNode(False) as its root, and a case arm whose mask folds to
a literal is refused at load — nothing the data decides is left in it. A mask
a consumer derives (~, &, |) may fold to the
always-true literal, the algebra being total over masks; construction folds,
so a boolean literal stands at the root of a mask or nowhere in it, and no
consumer needs a constant folder of its own to agree with the others about
which rows exist.
The declaration vocabularies are the language's own for the same reason
(:mod:math_spec.model): a dtype, a domain and an absence reading cross
into a program by a cast, and a member added to one spelling alone would
arrive as a string no consumer's branch recognises.
Frozen dataclasses only — no execution logic, and nothing imported from a consumer.
Expressions support operator sugar so programs read naturally in Python:
balance = GroupSum(Variable("p"), over="generator", coordinate=("bus",), into=("bus",)) - Parameter("load")
Check = Increasing | Curved | AtLeastTwo | Contiguous
module-attribute
#
ConnectiveWhereNode = NotNode | AndNode | OrNode
module-attribute
#
ConstraintSense = Literal['==', '<=', '>=']
module-attribute
#
Derivation = MaskOf | FirstOf | LastOf
module-attribute
#
DimensionDtype = _model.DimensionDtype
module-attribute
#
ExpressionNode = Constant | Parameter | Variable | Negate | Add | Multiply | Power | Divide | Sum | GroupSum | At | Translate | Window | Cases
module-attribute
#
FanIn = Literal['one-to-one', 'many-to-one', 'one-to-many']
module-attribute
#
ObjectiveSense = Literal['minimize', 'maximize']
module-attribute
#
ParameterDtype = _model.ParameterDtype
module-attribute
#
PredicateOperator = Literal['<=', '>=', '==', '!=', '<', '>']
module-attribute
#
QUADRATIC_POSITIONS = frozenset(get_args(QuadraticPosition))
module-attribute
#
QuadraticPosition = Literal['objective', 'constraint']
module-attribute
#
TypedPredicateNode = ParameterComparisonNode | ParameterDefinedNode | VariableDefinedNode | DimensionComparisonNode | DimensionPositionNode | LookupComparisonNode | LookupPairComparisonNode | LookupDefinedNode
module-attribute
#
VariableAbsence = _model.VariableAbsence
module-attribute
#
VariableType = _model.VariableDomain
module-attribute
#
WhereNode = BooleanLiteralNode | DimensionPositionNode | ParameterDefinedNode | VariableDefinedNode | ParameterComparisonNode | DimensionComparisonNode | LookupComparisonNode | LookupPairComparisonNode | LookupDefinedNode | NotNode | AndNode | OrNode
module-attribute
#
Add(left, right)
dataclass
#
Bases: Expression
At(operand, over, coordinate, into)
dataclass
#
Bases: Expression
Read operand through a lookup — the adjoint of :class:GroupSum.
Same mapping table, walked the other way: GroupSum consumes over
and produces into, this consumes into and produces over. The
fields are named for the table rather than the direction, so the pair
reads as one relation; the surface says which end you stand on
(sum(by=) consumes it, at(by=) produces it, the lookup names the map).
The join fans out, many over labels sharing one into tuple — the
fan-out GroupSum pays in reverse, so the locality class is unchanged.
AtLeastTwo(over, mask)
dataclass
#
Cases(regions)
dataclass
#
Bases: Expression
A value defined by region — exactly one region applies at each coordinate.
The language proves the regions apart before any data binds, and the
file's otherwise: covers whatever the rest leave, so they are disjoint
and total by construction: a consumer adds the regions rather than ranking
them, and needs neither an order nor a tie-break.
Not a shape operator — every region spans the dims the expression does, and this neither reduces nor replicates. What it adds is the one thing no other node here carries: a mask in a value position. A consumer that can restrict rows but cannot weigh a term by a predicate builds each region against its own mask and adds the results.
regions
instance-attribute
#
Constant(value)
dataclass
#
ConstraintDeclaration(dims, lhs, sense, rhs, where=None)
dataclass
#
lhs sense rhs for each coord combination of dims.
Either side may carry variables and constants alike; which side a
consumer gathers them onto is its own arrangement and not stated here.
where masks out coord combinations (row absence, like variables).
Contiguous(mask, values)
dataclass
#
Curved(x, y, over, curvature)
dataclass
#
DimensionComparisonNode(name, op, value)
dataclass
#
DimensionDeclaration(lookups=(), dtype='str')
dataclass
#
A dimension and the lookups its labels carry, of both kinds.
dtype = 'str'
class-attribute
instance-attribute
#
lookups = ()
class-attribute
instance-attribute
#
maps
property
#
Every map over the dimension, targeted and label-space alike.
What binding needs a relation for: both kinds are read by a where
and both arrive the same way, and only the targeted ones have a label
set to be checked against.
targets
property
#
Each targeted map over the dimension, to the dimension its values are labels of.
The question every consumer of a by= asks, and asked here so it has
one answer: an operator grouping through a lookup names the target as
the dim it lands on, and a partition array is named for it so an amount
declared over the group's own dim can be read through it.
DimensionPositionNode(name, op, position, by=None)
dataclass
#
Compare where a row sits along a dimension against a position — position(snapshot) == 0.
Both sides are integers, negative counting from the end; comparing
coordinates against the label at a position would read differently on an
axis whose coordinates do not arrive sorted (#32). With by the position
is counted within each group the lookup makes.
Divide(numerator, divisor)
dataclass
#
Bases: Expression
Quotient numerator / divisor. The divisor must be variable-free.
Expression()
dataclass
#
Base class for expressions over variables and parameters.
Affine everywhere but the objective, where a :class:Multiply of two
variable-carrying operands is degree 2; which position allows what is
math_spec.degree's to say and no node here records.
The four operators exist for the tests that compose plans by hand; constructing Programs in Python is not supported API, so there is no scalar coercion and no reflected form.
FirstOf(block, mask)
dataclass
#
Footprint(quadratic, variable_types, sos_types, shapes)
dataclass
#
Which of the language's constructs one program actually reaches for.
A subset, never the whole: the language admits more than any one file
uses, and an empty field says this program does not use that construct —
not that the construct does not exist. Every field is a set, so
if footprint.x asks whether it appears at all and y in footprint.x
asks about one kind, and a construct admitted later widens a set rather
than needing a field a consumer does not yet read.
Facts only. What a sink can ingest is a separate axis
(docs/about/ceiling.md, "Capability is not the ceiling"), where a
capability is neither a flat set nor one verdict per construct — so there
is deliberately no verdict here to read instead of giving one.
Nothing below the kind, either: a sink that takes a window but not a
wrapped one reads Window in shapes and then walks, because wrap,
partition and a named width are refinements without end and each is one
line once the set has said where to look.
| ATTRIBUTE | DESCRIPTION |
|---|---|
quadratic |
Each position a product of two variable-carrying operands
stands in. Empty is affine throughout. Convexity is not here: it is
a property of the whole Hessian rather than of any term, and the
coefficients deciding it arrive with the data — so, as with a
curve's shape (:class:
TYPE:
|
variable_types |
Every domain declared,
TYPE:
|
sos_types |
The order of each special-ordered set declared. Empty where the file declares none. |
shapes |
Every expression node kind that appears, complete rather than curated — picking the interesting ones would be the judgement this leaves to the consumer, and a node added later is reported without anyone remembering a filter.
TYPE:
|
GroupSum(operand, over, coordinate, into)
dataclass
#
Bases: Expression
Sum operand through coordinates declared on dim over.
coordinate names coordinates carried by dim over whose values are
labels of the matching dim in into; the result replaces over with
all of them.
into restates each coordinate's declared target, because a node is
read on its own — a consumer places terms from one without consulting the
program — and lowering is the only thing that writes it.
Several coordinates are one grouping into a product of targets, not a composition of groupings — they are consumed in a single join, so the pair of tuples is always the same length and their order pairs them up.
Increasing(parameter, over)
dataclass
#
LastOf(block, mask)
dataclass
#
LookupComparisonNode(name, over, op, value)
dataclass
#
Compare a lookup's values against a literal — period_of == 2030.
over is the dimension the lookup maps out of, copied off the
declaration during resolution so the frame check and every consumer read it
here rather than looking the lookup up again.
LookupDeclaration
#
Bases: NamedTuple
One declared lookup over a dimension, of either kind.
Exactly one of target and dtype is set. A targeted lookup's
values are labels of target, checked for containment once the dim
tables exist — which keeps a mistyped label from silently dropping its
terms in the join that places them — and it is what sum(by=) lands
terms on. A label space owns its values, typed by dtype the way a
dimension's labels are: it is read for selection and rendering, and
resolution refuses to group into one, so no expression node reaches it.
LookupDefinedNode(name, over)
dataclass
#
True where the named lookup has a value — the partial-lookup case.
A lookup may be partial: a null says the label belongs to no group (a generator on no bus, a line with one open end). This is how a declaration asks for the labels that do map, spelled as a bare name exactly as a parameter's definedness is.
LookupPairComparisonNode(name, other, over, op)
dataclass
#
Compare two lookups over one dimension — from != to.
The one comparison whose both sides are structure: two maps out of the same dimension, tested row by row on that dimension's own table. Over different dims there is no row to compare them on, which resolution refuses.
Mask(root)
dataclass
#
A resolved where and the questions the language answers about it.
root is the predicate an engine dispatches on with isinstance to
build the mask against data; every question is derived from it, so a mask
cannot disagree with itself. Wrap any resolved predicate — a declaration's
own, or one built from resolved pieces (~, &, |) —
and ask it here, so two consumers cannot answer differently.
Construction folds: a literal or a double negation a connective decides is
evaluated away, so a boolean literal stands at the root or nowhere, and a
consumer can check emptiness in O(1). Construction also refuses an
unresolved tree outright — parse_where's annotation over-claims, and a
mask that silently answered no atoms, no names and no dims for one would
be the divergence this class exists to prevent.
| ATTRIBUTE | DESCRIPTION |
|---|---|
root |
The resolved predicate the mask restricts rows by, folded.
TYPE:
|
atoms
property
#
The mask's leaves, connectives removed.
conjuncts
property
#
The predicates the mask joins with AND — its AND spine flattened, stopping at an OR or a NOT.
dims
property
#
The dims the mask is read at — the union of what each leaf carries.
Empty for a mask over nothing but literals. Read off the leaves, which resolution stamped with their declarations' dims, so a predicate built from resolved pieces answers exactly as a declaration's own does.
names_read
property
#
The parameters, lookups and variables the mask names.
root
instance-attribute
#
MaskOf(block, values)
dataclass
#
Multiply(left, right)
dataclass
#
Bases: Expression
Product of two operands.
Affine where at least one factor is variable-free. Degree 2 where neither
is, which the language allows in the objective alone
(math_spec.degree) — so a consumer that cannot represent a quadratic
term is told which position it is compiling rather than assuming it.
Negate(operand)
dataclass
#
Bases: Expression
operand
instance-attribute
#
ObjectiveDeclaration(sense, expression)
dataclass
#
Parameter(name)
dataclass
#
Bases: Expression
A parameter reference — contributes to the constant part.
name
instance-attribute
#
ParameterComparisonNode(name, op, value, dims)
dataclass
#
ParameterDeclaration(dims, dtype='float', derivation=None)
dataclass
#
Shape declaration; data is bound at execution time by name.
dtype is what the declaration claims the values are, and a consumer
binding data refuses a column that is not it — so the declaration is
what is read, rather than whatever the column happens to hold.
ParameterDefinedNode(name, dims)
dataclass
#
True wherever the named parameter is non-null and finite.
dims is the parameter's own, copied off the declaration during
resolution the way a lookup leaf carries over — so a consumer reads the
dims a leaf is read through here rather than looking the declaration up
again.
PiecewiseDeclaration(over, method, breakpoints, checks)
dataclass
#
A piecewise: block, kept as the facts a consumer binding its data reads.
The expansion lowered the links into constraints and emitted the
parameters it needs — each of those says how it is filled, on its own
:attr:ParameterDeclaration.derivation. What is left here is the curve
and what the block assumes of it.
| ATTRIBUTE | DESCRIPTION |
|---|---|
over |
The breakpoint dimension.
TYPE:
|
method |
How the weights are restricted.
TYPE:
|
breakpoints |
The links' values parameters, in link order. |
checks |
What the block assumes of the numbers, each carrying its own subjects, for the consumer holding them to check. |
Power(base, exponent)
dataclass
#
Bases: Expression
base ** exponent, both variable-free.
Degree 0 in variables wherever it appears, so no consumer has to ask what
position it stands in: the language refuses a variable anywhere under it
(math_spec.degree), which is what lets this fold to one number per
coordinate like any other parameter arithmetic.
Program(*, parameters, variables, constraints, objective, dimensions=MappingProxyType({}), sos=MappingProxyType({}), piecewise=MappingProxyType({}), named_expressions=MappingProxyType({}))
dataclass
#
A complete declarative description of a mathematical program, with no data in it.
Every group of declarations is keyed by the name the file wrote, in the order it wrote them, and is read-only: the mappings are wrapped at construction, so a consumer cannot rewrite what another consumer reads. A whole program is not hashable — the declarations and expression nodes inside it are, which is what dedup and memoisation ask for.
constraints
instance-attribute
#
dimensions = MappingProxyType({})
class-attribute
instance-attribute
#
expressions
property
#
Every expression a row is built from — the objective and both sides of each constraint.
What a walk over the program a solver sees takes. A declared
:attr:named_expressions entry is not among them: it builds no row, so
a question asked about what will be solved would answer wrongly if it
counted one.
footprint
cached
property
#
Which constructs this program uses — walked once, then held.
Safe to hold: a program cannot change after construction, its groups being sealed and every node under them frozen.
lookups
property
#
Every targeted map in the program, with the dimension it is over.
One walk for the several shapes consumers want it in — name to target, target to origin, the set of targets — because the nested comprehension that produces any of them is the same walk written again.
named_expressions = MappingProxyType({})
class-attribute
instance-attribute
#
objective
instance-attribute
#
parameters
instance-attribute
#
piecewise = MappingProxyType({})
class-attribute
instance-attribute
#
sos = MappingProxyType({})
class-attribute
instance-attribute
#
variables
instance-attribute
#
dimension(name)
#
parameter(name)
#
Region(when, value)
dataclass
#
One region of a :class:Cases: where it applies, and the value there.
when is stated on every region, the one the file wrote as
otherwise: included — its mask is the negation of the others, resolved
once here rather than by each consumer in turn. A consumer builds a region
without holding the rest in mind, and both facts it needs are on the region
it is reading. The mask is a :class:Mask, the same carrier a
declaration's where arrives in.
SosDeclaration(variable, over, sos_type, big_m=None)
dataclass
#
One special-ordered set per coordinate of the variable's foreach minus over.
The only declaration that adds neither a column nor a row: it names
columns a consumer already has and says what may be nonzero among them. Which
dims those are is the variable's own foreach and is read from it: a
copy here would be a second home for a fact
(:meth:Program.variable).
big_m caps the linking coefficient a consumer without the concept
reformulates with, and is None where the variable's own upper bound is
the only cap.
Sum(operand, over)
dataclass
#
Bases: Expression
Sum operand over the named dims, removing them from the result.
Translate(operand, dimension, offset, wrap, fill=None, partition=None)
dataclass
#
Bases: Expression
Re-index along one dimension: the result at t is operand at t - by.
One node for the whole of shift, whose edge= decides wrap:
edge='wrap' is periodic, absent or numeric is not.
wrap carries no default, on this node or on :class:Window. Whether an
axis closes onto itself is the difference between a battery that must end
as it started and one that need not, and there is no reading of a
translation that leaves it unsaid — a node that guessed would be answering
for the file.
fill decides what an acyclic shift leaves behind. None, what bare
shift lowers to, leaves the vacated positions absent: they carry no
value, the absence rules propagate that, and the row drops. A number makes
them present and contribute it, which is the only way a file can say
"before the axis starts, read zero" without inventing coordinates. Always
None under wrap, a cyclic map vacating nothing.
offset is how far back to reach: an integer, or the name of an integer
parameter when it differs per entity — a construction lead time, a transit
time, a minimum up time. A named offset may not depend on the dimension
being translated, and carries its sign in the values.
partition names a lookup over dimension, and then the translation
happens inside each group it makes: the neighbour of a coordinate is the
one before it in its own group, the edge is that group's edge, and a wrap
closes each group onto itself. A coordinate the lookup sends nowhere is in
no group and reaches nothing.
Variable(name)
dataclass
#
Bases: Expression
A variable reference — one term per existing variable row.
name
instance-attribute
#
VariableDeclaration(dims, where=None, lower=(lambda: Constant(float('-inf')))(), upper=(lambda: Constant(float('inf')))(), variable_type='continuous', absence='undefined')
dataclass
#
absence = 'undefined'
class-attribute
instance-attribute
#
dims
instance-attribute
#
lower = field(default_factory=lambda: Constant(float('-inf')))
class-attribute
instance-attribute
#
upper = field(default_factory=lambda: Constant(float('inf')))
class-attribute
instance-attribute
#
variable_type = 'continuous'
class-attribute
instance-attribute
#
where = None
class-attribute
instance-attribute
#
VariableDefinedNode(name, dims)
dataclass
#
True at the coordinates where the named variable exists.
The variable counterpart of :class:ParameterDefinedNode, and spelled the
same way — a bare name. A parameter's bare name asks whether it has a value
here; a variable's asks whether it exists here. dims is the variable's
frame, copied off the declaration during resolution.
Window(operand, dimension, width, wrap, partition=None)
dataclass
#
Bases: Expression
Sum operand over a trailing window along one dimension.
The result at t is the sum of the operand at every position from t - width + 1 through t, so a width of 1 is the operand itself. The dimension survives: this replicates terms onto the positions that can see them rather than reducing anything away.
width is a whole number, or the name of an integer parameter when the
window differs per entity — a minimum up time, a rolling budget, a delivery
horizon. A named width may not depend on the dimension being summed over.
wrap says whether the window reaches around the start of the axis
instead of stopping short at it, and is stated at every construction for
the reason :class:Translate gives.
partition names a lookup over that dimension, and the window then stops
at each group's edge: a representative day, a season, a scenario's own run
of hours. Positions are counted inside the group rather than along the
axis, so a coordinate the lookup places nowhere reaches nothing at all —
not even itself.
One node rather than a sum of Translates, because the number of terms
would then be read from data and the program's shape is fixed before any
data is bound. What data supplies is the mask's cardinality, exactly as it
supplies how many snapshots there are.
carries_variable(expression)
#
check_message(block, pw, check)
#
The sentence a consumer raises when the data bound to block fails check.
The language's own wording, so every consumer refuses in the same words; a consumer appends what it saw.
Source code in src/math_spec/program.py
children(expression)
#
The sub-expressions of expression — the structural half of any walk.
Every walk over a program's expressions recurses through here and differs only in what it does at the leaves. Enumerating the children once is how a node added later reaches all of them rather than one.
Source code in src/math_spec/program.py
divisor_parameters(*expressions)
#
Parameters appearing anywhere in a divisor position.
Static, like :func:parameters_of: which names can reach a divisor is
the program's to answer, and where they must have values is decided by the
rows a declaration builds.
Source code in src/math_spec/program.py
fan_in(expression)
#
How expression's output rows relate to its input slots.
Total over the node set, so a consumer asks any node rather than keeping a list of which kinds carry the answer. Arithmetic and the leaves reshape nothing, which is one slot for one row — the same class a pullback and a translation are in, reached for a different reason.
Exhaustive rather than defaulted: a node added without a case here is a type error at this function, where the absence rule it needs is decided, instead of silently inheriting the class that reshapes nothing.
:class:Cases is in that class too, for a reason of its own: its regions
are disjoint, so an output row reads exactly one of them — the several
values it holds are alternatives rather than slots summed together.
Source code in src/math_spec/program.py
is_quadratic(expression)
#
Whether expression contains a product of two variable-carrying operands.
A structural question over the program, and unrelated consumers ask it — what a solver must support, which declarations to build last, whether this form can be represented at all — so it is answered once here beside the other walks rather than once per consumer in its own terms.
Whether a degree may be written is the language's verdict, and this is not a second opinion on it: by the time a program exists the question is which shape the expression has, and the program is what is in hand to answer it.
Source code in src/math_spec/program.py
parameters_of(*expressions)
#
Every parameter named anywhere under expressions.
quotients(*expressions)
#
Every division under expressions, each kept whole.
The divisor and the numerator answer different questions and one consumer
needs them paired: a divisor is judged against the rows the declaration
builds narrowed by the variables in its own numerator, which the flat
:func:divisor_parameters cannot say.
Source code in src/math_spec/program.py
variables_of(*expressions)
#
Every variable named anywhere under expressions.
walk(*expressions)
#
Every node under expressions, each expression itself included, parents first.
The traversal every question about a program is a filter of — which names
it mentions, whether a variable stands under it, which divisions it
contains. One generator rather than that five-line recursion once per
question: how a program is traversed is one fact, so a node kind
:func:children learns to descend into reaches every caller at once
rather than the callers that remembered.