From d0db1e11211e0fce72a5350bac1207732bdcd565 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 16:16:06 +0000 Subject: [PATCH 1/4] feat(language): a model writes its formulations out on request, and a curve prints as the curve it states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Spec.expand()` is public and takes 'piecewise', 'sos', or nothing for both. `sos:` is the second formulation: it states binaries and the rows that link them, `method: adjacency` is `method: sos2` written out, and `big_m:` is renamed `bound:` — the coefficient those rows link a member by, rather than the tighter of it and the member's own upper bound. A set is refused at load unless its members start at or above zero and one finite coefficient links them, so nothing about a set waits for data. The typesetter prints the model it was handed: a `piecewise:` block is one line — the links on the locus through its breakpoints — and `typeset(spec)` no longer expands. `_ExpandedSpec` is gone; `resolved` and the record of what a curve derived live on `Spec`, and `to_yaml` refuses on an expansion whose curves derived parameters. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGvzXkPDrzi6f1PNmvebhc --- docs/about/limits.md | 27 +-- docs/about/what-counts-as-language.md | 6 +- docs/howto/curve-by-hand.md | 2 +- docs/reference/language/piecewise.md | 95 +++++++++-- docs/reference/notation.md | 151 +++++++++-------- docs/reference/reading.md | 18 ++ docs/reference/typeset.md | 5 +- examples/symbols/piecewise.yaml | 9 +- examples/symbols/sos.yaml | 10 +- examples/symbols/transport_pwl.yaml | 8 +- schema/math-spec.schema.json | 8 +- src/math_spec/lowering.py | 30 ++-- src/math_spec/model.py | 228 ++++++++++++++++++++------ src/math_spec/piecewise.py | 66 ++++---- src/math_spec/program.py | 8 +- src/math_spec/resolution.py | 18 +- src/math_spec/sos.py | 139 ++++++++++++++++ src/math_spec/typesetting/__init__.py | 24 ++- src/math_spec/typesetting/format.py | 12 +- src/math_spec/typesetting/symbols.py | 8 +- src/math_spec/typesetting/walk.py | 117 ++++++++++++- src/math_spec/validation.py | 16 +- tests/test_boundedness.py | 9 +- tests/test_expand.py | 98 +++++++++++ tests/test_piecewise.py | 44 ++--- tests/test_public_surface.py | 16 +- tests/test_reading_page.py | 2 +- tests/test_separability.py | 2 +- tests/test_sos.py | 115 +++++++++++++ tests/test_validation.py | 42 ++++- tests/typesetting/golden/latex.out | 20 ++- tests/typesetting/golden/markdown.out | 51 ++++++ tests/typesetting/golden/model.yaml | 41 +++++ tests/typesetting/golden/typst.out | 20 ++- tests/typesetting/test_declaration.py | 6 +- tests/typesetting/test_golden.py | 15 +- tests/typesetting/test_walk.py | 105 +++++++++++- tools/notation.py | 50 +++--- 38 files changed, 1310 insertions(+), 331 deletions(-) create mode 100644 src/math_spec/sos.py create mode 100644 tests/test_expand.py create mode 100644 tests/test_sos.py diff --git a/docs/about/limits.md b/docs/about/limits.md index a95a0fb3..a0ba7c68 100644 --- a/docs/about/limits.md +++ b/docs/about/limits.md @@ -23,9 +23,13 @@ costs to add. `at`, `shift`, and the `where` comparisons. A file cannot add one. Adding one here is the expensive kind: every engine that builds models has to implement it, and the typesetter has to print it in LaTeX, Typst and Markdown. -- **A formulation** is a block that expands into ordinary variables and - constraints before the model is built. `piecewise:` is the only one. It costs - as much as a primitive to build, but composes as freely as a macro. +- **A formulation** is a block that states ordinary variables and constraints + rather than being one. `piecewise:` and `sos:` are the two. It costs as much as + a primitive to build, but composes as freely as a macro. A formulation emits + variables and constraints, and any parameter it emits it derives — so the same + data binds a model and its expansion, and + [`spec.expand()`](../reference/language/piecewise.md#writing-a-formulation-out) + needs no source a reader has to supply. A request that is none of the three is refused, and the [table of refusals](#deliberate-non-primitives) records it with what to write @@ -66,11 +70,11 @@ the same model written out by hand. ### Three kinds of refusal -| The language refuses it because… | Examples | Can it change? | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | -| **one solver cannot take it** | indicator constraints; a quadratic constraint. `sos:` was in this group, and entered: a solver with sets takes it as one, and a solver without gets binaries | yes, solver by solver | -| **the file would stop being the artifact** | arbitrary Python, whose content no loader can check and no typesetter can print | no | -| **this project puts the work elsewhere** | data preparation such as resampling; helpers for one domain; Python that decides which declarations exist | it could; this project does not want it to | +| The language refuses it because… | Examples | Can it change? | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| **one solver cannot take it** | indicator constraints; a quadratic constraint. `sos:` was in this group, and entered: a solver with sets takes it as one, and a model for a solver without is written out first | yes, solver by solver | +| **the file would stop being the artifact** | arbitrary Python, whose content no loader can check and no typesetter can print | no | +| **this project puts the work elsewhere** | data preparation such as resampling; helpers for one domain; Python that decides which declarations exist | it could; this project does not want it to | Three things never appear inside one model: an `if`, a loop, and a set of declarations that depends on the data. A dimension computed before the model @@ -89,13 +93,14 @@ answer it. If it did, one solver's limits would be written into the language, and every other solver would inherit them. - HiGHS has no special-ordered sets. Gurobi does. An engine handing a model to - HiGHS rewrites each set as binaries and big-M rows; one handing it to Gurobi - passes the set through. + Gurobi passes the set through; one handing it to HiGHS refuses it, and the + author writes the set out with `spec.expand('sos')` first. - A quadratic constraint is accepted by some solvers only when it is convex, and convexity depends on the numbers, which the file does not have. So `sos:` entered the language on the first question alone. Each engine then -decides how to hand it to its solver, and reports which it did. +decides whether it takes a set, and the language decides what a set is written +out as. ## What counts as data preparation diff --git a/docs/about/what-counts-as-language.md b/docs/about/what-counts-as-language.md index 8e4ca26e..f0f52271 100644 --- a/docs/about/what-counts-as-language.md +++ b/docs/about/what-counts-as-language.md @@ -34,8 +34,10 @@ Four rules follow from the test: - Degree is decided when the file loads. Whether `x * y` is allowed does not depend on which engine builds the model. -A `piecewise:` block expands into ordinary variables and constraints, so the -language decides that expansion too. +A `piecewise:` block and a `sos:` block each state ordinary variables and +constraints, so the language decides what they state, and +[`spec.expand()`](../reference/language/piecewise.md#writing-a-formulation-out) +writes it out the same way for every tool. ## What each tool decides for itself diff --git a/docs/howto/curve-by-hand.md b/docs/howto/curve-by-hand.md index c298d8c4..caa05314 100644 --- a/docs/howto/curve-by-hand.md +++ b/docs/howto/curve-by-hand.md @@ -26,7 +26,7 @@ file, so it cannot say this. The formulation written out can. ```yaml sos: - on_one_segment: { variable: weight, over: bp, type: 2, big_m: 1 } + on_one_segment: { variable: weight, over: bp, type: 2 } ``` 3. **Write the convexity row, and one row per flow.** The row per flow is where diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 92e84a85..bd307a6c 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -9,6 +9,10 @@ Two blocks state shapes that no `expression:` can, because an expression is affine. `piecewise:` states a curve through breakpoints. `sos:` states a family of variables of which only one, or only two neighbours, may be non-zero. +Both are **formulations**: each states plain variables and constraints rather +than being one, and [`spec.expand()`](#writing-a-formulation-out) writes them +out. + ## `piecewise` A `piecewise` block ties two or more expressions to one piecewise-linear curve. @@ -48,11 +52,12 @@ piecewise: | `activity` | a binary variable that gates the curve ([below](#activity)) | default `null` | | `points` | how far each curve runs, where the curves are not all the same length ([below](#points)) | default `null` | -A block expands before building, into plain variables and constraints: one -weight per breakpoint in `[0, 1]`, one row making the weights sum to 1, and one -row per link tying its expression to the weighted breakpoints. That expansion -is what the rest of the model sees, and what the -[typeset output](../typeset.md) prints. +A block states plain variables and constraints: one weight per breakpoint in +`[0, 1]`, one row making the weights sum to 1, and one row per link tying its +expression to the weighted breakpoints. A `Program` holds those rows, because a +consumer builds them; the [typeset output](../typeset.md) prints the curve +itself, and [`spec.expand()`](#writing-a-formulation-out) is what writes the +rows into a model of their own. The breakpoint order is the declared order of `over`. A curve whose breakpoints decrease in that order is refused when the data binds. @@ -107,15 +112,16 @@ the axis. A gap, or a curve with no points, is refused when the data binds. `method` says how the weights are restricted once they exist. -| `method` | What it adds | | -| ----------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `adjacency` _(default)_ | a binary per segment, and `lam <= seg + shift(seg, along=bp, offset=1, edge=0)` | the curve, built | -| `sos2` | an [`sos:`](#sos) block over the same weights | the curve, stated for a solver that branches on the set itself | -| `convex` | nothing | the hull, which is a pure linear program | -| `lp` | no weights at all: one row per segment line, plus two rows holding the domain | the curve as its own lines | +| `method` | What it adds | | +| ----------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `adjacency` _(default)_ | an [`sos:`](#sos) block over the weights, written out as binaries | the curve, built | +| `sos2` | an [`sos:`](#sos) block over the weights, left as a set | the curve, stated for a solver that branches on the set itself | +| `convex` | nothing | the hull, which is a pure linear program | +| `lp` | no weights at all: one row per segment line, plus two rows holding the domain | the curve as its own lines | `adjacency` and `sos2` state the same restriction and reach the same optimum. -They differ in what the solver is handed. +They differ in what the solver is handed: `adjacency` **is** `sos2` with the set +written out, so the two emit the same rows under the same names. `convex` is a different model. It is exact only for a curve whose curvature matches the optimisation pressure, and that match is checked against the @@ -154,7 +160,7 @@ sos: variable: build # the variable the set is over over: size # the dimension it runs along — one set per coordinate of the rest type: 1 # 1: at most one non-zero; 2: at most two, and consecutive - big_m: 500 # optional, and only read by a solver that has to reformulate + bound: 500 # optional: the coefficient the set's own expansion links a member by ``` `type: 1` is a choice: at most one member is non-zero. `type: 2` is an @@ -167,8 +173,61 @@ Membership belongs to the variable. Its `where` decides which coordinates exist, so a masked-out member is not in the set. The order is the declared order of the `over` dimension. -A solver with no concept of a set is handed binaries and big-M rows instead. -That rewrite is mixed-integer, so it gives up its duals, and it needs a finite -M: every member needs a `bounds.upper` or a `big_m:`, and a negative -`bounds.lower` is refused. A model that fails those conditions still solves on -a solver that takes the set, and the message says so. +### What a set is written out as + +`spec.expand('sos')` states the set as binaries: one per member for `type: 1`, +one per segment for `type: 2`. The names are the block's own, and the rows are +these, for a set `s` over variable `x` along `d`: + +| `type` | Emitted | +| ------ | ------------------------------------------------------------------------------- | +| both | `s_seg`, a binary over `x`'s own dims, masked as `x` is | +| both | `s_pick`: `sum(s_seg, over=d) <= 1` | +| `1` | `s_nonzero`: `x <= bound * (s_seg)` | +| `2` | `s_adjacency`: `x <= bound * (s_seg + shift(s_seg, along=d, offset=1, edge=0))` | + +The coefficient is the block's `bound:` where it declares one, and the member's +own `bounds.upper` otherwise. A binary member's is 1, from its domain. + +The rewrite states the same feasible set as the set itself only for a member at +or above zero linked by a finite coefficient, so a model is refused at load +unless both hold: + +- `bounds.lower` is a number of at least zero. A parameter there is data, and + whether it is at or above zero is not decidable without it. +- the set declares `bound:`, or the member declares `bounds.upper`, or the + member is `domain: binary`. + +A name the expansion writes that the file already declares is refused at load +too. + +## Writing a formulation out + +`Spec.expand()` returns the same math with its formulations stated as plain +variables and constraints: + +```python +from math_spec import to_spec + +spec = to_spec('curve.yaml') +spec.expand() # every formulation +spec.expand('sos') # only the sets +spec.expand('piecewise') # only the curves +``` + +- **The kinds are `'piecewise'` and `'sos'`, and no argument means both.** Any + other string is refused, naming the two. Curves go first whatever order they + are asked in, because a `method: sos2` curve states a set and no set states a + curve. +- **A model with nothing to write out is the model that comes back.** So is a + second call with the same kinds. +- **The same data binds a model and its expansion.** A set emits no parameter, + and every parameter a curve emits it derives + ([`derivation`](../reading.md#nodes-and-masks)). +- **A model that derived parameters prints rather than round-trips.** A derived + parameter is filled from the block it came from, which a file cannot state, + so `to_yaml()` on such an expansion is refused and + [`typeset()`](../typeset.md) is what reads it. +- **`to_program()` writes the curves out and leaves the sets.** A program + carries a set, because a consumer with the concept takes one; a consumer + without it refuses the model and names `spec.expand('sos')`. diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 3dffe77b..71e82357 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -45,6 +45,7 @@ dimensions: zone: { dtype: str } season: { dtype: str } technology: { dtype: str } + bp: { dtype: int } # the breakpoints a curve runs through relations: gen_bus: { key: generator, values: bus } @@ -69,6 +70,10 @@ parameters: lead: { dims: [generator], dtype: int } budget: { dims: [] } # scalar: the legend says so rather than printing an empty product growth: { dims: [] } # the base of a power; the exponent is `lead`, a column + bp_x: { dims: [generator, bp] } # the x-axis of every curve below + bp_y: { dims: [generator, bp] } + bp_heat: { dims: [generator, bp] } + bp_run: { dims: [generator, bp], dtype: bool } # how far each curve runs, so a block has a mask to print ``` #### Sets @@ -81,6 +86,7 @@ parameters: | $`\mathcal{Z}`$ | index $`z`$ — `zone` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$ | | $`\mathcal{S}`$ | index $`s`$ — `season` with $`\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}`$ | | $`\mathcal{E}`$ | index $`e`$ — `technology` with $`\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$ | +| $`\mathcal{A}`$ | index $`a`$ — `bp` | #### Parameters @@ -98,6 +104,10 @@ parameters: | $`\mathrm{lead}`$ | `lead` over $`\mathcal{G}`$ | | $`\mathrm{budget}`$ | `budget` (scalar) | | $`\mathrm{growth}`$ | `growth` (scalar) | +| $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_heat}`$ | `bp_heat` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$ | #### Variables @@ -113,6 +123,10 @@ parameters: | $`\mathit{reserve}`$ | `reserve` (scalar) | | $`\mathit{headroom}`$ | `headroom` (scalar) | | $`\mathit{weight}`$ | `weight` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{fuel}`$ | `fuel` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{heat}`$ | `heat` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{warm}`$ | `warm` over $`\mathcal{T} \times \mathcal{G}`$ | #### Definitions @@ -901,73 +915,103 @@ weight: 0 \le \mathit{weight}_{t,g} \le 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` -### Curves, as what they expand to +#### `fuel` -A curve is sugar: what prints is the formulation it expands to, which is the math the solver receives. One row per `method:`, each from the model named under it, so the symbols in this section are that model's. +a curve's second axis -#### `economies_of_scale` +```yaml +fuel: + dims: [snapshot, generator] + bounds: { lower: 0 } +``` -**`method: adjacency`** — a binary per segment, and a row making the two nonzero weights neighbours, in `examples/ports/transport_pwl.yaml`. +```math +\mathit{fuel}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### `heat` -Rendered with the sidecar symbol table `examples/symbols/transport_pwl.yaml`, which is what the weights print as: +its third, so one curve ties three expressions ```yaml -notation: latex +heat: + dims: [snapshot, generator] + bounds: { lower: 0 } +``` -names: - economies_of_scale_lam: "\\lambda" - economies_of_scale_seg: "\\delta" - bp_x: "\\mathrm{x}" - bp_y: "\\mathrm{y}" +```math +\mathit{heat}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +#### `op_cost` + +bounded by a curve rather than pinned to it + ```yaml -economies_of_scale: - over: bp - links: - - [shipment, bp_x] - - [scaled, bp_y] +op_cost: + dims: [snapshot, generator] + bounds: { lower: 0 } ``` ```math -\sum_{b \in \mathcal{B}} \lambda_{p,m,b} = 1 \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` -```math -\mathit{shipment}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot \mathrm{x}_{b} \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} -``` +#### `warm` -```math -\mathit{scaled}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot \mathrm{y}_{b} \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} +a gate not every unit has, so the curve it gates is ungated where it does not exist + +```yaml +warm: + dims: [snapshot, generator] + domain: binary + where: "is_flexible" ``` ```math -\sum_{b \in \mathcal{B}} \delta_{p,m,b} = 1 \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} +\mathit{warm}_{t,g} \in \{0, 1\} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{is\_flexible}_{g} ``` -```math -\lambda_{p,m,b} \le \delta_{p,m,b} + \delta_{p,m,b \boxminus_{0} 1} \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M},\ b \in \mathcal{B} +### Curves + +A curve prints as the curve it states, over the frame the block builds one per coordinate of. What it expands to is the math the solver receives, and `typeset(spec.expand())` prints that instead. One row per `method:`, each from the model named under it, so the symbols in this section are that model's. + +#### `economies_of_scale` + +**`method: adjacency`** — a binary per segment, and a row making the two nonzero weights neighbours, in `examples/ports/transport_pwl.yaml`. + +Rendered with the sidecar symbol table `examples/symbols/transport_pwl.yaml`, which is what the breakpoints print as: + +```yaml +notation: latex + +names: + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" ``` -```math -0 \le \lambda_{p,m,b} \le 1 \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M},\ b \in \mathcal{B} +```yaml +economies_of_scale: + over: bp + links: + - [shipment, bp_x] + - [scaled, bp_y] ``` ```math -\delta_{p,m,b} \in \{0, 1\} \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M},\ b \in \mathcal{B} +\left( \mathit{shipment}_{p,m},\ \mathit{scaled}_{p,m} \right) \in \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{b},\ \mathrm{y}_{b}) \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} ``` #### `cost_curve` **`method: sos2`** — the same weights, restricted by a set the solver branches on (the sos rules), in `examples/sos.yaml`. -Rendered with the sidecar symbol table `examples/symbols/sos.yaml`, which is what the weights print as: +Rendered with the sidecar symbol table `examples/symbols/sos.yaml`, which is what the breakpoints print as: ```yaml notation: latex names: - cost_curve_lam: "\\lambda" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" ``` @@ -982,36 +1026,19 @@ cost_curve: ``` ```math -\sum_{b \in \mathcal{B}} \lambda_{t,g,b} = 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -```math -\mathit{dispatch}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -```math -\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{y}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -```math -0 \le \lambda_{t,g,b} \le 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} -``` - -```math -\left( \lambda_{t,g,b} \right)_{b \in \mathcal{B}} \in \mathrm{SOS}2 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` #### `cost_curve` **`method: convex`** — nothing — the weights range over the hull, which is a pure LP, in `examples/piecewise.yaml`. -Rendered with the sidecar symbol table `examples/symbols/piecewise.yaml`, which is what the weights print as: +Rendered with the sidecar symbol table `examples/symbols/piecewise.yaml`, which is what the breakpoints print as: ```yaml notation: latex names: - cost_curve_lam: "\\lambda" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" ``` @@ -1026,26 +1053,14 @@ cost_curve: ``` ```math -\sum_{b \in \mathcal{B}} \lambda_{t,g,b} = 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -```math -\mathit{dispatch}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -```math -\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{y}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} -``` - -```math -0 \le \lambda_{t,g,b} \le 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} +\left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{conv}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` #### `cost_curve` **`method: lp`** — no weights at all — one row per segment line, plus the two rows holding the domain, in `examples/piecewise_lp.yaml`. -Rendered with the sidecar symbol table `examples/symbols/piecewise_lp.yaml`, which is what the weights print as: +Rendered with the sidecar symbol table `examples/symbols/piecewise_lp.yaml`, which is what the breakpoints print as: ```yaml notation: latex @@ -1065,15 +1080,7 @@ cost_curve: ``` ```math -\mathit{op\_cost}_{t,g} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \ge \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( \mathit{dispatch}_{t,g} - \mathrm{x}_{g,b} \right) + \mathrm{y}_{g,b} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) \neq 0 -``` - -```math -\mathit{dispatch}_{t,g} \ge \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) = 0 -``` - -```math -\mathit{dispatch}_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) = \lvert \mathcal{B} \rvert - 1 +\mathit{op\_cost}_{t,g} \ge \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b})(\mathit{dispatch}_{t,g}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` ### Sets carried to the solver diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 7d26b4b4..f59599ab 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -73,6 +73,24 @@ on a `Program`, it returns the same object unchanged. | building rows, as a solver backend or a second front end does | `Program` | Every declaration is there, and resolved | | reading the file, for `macros:`, `description:`, or a link as it was written | `Spec` | A program keeps a curve's facts | +## Formulations written out + +`Spec.expand()` returns a `Spec` whose formulations — `piecewise:` and `sos:` — +are stated as the variables and constraints they stand for. It is the same math, +bound by the same data, and it is what to print for a reader who wants the rows +rather than the curve: + +```python +sorted(spec.expand().variables) # ['cost', 'curve_lam', 'p'] +sorted(spec.expand().constraints) # ['curve_convexity', 'curve_link0', 'curve_link1', 'target'] +spec.expand() is spec.expand() # True +``` + +`to_program` writes the curves out and leaves the sets, because a program +carries a set for a consumer that has the concept. A consumer without one +refuses the model and names `spec.expand('sos')`; what that emits is on the +[piecewise page](language/piecewise.md#what-a-set-is-written-out-as). + `program.piecewise` keeps what the block assumed about the numbers, such as "the breakpoints in `bp_x` increase", as a `checks` tuple. The engine, which has the numbers, runs each check, and `check_message` gives it the sentence to diff --git a/docs/reference/typeset.md b/docs/reference/typeset.md index c118753a..4bbd3177 100644 --- a/docs/reference/typeset.md +++ b/docs/reference/typeset.md @@ -46,7 +46,10 @@ a flag. `-o FILE` writes to a file instead of stdout. - The model's `description:` opens the document. -- A `piecewise:` block prints as the variables and constraints it expands into. +- A `piecewise:` block prints as one line: the curve it states, over the frame + it states one curve per coordinate of. To print the variables and constraints + it stands for instead, print + [`spec.expand()`](language/piecewise.md#writing-a-formulation-out). - A [named expression](language/named.md) prints its symbol where it is used and its body once, under a **Definitions** heading, in declaration order. A `cases:` block and a [reported entry](language/named.md#reported-expressions) diff --git a/examples/symbols/piecewise.yaml b/examples/symbols/piecewise.yaml index de0cfcce..52972877 100644 --- a/examples/symbols/piecewise.yaml +++ b/examples/symbols/piecewise.yaml @@ -2,14 +2,11 @@ # # SPDX-License-Identifier: MIT -# The sidecar symbol table for `examples/piecewise.yaml`. The weights a curve -# expands to are named after the block that declared them, which is right in -# the file and unreadable in an equation that names one six times: papers write -# the convex-combination weight as lambda, so this says so out loud rather than -# the typesetter renaming anything behind the reader's back. +# The sidecar symbol table for `examples/piecewise.yaml`. A curve prints through +# its breakpoints, and `x` and `y` are what the piecewise-linear literature +# calls them. notation: latex names: - cost_curve_lam: "\\lambda" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" diff --git a/examples/symbols/sos.yaml b/examples/symbols/sos.yaml index 4bf58603..1e051e10 100644 --- a/examples/symbols/sos.yaml +++ b/examples/symbols/sos.yaml @@ -2,14 +2,12 @@ # # SPDX-License-Identifier: MIT -# The sidecar symbol table for `examples/sos.yaml`. The weights a curve -# expands to are named after the block that declared them, which is right in -# the file and unreadable in an equation that names one six times: papers write -# the convex-combination weight as lambda, so this says so out loud rather than -# the typesetter renaming anything behind the reader's back. +# The sidecar symbol table for `examples/sos.yaml`. A curve prints through its +# breakpoints, and `x` and `y` are what the piecewise-linear literature calls +# them — said out loud here rather than the typesetter renaming anything behind +# the reader's back. notation: latex names: - cost_curve_lam: "\\lambda" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" diff --git a/examples/symbols/transport_pwl.yaml b/examples/symbols/transport_pwl.yaml index c420239c..3e722c50 100644 --- a/examples/symbols/transport_pwl.yaml +++ b/examples/symbols/transport_pwl.yaml @@ -2,15 +2,11 @@ # # SPDX-License-Identifier: MIT -# The sidecar symbol table for `examples/ports/transport_pwl.yaml`. The -# adjacency method expands to two families — the convex-combination weights and -# the binary that says which segment is live — and names both after the block, -# which no equation can carry. Lambda and delta are what the piecewise-linear +# The sidecar symbol table for `examples/ports/transport_pwl.yaml`. A curve +# prints through its breakpoints, and `x` and `y` are what the piecewise-linear # literature calls them. notation: latex names: - economies_of_scale_lam: "\\lambda" - economies_of_scale_seg: "\\delta" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 2cdc05fa..5fff517b 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -517,9 +517,9 @@ }, "SosBlock": { "additionalProperties": false, - "description": "A special-ordered set over one dimension of one variable.\n\nOne set per coordinate of the variable's ``dims`` minus ``over``; the\nmembers are the variable's *existing* coordinates along ``over``, in that\ndimension's declared order, and ``big_m`` is the optional cap a consumer\nthat reformulates the set puts on its linking rows.\n\n``type: 1`` admits at most one nonzero member, ``type: 2`` at most two,\nand those two consecutive. Unlike every other block this one declares no\nmath to read off ``A``: it is a *set*, carried to a consumer that has the\nconcept and reformulated for one that does not.", + "description": "A special-ordered set over one dimension of one variable.\n\nOne set per coordinate of the variable's ``dims`` minus ``over``; the\nmembers are the variable's *existing* coordinates along ``over``, in that\ndimension's declared order.\n\n``type: 1`` admits at most one nonzero member, ``type: 2`` at most two,\nand those two consecutive. A consumer with the concept takes the set as\none; :meth:`Spec.expand` states it as binaries instead, and ``bound`` is\nthe coefficient those rows link a member by, where the member's own\n``upper`` is not the one to use.", "properties": { - "big_m": { + "bound": { "anyOf": [ { "type": "number" @@ -529,7 +529,7 @@ } ], "default": null, - "title": "Big M" + "title": "Bound" }, "description": { "anyOf": [ @@ -636,7 +636,7 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, - "description": "The declared math \u2014 one YAML file, or one dict, validated. Nothing here has seen data.\n\nA ``Spec`` that exists has passed the whole language: constructing one by\nany route \u2014 ``to_spec``, :meth:`model_validate`, the constructor \u2014 runs\nevery load-time check, expansion and expression pass included, and raises\n:class:`~math_spec.errors.LanguageError` on a model the language refuses.\nHolding one is the proof, so nothing downstream checks it again.\n\nThe API is the ten declaration sections plus ``version`` and\n``description``, and two ways back out: :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads. Everything else on\nthis class is pydantic's, not a contract this package keeps.", + "description": "The declared math \u2014 one YAML file, or one dict, validated. Nothing here has seen data.\n\nA ``Spec`` that exists has passed the whole language: constructing one by\nany route \u2014 ``to_spec``, :meth:`model_validate`, the constructor \u2014 runs\nevery load-time check, expansion and expression pass included, and raises\n:class:`~math_spec.errors.LanguageError` on a model the language refuses.\nHolding one is the proof, so nothing downstream checks it again.\n\nThe API is the ten declaration sections plus ``version`` and\n``description``, three ways back out \u2014 :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the\nsame math with its formulations written out \u2014 and :attr:`resolved`, the\ntyped trees every reader in this package walks. Everything else on this\nclass is pydantic's, not a contract this package keeps.", "properties": { "constraints": { "additionalProperties": { diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 41e11080..0e8ee9c4 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -34,14 +34,14 @@ VariableNode, ) from math_spec.dimensions import dims_of -from math_spec.piecewise import declaration_of, derivations_of, expand_piecewise +from math_spec.piecewise import declaration_of, derivations_of from math_spec.validation import to_spec if TYPE_CHECKING: from collections.abc import Callable, Mapping from pathlib import Path - from math_spec.model import Spec, _ExpandedSpec + from math_spec.model import Spec def _none_of(masks: list[program.Mask]) -> program.Mask: @@ -64,7 +64,7 @@ def to_program(spec: str | Path | Mapping[str, object] | Spec | program.Program) model, or a program already. Idempotent, so a caller that does not know which it holds can call this and be sure. - Not memoised; :func:`~math_spec.piecewise.expand_piecewise` is. + Not memoised; :meth:`~math_spec.model.Spec.expand` is. Args: spec: What to read the declarations from. @@ -80,22 +80,30 @@ def to_program(spec: str | Path | Mapping[str, object] | Spec | program.Program) """ if isinstance(spec, program.Program): return spec - return lower_program(expand_piecewise(to_spec(spec))) + return lower_program(to_spec(spec).expand('piecewise')) -def lower_program(expanded: _ExpandedSpec) -> program.Program: - """Compile an expanded model into a :class:`~math_spec.program.Program`. +def lower_program(expanded: Spec) -> program.Program: + """Compile a model whose curves are written out into a :class:`~math_spec.program.Program`. - A ``domain: binary`` variable lowers with fixed 0/1 bounds. + A ``domain: binary`` variable lowers with fixed 0/1 bounds. A ``sos:`` + block lowers as itself — a program carries a set, and + :meth:`~math_spec.model.Spec.expand` is what states one as binaries + instead. + + Args: + expanded: A model with no ``piecewise:`` block left, which + :meth:`~math_spec.model.Spec.expand` returns. Raises: LanguageError: A construct outside the language, named with its rewrite. """ + assert not expanded.piecewise, "a curve states rows, and lowering reads them: pass spec.expand('piecewise')" resolved = expanded.resolved derivations = { name: how - for block, ex in expanded.expanded_piecewise.items() + for block, ex in expanded._expanded_piecewise.items() for name, how in derivations_of(block, ex).items() } parameters = { @@ -155,7 +163,7 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: sdef.variable, sdef.over, sos_type=sdef.type, - big_m=sdef.big_m, + bound=sdef.bound, ) for sname, sdef in expanded.sos.items() } @@ -171,7 +179,7 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: objective=objective, dimensions=dimensions, sos=sos, - piecewise={name: declaration_of(ex) for name, ex in expanded.expanded_piecewise.items()}, + piecewise={name: declaration_of(ex) for name, ex in expanded._expanded_piecewise.items()}, named_expressions=expressions, ) @@ -185,7 +193,7 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: class _Lowering: """One expression walk, and the two things every step of it reads.""" - schema: _ExpandedSpec + schema: Spec context: str def expr(self, node: ArithmeticNode) -> program.ExpressionNode: diff --git a/src/math_spec/model.py b/src/math_spec/model.py index fbc75667..3e1de16c 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -30,8 +30,9 @@ ) from math_spec._expression_parser import NAME, ComparisonOperator -from math_spec.errors import did_you_mean, schema_error +from math_spec.errors import SchemaError, did_you_mean, schema_error from math_spec.operators import BUILTIN_NAMES +from math_spec.sos import Emitted, coefficient if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping @@ -117,6 +118,10 @@ def _reject_unknown_keys(cls, data: object) -> object: #: ``tests/test_schema.py``. PiecewiseMethod = Literal['adjacency', 'sos2', 'convex', 'lp'] +#: A block that states rows rather than being one, which :meth:`Spec.expand` +#: writes out on request. +Formulation = Literal['piecewise', 'sos'] + #: The shape a method needs a curve to have to be exact on it, carried by the #: :class:`~math_spec.program.Curved` check. ``convex`` and ``concave`` name #: one bend; ``either`` is the hull's weaker condition — any single bend will @@ -133,6 +138,10 @@ def _reject_unknown_keys(cls, data: object) -> object: VARIABLE_ABSENCE = frozenset(get_args(VariableAbsence)) CURVATURES = frozenset(get_args(Curvature)) +#: Every formulation, in the order :meth:`Spec.expand` writes them out: a curve +#: emits a set, and no set emits a curve. +FORMULATIONS: tuple[Formulation, ...] = ('piecewise', 'sos') + def _also_written_as( core_schema: CoreSchema, handler: GetJsonSchemaHandler, shorthand: Mapping[str, object] @@ -614,13 +623,13 @@ class SosBlock(_StrictBlock): One set per coordinate of the variable's ``dims`` minus ``over``; the members are the variable's *existing* coordinates along ``over``, in that - dimension's declared order, and ``big_m`` is the optional cap a consumer - that reformulates the set puts on its linking rows. + dimension's declared order. ``type: 1`` admits at most one nonzero member, ``type: 2`` at most two, - and those two consecutive. Unlike every other block this one declares no - math to read off ``A``: it is a *set*, carried to a consumer that has the - concept and reformulated for one that does not. + and those two consecutive. A consumer with the concept takes the set as + one; :meth:`Spec.expand` states it as binaries instead, and ``bound`` is + the coefficient those rows link a member by, where the member's own + ``upper`` is not the one to use. """ _label: ClassVar[str] = 'a sos declaration' @@ -628,7 +637,7 @@ class SosBlock(_StrictBlock): variable: str over: str type: SosType - big_m: float | None = None + bound: float | None = None description: str | None = None @field_validator('type', mode='wrap') @@ -643,11 +652,14 @@ def _check_type(cls, v: object, handler: ValidatorFunctionWrapHandler) -> SosTyp except ValidationError: raise ValueError(msg) from None - @field_validator('big_m') + @field_validator('bound') @classmethod - def _check_big_m(cls, v: float | None) -> float | None: + def _check_bound(cls, v: float | None) -> float | None: if v is not None and not (v > 0 and math.isfinite(v)): - msg = f'big_m must be a positive, finite number, got {v!r} — it caps a linking coefficient.' + msg = ( + f'bound must be a positive, finite number, got {v!r} — it is the coefficient the rows this ' + f'set expands to multiply a binary by.' + ) raise ValueError(msg) return v @@ -694,17 +706,25 @@ class Spec(_StrictBlock): Holding one is the proof, so nothing downstream checks it again. The API is the ten declaration sections plus ``version`` and - ``description``, and two ways back out: :meth:`to_dict` for the model as - data, :meth:`to_yaml` for the file a reviewer reads. Everything else on - this class is pydantic's, not a contract this package keeps. + ``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:`resolved`, the + typed trees every reader in this package walks. Everything else on this + class is pydantic's, not a contract this package keeps. """ _label: ClassVar[str] = 'the top level of the file' - #: The :class:`_ExpandedSpec` built from this model, at load — it holds - #: the resolved trees. Owned entirely — written and read — by - #: :func:`~math_spec.piecewise.expand_piecewise`; only the slot lives here. - _expansion: _ExpandedSpec | None = PrivateAttr(default=None) + #: What :meth:`expand` returned for each set of formulations asked for, so + #: a second ask is the same object rather than a second expansion. A model + #: that expands to itself is not stored: two of them compare by their + #: private state, which a model holding itself cannot answer. + _expansions: dict[tuple[Formulation, ...], Spec] = PrivateAttr(default_factory=dict) + #: What each ``piecewise:`` block of the model this one expanded became — + #: the block as written and the names its expansion chose. Empty on a model + #: that is not an expansion. Written by + #: :func:`~math_spec.piecewise.expand_piecewise`. + _expanded_piecewise: dict[str, ExpandedPiecewise] = PrivateAttr(default_factory=dict) #: Which language surface this file is written against. Absent means 0, so #: the field is additive. **0 means unstable** — the surface may change in @@ -790,11 +810,86 @@ def to_dict(self) -> dict[str, object]: return self.model_dump() def to_yaml(self) -> str: - """The file a reviewer reads — including for a model that never had one.""" + """The file a reviewer reads — including for a model that never had one. + + Raises: + SchemaError: This model is an expansion that derived parameters + from a curve, which a file would declare as data nobody + supplies. + """ import yaml + if derived := self._derived_parameters(): + msg = ( + f'this model is an expansion, and {derived} is derived from a piecewise: block rather than ' + f'supplied. A file of it would declare data nobody has. Write the model it came from, or ' + f'read this one with typeset().' + ) + raise SchemaError(msg) return yaml.safe_dump(self.to_dict(), sort_keys=False, allow_unicode=True) + def _derived_parameters(self) -> list[str]: + """The parameters this model's own expansion emitted and derives, which no file can declare.""" + from math_spec.piecewise import derivations_of + + return sorted( + name for block, expanded in self._expanded_piecewise.items() for name in derivations_of(block, expanded) + ) + + 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 are + the sources that bind it: a set emits no parameter, and every parameter + a curve emits it derives. + + 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. An expanded curve's derived parameters ride with it + in process, so what reads the result is :func:`typeset`, not + :meth:`to_yaml`, which refuses on it. + + Raises: + ValueError: *kinds* names something that is not a formulation. + """ + wanted = _formulations(kinds) + if (found := self._expansions.get(wanted)) is not None: + return found + from math_spec.piecewise import expand_piecewise + from math_spec.sos import expand_sets + + expanded = self + if 'piecewise' in wanted: + expanded = expand_piecewise(expanded) + if 'sos' in wanted and expanded.sos: + expanded = expand_sets(expanded) + if expanded is not self: + self._expansions[wanted] = expanded + return expanded + + @cached_property + def resolved(self) -> Resolved: + """Every expression and where string this model declares, typed once — what every reader after validation walks. + + Computing it *is* the expression pass, so a model the language refuses + raises here; loading forces it, so a spec in hand already holds it. It + holds what *this* model declares: the rows a formulation states are on + :meth:`expand`'s result instead. + """ + from math_spec.validation import validate_expressions + + return validate_expressions(self) + @model_validator(mode='after') def _names_are_names(self) -> Spec: """Every declaration is keyed by something an expression could write. @@ -826,6 +921,8 @@ def _validate_references(self) -> Spec: *self._relation_targets(), *self._bound_names(), *self._sos_shapes(), + *self._sos_bounds(), + *self._sos_emitted_names(), ] if errors: raise ValueError('\n'.join(errors)) @@ -970,17 +1067,60 @@ def _sos_shapes(self) -> Iterator[str]: else: claimed[block.variable] = sname + def _sos_bounds(self) -> Iterator[str]: + """A set states what the binaries it expands to state: its members start at zero, and one coefficient links them. + + Decided here rather than where the rewrite runs, so a set the language + cannot state twice is refused before any data exists and no consumer + asks the question again. A parameter-valued ``lower`` is data, so + whether it is at or above zero is not decidable here. + """ + for sname, block in self.sos.items(): + if (member := self.variables.get(block.variable)) is None: + continue + context = f"Sos '{sname}'" + lower = 0.0 if member.domain == 'binary' else member.bounds.lower + if isinstance(lower, str): + yield ( + f"{context}: variable '{block.variable}' bounds.lower is the parameter '{lower}', and the " + f'set expands to rows that bound a member from above only, so a member below zero stays ' + f'free of them. Declare a literal bounds.lower of at least zero.' + ) + elif lower < 0: + yield ( + f"{context}: variable '{block.variable}' has bounds.lower {lower}, and the set expands to " + f'rows that bound a member from above only, so a member below zero stays free of them. ' + f'Declare bounds.lower of at least zero.' + ) + if coefficient(block.bound, member.domain, member.bounds.upper) is None: + yield ( + f"{context}: variable '{block.variable}' has no upper bound, and the set expands to rows " + f'that link each member to a binary by one coefficient. Declare bounds.upper on the ' + f'variable, or bound: on the set.' + ) + + def _sos_emitted_names(self) -> Iterator[str]: + """No name a set's expansion writes is one the file already declares.""" + declared: dict[str, Iterable[str]] = {'variable': self.variables, 'constraint': self.constraints} + for sname, block in self.sos.items(): + for kind, names in Emitted.of(sname, block.type).by_kind: + yield from ( + f"Sos '{sname}': its expansion writes {kind} '{one}', which this file already declares. " + f'Rename one of them.' + for one in names + if one in declared[kind] + ) + @model_validator(mode='after') def _validate_expressions(self) -> Spec: - """Every expression and where string — after expansion, whose emitted declarations are language too. + """Every expression and where string — this file's own, and every one a curve emits. - The expansion is what holds the resolved trees, so it is built here - for every model, ``piecewise:`` or not. The expander imports this - module, so the import is local. + A curve's expansion is a model in its own right, so validating it is + what holds the declarations it writes to the language; it runs first, + so a fault in a link is named against the link the file wrote. """ - from math_spec.piecewise import expand_piecewise - - _ = expand_piecewise(self).resolved + self.expand('piecewise') + _ = self.resolved return self @@ -1000,34 +1140,14 @@ class ExpandedPiecewise(_StrictBlock): ends: str | None = None -class _ExpandedSpec(Spec): - """A model with nothing left to expand — what rows are built from. +def _formulations(asked: tuple[str, ...]) -> tuple[Formulation, ...]: + """What *asked* names, in :data:`FORMULATIONS` order — all of them where it names none. - :func:`~math_spec.piecewise.expand_piecewise` produces one, and - ``variables:`` and ``constraints:`` then hold the whole model. A consumer - that builds takes this type; one that reads takes :class:`Spec` and - accepts either. + Raises: + ValueError: A name that is not a formulation. """ - - #: What each ``piecewise:`` block became, under the block's name — the - #: block as written and the names its expansion chose, which is what a - #: program's :class:`~math_spec.program.PiecewiseDeclaration` is built from. - expanded_piecewise: dict[str, ExpandedPiecewise] = {} - - @model_validator(mode='after') - def _nothing_left_to_expand(self) -> _ExpandedSpec: - if self.piecewise: - msg = 'an _ExpandedSpec carries no piecewise: — expand_piecewise is what produces one' - raise ValueError(msg) - return self - - @cached_property - def resolved(self) -> Resolved: - """Every expression and where string typed, once — what every reader after validation walks. - - Computing it *is* the expression pass, so a model the language refuses - raises here; loading forces it, so a spec in hand already holds it. - """ - from math_spec.validation import validate_expressions - - return validate_expressions(self) + if unknown := [kind for kind in asked if kind not in FORMULATIONS]: + spelled = ' and '.join(repr(kind) for kind in FORMULATIONS) + msg = f'{unknown[0]!r} is not a formulation. Expand {spelled}, or pass none of them for every one.' + raise ValueError(msg) + return tuple(kind for kind in FORMULATIONS if not asked or kind in asked) diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index bba5ae3b..2a292aa2 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -20,7 +20,7 @@ from math_spec.dimensions import dims_of from math_spec.errors import LanguageError, PiecewiseExpansionError from math_spec.expansion import parse_and_expand -from math_spec.model import Curvature, ExpandedPiecewise, PiecewiseBlock, Spec, _ExpandedSpec, undeclared_dimension +from math_spec.model import Curvature, ExpandedPiecewise, PiecewiseBlock, Spec, undeclared_dimension from math_spec.program import ( AtLeastTwo, Check, @@ -34,6 +34,7 @@ PiecewiseDeclaration, ) from math_spec.resolution import Namespace, resolve_expression +from math_spec.sos import Emitted, emit if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -111,10 +112,11 @@ class _Block: """One ``piecewise:`` block being expanded into the raw model it writes. Every name the expansion may write is spelled once here, so the emitters - and the collision check read the same table. ``points`` is the derived - mask, written only where ``nominated`` names the values parameter it is - derived from; ``mask`` is whichever parameter masks the weights, or - ``None`` for a whole curve. + and the collision check read the same table — ``set`` is the one a method + that states a set writes through :func:`math_spec.sos.emit`. ``points`` is + the derived mask, written only where ``nominated`` names the values + parameter it is derived from; ``mask`` is whichever parameter masks the + weights, or ``None`` for a whole curve. Raises: PiecewiseExpansionError: A block naming something that does not exist, @@ -128,13 +130,11 @@ def __init__(self, schema: Spec, raw: dict[str, object], name: str, pw: Piecewis self.pw = pw self.nominated = _nominated(pw) self.lam = f'{name}_lam' - self.seg = f'{name}_seg' self.starts = f'{name}_starts' self.ends = f'{name}_ends' self.points = f'{name}_points' self.convexity = f'{name}_convexity' - self.pick = f'{name}_pick' - self.adjacency = f'{name}_adjacency' + self.set = Emitted.of(name, 2) self.chord = f'{name}_chord' self.domain_lo = f'{name}_domain_lo' self.domain_hi = f'{name}_domain_hi' @@ -207,17 +207,8 @@ def _weights(self) -> None: list(self.frame), f'({link.expression}) {link.sign} sum({self.lam} * {link.values}, over={d})', ) - if self.pw.method == 'sos2': + if self.pw.method in ('sos2', 'adjacency'): self._section('sos')[self.name] = {'variable': self.lam, 'over': d, 'type': 2} - elif self.pw.method == 'adjacency': - self._weight(self.seg, domain='binary', bounds={}) - for suffix, where, rhs in gated: - self._constraint(self.pick + suffix, list(self.frame), f'sum({self.seg}, over={d}) == {rhs}', where) - self._constraint( - self.adjacency, - [*self.frame, d], - f'{self.lam} <= {self.seg} + shift({self.seg}, along={d}, offset=1, edge=0)', - ) def _gate_rows(self) -> tuple[tuple[str, str | None, str], ...]: """What the weights sum to, as ``(name suffix, where, right-hand side)``. @@ -280,18 +271,22 @@ def _segment_lines(self) -> None: # -- checks ------------------------------------------------------------ def _emitted_by_kind(self) -> tuple[tuple[str, tuple[str, ...]], ...]: - """Every name this block may write, by the kind of declaration each would collide with.""" + """Every name this block may write, by the kind of declaration each would collide with. + + The set a block states writes names of its own, and they are reserved + whichever method the block declares: which of the two write them is the + method's business, and a collision is the file's either way. + """ return ( - ('variable', (self.lam, self.seg)), + ('variable', (self.lam, self.set.seg)), ('parameter', (self.starts, self.ends, *((self.points,) if self.nominated is not None else ()))), ( 'constraint', ( self.convexity, self.convexity + _UNGATED, - self.pick, - self.pick + _UNGATED, - self.adjacency, + self.set.pick, + self.set.link, self.chord, self.domain_lo, self.domain_hi, @@ -433,28 +428,29 @@ def _expr_dims(self, text: str, ctx: str) -> frozenset[str]: ) from exc -def expand_piecewise(schema: Spec) -> _ExpandedSpec: - """Return *schema* as a :class:`_ExpandedSpec` — every ``piecewise:`` block expanded away. +def expand_piecewise(schema: Spec) -> Spec: + """*schema* with every ``piecewise:`` block written out — *schema* itself where it declares none. - Memoised on *schema*. + A ``method: adjacency`` block states its restriction as the set + ``method: sos2`` states, and then that set is written out here too: the + binaries are what the method *is*, so the model that comes back carries no + set of its own (:func:`math_spec.sos.emit` is where they are spelled). Raises: PiecewiseExpansionError: A block naming something that does not exist, or emitting a name the file already declares. """ - if isinstance(schema, _ExpandedSpec): - return schema - if schema._expansion is not None: - return schema._expansion if not schema.piecewise: - schema._expansion = _ExpandedSpec.model_construct(**dict(schema)) - return schema._expansion + return schema raw = schema.model_dump() raw.setdefault('variables', {}) raw.setdefault('constraints', {}) - raw['expanded_piecewise'] = {name: _Block(schema, raw, name, pw).expand() for name, pw in schema.piecewise.items()} + records = {name: _Block(schema, raw, name, pw).expand() for name, pw in schema.piecewise.items()} raw['piecewise'].clear() - expanded = _ExpandedSpec.model_validate(raw) - schema._expansion = expanded + for name, pw in schema.piecewise.items(): + if pw.method == 'adjacency': + emit(raw, name) + expanded = Spec.model_validate(raw) + expanded._expanded_piecewise = {name: ExpandedPiecewise.model_validate(record) for name, record in records.items()} return expanded diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 42b59f51..ad7b969f 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -772,15 +772,15 @@ class SosDeclaration: 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. + ``bound`` is the coefficient the rows this set expands to link a member by + (:meth:`~math_spec.model.Spec.expand`), and is ``None`` where the file + states none and the member's own upper bound is it. """ variable: str over: str sos_type: Literal[1, 2] - big_m: float | None = None + bound: float | None = None @dataclass(frozen=True) diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index e1430eb1..577b2e4f 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -223,26 +223,30 @@ class Resolved: constraints: Each constraint's comparison and ``where``. objective: The objective's expression, ``None`` where the file declares none. + piecewise: Each ``piecewise:`` block's link expressions, in link order. """ expressions: dict[str, CasesNode | DefinitionNode] variables: dict[str, Mask | None] constraints: dict[str, ResolvedConstraint] objective: ArithmeticNode | None + piecewise: dict[str, tuple[ArithmeticNode, ...]] @cached_property def read_by_the_math(self) -> frozenset[str]: - """The named expressions the math reads: every entry the objective or a constraint reaches, transitively. - - Read off those two positions alone: a bound and a ``where`` name no - entry, and a piecewise link's expression reaches here through the - constraints its expansion emitted. The rest of the ``expressions:`` - section is read back after a solve and never fed to one - (:attr:`~math_spec.program.ExpressionDeclaration.in_math`). + """The named expressions the math reads: every entry the objective, a constraint or a curve reaches, transitively. + + Read off those three positions alone: a bound and a ``where`` name no + entry. The rest of the ``expressions:`` section is read back after a + solve and never fed to one + (:attr:`~math_spec.program.ExpressionDeclaration.in_math`). A curve + counts because it states rows, so the answer does not move when the + curve is written out (:meth:`~math_spec.model.Spec.expand`). """ roots: list[ParsedNode] = [constraint.expression for constraint in self.constraints.values()] if self.objective is not None: roots.append(self.objective) + roots.extend(link for links in self.piecewise.values() for link in links) return frozenset(node.name for node in nodes(*roots) if isinstance(node, CasesNode | DefinitionNode)) diff --git a/src/math_spec/sos.py b/src/math_spec/sos.py new file mode 100644 index 00000000..d481c96e --- /dev/null +++ b/src/math_spec/sos.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Expand ``sos:`` blocks into the binaries and rows that state the same restriction. + +A set becomes ordinary declarations under names prefixed with the block's own, +the way a ``piecewise:`` block becomes weights and rows; what it emits is +tabled in ``docs/reference/language/piecewise.md``. The rewrite states the same +feasible set as the set itself only for a member at or above zero linked by a +finite coefficient, so a model declaring a set without those is refused at load +(:meth:`math_spec.model.Spec` validates it) rather than here. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from math_spec.model import SosType, Spec + + +def coefficient(bound: float | None, domain: str, upper: float | str) -> float | str | None: + """What a member's linking row multiplies its binary by, or ``None`` where the model states none that is finite. + + The set's own ``bound:`` first, so the coefficient a reader sees is the one + the file chose rather than the tighter of two; then the 1 a binary's domain + fixes, which no bounds block carries; then the member's declared ``upper``, + a number or the name of a parameter. + """ + if bound is not None: + return bound + if domain == 'binary': + return 1.0 + return None if upper == float('inf') else upper + + +@dataclass(frozen=True) +class Emitted: + """Every name one set's expansion writes, spelled once for the emitter and the collision check. + + The linking row is named after what it says, which the two orders do not + share: order 2 admits a member in either half of one segment, and order 1 + admits it alone. + """ + + seg: str + pick: str + link: str + + @classmethod + def of(cls, name: str, order: SosType) -> Emitted: + """The names set *name* of *order* writes.""" + return cls(f'{name}_seg', f'{name}_pick', f'{name}_{"adjacency" if order == 2 else "nonzero"}') + + @property + def by_kind(self) -> tuple[tuple[str, tuple[str, ...]], ...]: + """Each name by the kind of declaration it would collide with.""" + return (('variable', (self.seg,)), ('constraint', (self.pick, self.link))) + + +#: What the binary says, per order — the description its legend entry carries. +_SEGMENTS = { + 1: 'a binary per member, 1 where that member may be nonzero', + 2: 'a binary per segment, 1 where the two members it spans may be nonzero', +} + + +def expand_sets(schema: Spec) -> Spec: + """*schema* with every ``sos:`` block written out as binaries and the rows that link them. + + The record of what an expanded curve derived rides along, because a model + whose curves are already written out is the one this is usually asked of. + """ + from math_spec.model import Spec as Model + + raw = schema.model_dump() + for name in list(schema.sos): + emit(raw, name) + expanded = Model.model_validate(raw) + expanded._expanded_piecewise = dict(schema._expanded_piecewise) + return expanded + + +def emit(raw: dict[str, object], name: str) -> None: + """Write what the set *name* states as declarations of *raw*, and drop the block. + + Args: + raw: A model as data, mid-expansion, declaring the set and the variable + it runs over. + name: Which set to lower. + """ + sets = _section(raw, 'sos') + block = sets.pop(name) + assert isinstance(block, dict), 'a validated model carries each set as a mapping' + variable, over, order = block['variable'], block['over'], block['type'] + member = _section(raw, 'variables')[variable] + assert isinstance(member, dict), 'a validated model carries each variable as a mapping' + dims = list(member['dims']) + emitted = Emitted.of(name, order) + + _section(raw, 'variables')[emitted.seg] = { + 'dims': dims, + **({'where': member['where']} if member.get('where') else {}), + 'domain': 'binary', + 'description': _SEGMENTS[order], + } + picked = emitted.seg if order == 1 else f'{emitted.seg} + shift({emitted.seg}, along={over}, offset=1, edge=0)' + constraints = _section(raw, 'constraints') + constraints[emitted.pick] = { + 'dims': [d for d in dims if d != over], + 'expression': f'sum({emitted.seg}, over={over}) <= 1', + } + constraints[emitted.link] = { + 'dims': dims, + 'expression': f'{variable} <= {_multiplier(block, member)} * ({picked})', + } + + +def _multiplier(block: dict[str, object], member: dict[str, object]) -> float | str: + """The coefficient as an expression writes it, read off the set and its member.""" + bounds: dict[str, object] = {'upper': float('inf')} + declared = member.get('bounds') + assert declared is None or isinstance(declared, dict), 'a validated model carries a bounds block as a mapping' + bounds.update(declared or {}) + upper, bound = bounds['upper'], block.get('bound') + assert isinstance(upper, float | str), 'a bound is a number or the name of a parameter' + assert bound is None or isinstance(bound, float), 'the schema holds bound: to a number' + found = coefficient(bound, str(member.get('domain', 'continuous')), upper) + assert found is not None, 'a set whose member has no finite coefficient is refused at load' + return found + + +def _section(raw: dict[str, object], name: str) -> dict[str, object]: + """The *name* section of the raw model, created empty where the file declares none.""" + section = raw.setdefault(name, {}) + assert isinstance(section, dict), f'{name}: is a mapping in a validated model' + return section diff --git a/src/math_spec/typesetting/__init__.py b/src/math_spec/typesetting/__init__.py index 7bbdadd8..99210937 100644 --- a/src/math_spec/typesetting/__init__.py +++ b/src/math_spec/typesetting/__init__.py @@ -29,7 +29,6 @@ from typing import TYPE_CHECKING, Literal, TypedDict, Unpack from math_spec.errors import SchemaError, did_you_mean -from math_spec.piecewise import expand_piecewise from math_spec.typesetting.latex import LatexFormat from math_spec.typesetting.markdown import MarkdownFormat from math_spec.typesetting.symbols import Symbols, SymbolTable @@ -87,7 +86,7 @@ def _walk( if fmt not in FORMATS: msg = f"'{fmt}' is not a format this package prints. Formats: {', '.join(FORMATS)}." raise ValueError(msg) - schema = expand_piecewise(to_spec(model)) + schema = to_spec(model) format_ = FORMATS[fmt] if symbols is None: symbols = SymbolTable(format_.notation) @@ -116,7 +115,9 @@ def typeset( model: Anything :func:`math_spec.to_spec` accepts. A :class:`~math_spec.model.Spec` is rendered as it stands, so printing one model in several formats reads and checks the file - once rather than once per format. + 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 @@ -168,7 +169,8 @@ def typeset_declaration( """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, or a variable's domain, quantifier included — + definition, a constraint, 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 @@ -177,7 +179,8 @@ def typeset_declaration( Args: model: Anything :func:`math_spec.to_spec` accepts. - name: A named expression, constraint or variable the model declares. + name: A named expression, constraint, ``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 @@ -191,17 +194,22 @@ def typeset_declaration( 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 three, or as two — a + SchemaError: *name* is declared as none of the four, 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) schema = walk.schema - kinds = {'named expression': schema.expressions, 'constraint': schema.constraints, 'variable': schema.variables} + kinds = { + 'named expression': schema.expressions, + 'constraint': schema.constraints, + 'curve': schema.piecewise, + 'variable': schema.variables, + } found = [kind for kind, group in kinds.items() if name in group] if not found: everything = {n for group in kinds.values() for n in group} - msg = f"'{name}' is not a named expression, constraint or variable. {did_you_mean(name, everything)}" + msg = f"'{name}' is not a named expression, constraint, curve or variable. {did_you_mean(name, everything)}" raise SchemaError(msg) if len(found) > 1: msg = f"'{name}' is both a {found[0]} and a {found[1]}, and one line prints one of them — rename one." diff --git a/src/math_spec/typesetting/format.py b/src/math_spec/typesetting/format.py index a0bedfb5..1f09d503 100644 --- a/src/math_spec/typesetting/format.py +++ b/src/math_spec/typesetting/format.py @@ -56,6 +56,8 @@ 'integers', 'binary_set', 'sos_set', + 'curve', + 'hull', 'position', 'dual', 'minimize', @@ -66,9 +68,11 @@ #: LaTeX spelling first and its Typst spelling second — one row per operator, #: so no format can be missing one. ``such_that`` is the colon in #: "∀ t ∈ T : condition", ``times`` sits between sets in the legend, -#: ``maps_to`` is the → in a coordinate map, and the three translations are -#: three models: plain leaves the vacated position absent, ``cyclic_*`` wraps, -#: ``edge_*`` fills it with the value it carries as a subscript. +#: ``maps_to`` is the → in a coordinate map, ``curve`` and ``hull`` are the two +#: sets a ``piecewise:`` block states its links lie on, and the three +#: translations are three models: plain leaves the vacated position absent, +#: ``cyclic_*`` wraps, ``edge_*`` fills it with the value it carries as a +#: subscript. OPERATOR_SPELLINGS: dict[OperatorName, tuple[str, str]] = { 'cdot': (r'\cdot', 'dot'), 'plus': ('+', '+'), @@ -98,6 +102,8 @@ 'integers': (r'\mathbb{Z}', 'ZZ'), 'binary_set': (r'\{0, 1\}', '{0, 1}'), 'sos_set': (r'\mathrm{SOS}', 'upright("SOS")'), + 'curve': (r'\mathrm{pwl}', 'upright("pwl")'), + 'hull': (r'\mathrm{conv}', 'upright("conv")'), 'position': (r'\mathrm{pos}', 'upright("pos")'), 'dual': (r'\lambda', 'lambda'), 'minimize': (r'\min', 'min'), diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index 995280cb..203dbf83 100644 --- a/src/math_spec/typesetting/symbols.py +++ b/src/math_spec/typesetting/symbols.py @@ -21,7 +21,7 @@ from math_spec.typesetting.format import NOTATIONS if TYPE_CHECKING: - from math_spec.model import _ExpandedSpec + from math_spec.model import Spec from math_spec.typesetting.format import Format, Notation __all__ = ['SymbolTable', 'Symbols'] @@ -72,7 +72,7 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, giv return _word(name, fmt, given=given) -def chosen_expressions(schema: _ExpandedSpec) -> frozenset[str]: +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 @@ -101,7 +101,7 @@ class Symbols: SchemaError: If *table* is written in a notation *fmt* does not read. """ - def __init__(self, schema: _ExpandedSpec, fmt: Format, table: SymbolTable) -> None: + 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 ' @@ -236,7 +236,7 @@ def load(cls, source: str | Path | Mapping[str, object]) -> SymbolTable: names={k: str(v) for k, v in _section(raw, 'names').items()}, ) - def checked_against(self, schema: _ExpandedSpec) -> SymbolTable: + def checked_against(self, schema: Spec) -> SymbolTable: """Reject entries naming nothing in *schema*, with the near miss.""" dims = set(schema.dimensions) everything = ( diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index bedc65e1..dacd7521 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -57,7 +57,7 @@ import datetime from collections.abc import Iterable, Mapping - from math_spec.model import RelationBlock, SosBlock, _ExpandedSpec + from math_spec.model import PiecewiseBlock, RelationBlock, SosBlock, Spec from math_spec.program import Walk as RelationWalk from math_spec.typesetting.format import Format from math_spec.typesetting.symbols import Symbols @@ -232,7 +232,7 @@ class Walk: def __init__( self, - schema: _ExpandedSpec, + schema: Spec, symbols: Symbols, fmt: Format, *, @@ -703,7 +703,16 @@ def _objective(self) -> list[Line]: return [Line(label='', left=sense, right=self._expression(node, self._context()))] def _constraints(self) -> list[Line]: - return [self._constraint(name) for name in self.schema.constraints] + """Every constraint, then every curve. + + A ``piecewise:`` block restricts what its link expressions may be + together, which is what a row does, so it prints here rather than among + the domains — where a set prints, being a property of one variable. + """ + return [ + *(self._constraint(name) for name in self.schema.constraints), + *(self._piecewise(name) for name in self.schema.piecewise), + ] def _constraint(self, name: str) -> Line: block = self.schema.constraints[name] @@ -758,15 +767,17 @@ def definition(self, name: str) -> Line: ) def line(self, name: str) -> Line: - """The one line *name* prints as: a named expression's definition, a constraint, or a variable's domain. + """The one line *name* prints as: a named expression's definition, a constraint, a curve, or a variable's domain. - *name* is one of the three; :func:`~math_spec.typesetting.typeset_declaration` - refuses the rest, and a constraint sharing a variable's name. + *name* is one of the four; :func:`~math_spec.typesetting.typeset_declaration` + refuses the rest, and a name declared as two of them. """ if name in self.schema.expressions: return self.definition(name) if name in self.schema.constraints: return self._constraint(name) + if name in self.schema.piecewise: + return self._piecewise(name) return self._variable(name) def _arms(self, node: CasesNode, ctx: _Context) -> list[tuple[str, str]]: @@ -838,6 +849,100 @@ def _sos(self, name: str, block: SosBlock, ctx: _Context) -> Line: condition=self._quantifier([d for d in dims if d != block.over], ''), ) + def _piecewise(self, name: str) -> Line: + """One ``piecewise:`` block as the curve it states, over the frame it states one per coordinate of. + + The links' expressions are a point, and the block says that point lies + on the piecewise-linear locus through the breakpoints. A bounded link + states one side of the locus instead, so there the locus prints as the + function of the pinned link that it is and the link's own sign says + which side. + """ + block = self.schema.piecewise[name] + links = self.schema.resolved.piecewise[name] + frame = self._curve_frame(name, block, links) + ctx = self._context([*frame, block.over]) + locus = self._locus(block, ctx) + bounded = next((i for i, link in enumerate(block.links) if link.sign != '=='), None) + if bounded is None: + left = self._tuple([self._expression(node, ctx) for node in links]) + right = f'{self._op("in")} {locus}' + else: + pinned = links[1 - bounded] + left = self._expression(links[bounded], ctx) + sign = self._op(_PREDICATES[block.links[bounded].sign]) + right = f'{sign} {self.format.apply(locus, self._expression(pinned, ctx))}' + return Line(label=name, left=left, right=right, condition=self._quantifier(frame, '')) + + def _locus(self, block: PiecewiseBlock, ctx: _Context) -> str: + """The set the links lie on: the curve through the breakpoints, or the hull ``convex`` relaxes it onto. + + A gate multiplies it, which is what gating a curve does — the weights + sum to the gate, so the locus is the origin where the gate is 0 and the + curve where it is 1. + """ + operator = self._op('hull' if block.method == 'convex' else 'curve') + through = self.format.subscript(operator, [self._breakpoints(block, ctx)]) + values = self.format.joined( + [ + ctx.indexed(self.symbols.name[link.values], list(self.schema.parameters[link.values].dims)) + for link in block.links + ], + '', + ) + locus = self.format.apply(through, values) + gate = self._gate(block, ctx) + return f'{gate} {self._op("cdot")} {locus}' if gate else locus + + def _breakpoints(self, block: PiecewiseBlock, ctx: _Context) -> str: + """Which breakpoints the curve runs through: every one of the dimension, or the ones ``points:`` admits. + + A ``points:`` naming a boolean parameter reads as the flag it is, and + one naming a values parameter as the rows that parameter has, which is + the same reading a ``where`` gives either of them. + """ + over = self._membership(block.over) + if block.points is None: + return over + admitted = ParameterDefinedNode(block.points, tuple(self.schema.parameters[block.points].dims)) + return f'{over} {self._op("such_that")} {self._predicate(admitted, ctx)}' + + def _gate(self, block: PiecewiseBlock, ctx: _Context) -> str: + """The factor an ``activity:`` puts on the locus, or ``''`` where the block has none. + + Where the gate is a variable that does not exist at every coordinate + the curve is built for, the factor is the gate where it exists and 1 + where it does not — the two rows the expansion writes there, because a + variable that does not exist takes its row with it and would leave the + curve unstated rather than ungated. ``absence: zero`` is the other + reading and pins the curve off, which is the factor on its own. + """ + if (activity := block.activity) is None: + return '' + gate = self.schema.variables[activity] + symbol = ctx.indexed(self.symbols.name[activity], list(gate.dims)) + mask = self.schema.resolved.variables[activity] + if mask is None or gate.absence == 'zero': + return symbol + where = self._predicate(mask.root, ctx, need=_WHERE_PRECEDENCE['and']) + return self.format.cases( + [(symbol, f'{self.format.prose("if ")} {where}'), ('1', self.format.prose('otherwise'))] + ) + + def _curve_frame(self, name: str, block: PiecewiseBlock, links: tuple[ArithmeticNode, ...]) -> list[str]: + """The dimensions the block builds one curve per coordinate of: every one its links and its gate carry. + + The union the expansion takes its own frame from, and the expansion has + already held it to the rules — that no link carries the breakpoint + dimension among them (:mod:`math_spec.piecewise`). + """ + dims: frozenset[str] = frozenset() + for i, node in enumerate(links): + dims |= dims_of(node, self.schema, f"piecewise '{name}' link {i}") + if block.activity is not None: + dims |= frozenset(self.schema.variables[block.activity].dims) + return self._sorted(dims) + def _bound(self, ctx: _Context, value: float | str) -> str: if isinstance(value, str): return ctx.indexed(self.symbols.name[value], list(self.schema.parameters[value].dims)) diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index c0fa5453..1a5a7750 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -110,6 +110,9 @@ def validate_expressions(schema: Spec) -> Resolved: ``over=snapshot`` under a formal ``snapshot`` cannot say which it means; - every dim rule (``dimensions.check_schema``), once names resolve. + A ``piecewise:`` block's links are resolved here too, so the typesetter + reads the curve a file states without expanding it. + Returns: Every declaration's typed tree — what the dim rules, lowering and the typesetter read instead of resolving the text again. @@ -162,10 +165,21 @@ def validate_expressions(schema: Spec) -> Resolved: schema.objective.expression, schema, ns, 'The objective', errors, comparison=False, ceiling=2 ) + piecewise = {} + for pname, pdef in schema.piecewise.items(): + links = [ + _check_expression( + link.expression, schema, ns, f"piecewise '{pname}' link {i}", errors, comparison=False, ceiling=1 + ) + for i, link in enumerate(pdef.links) + ] + if all(link is not None for link in links): + piecewise[pname] = tuple(link for link in links if link is not None) + if errors: raise SchemaError(_once(errors)) - resolved = Resolved(expressions, variables, constraints, objective) + resolved = Resolved(expressions, variables, constraints, objective, piecewise) check_schema(schema, resolved) return resolved diff --git a/tests/test_boundedness.py b/tests/test_boundedness.py index b606dc73..2ad08a0c 100644 --- a/tests/test_boundedness.py +++ b/tests/test_boundedness.py @@ -71,7 +71,14 @@ def test_a_variable_the_objective_drives_unopposed_is_named_with_its_side(patch, pytest.param({'variables.v.bounds': {'lower': 'c'}}, id='a-parameter-bound-is-data'), pytest.param({'variables.v.domain': 'binary'}, id='a-binary-is-bounded-by-its-domain'), pytest.param({'constraints': {'k': {'dims': ['g'], 'expression': 'v >= c'}}}, id='named-by-a-constraint'), - pytest.param({'sos': {'s': {'variable': 'v', 'over': 'g', 'type': 1}}}, id='carried-by-a-set'), + pytest.param( + { + 'objective.expression': '-sum(v, over=g)', + 'variables.v.bounds': {'lower': 0}, + 'sos': {'s': {'variable': 'v', 'over': 'g', 'type': 1, 'bound': 10}}, + }, + id='carried-by-a-set', + ), pytest.param({'objective.expression': 'sum(c * v, over=g)'}, id='a-parameter-coefficient-may-be-zero'), pytest.param({'objective.expression': 'sum(v - v, over=g)'}, id='both-signs-may-cancel'), pytest.param({'objective.expression': 'sum(v * v, over=g)'}, id='a-degree-two-term-carries-no-sign'), diff --git a/tests/test_expand.py b/tests/test_expand.py new file mode 100644 index 00000000..be36ad69 --- /dev/null +++ b/tests/test_expand.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""`Spec.expand`: what it takes, what comes back, and what still binds it. + +The kinds are a closed pair and the result is a plain `Spec`, so the claims here +are about the verb rather than about either formulation — those are in +`test_piecewise.py` and `test_sos.py`. +""" + +from __future__ import annotations + +import re + +import pytest + +from math_spec.errors import SchemaError +from math_spec.lowering import to_program +from tests.fixtures import DISPATCH_MODEL, override, schema_of +from tests.test_sos import CURVE +from tools.render_tex import models + +#: The curve masked by one of its own values parameters, so its expansion +#: derives the parameters a file cannot declare. +MASKED = override( + CURVE, + **{ + 'piecewise.cost_curve.method': 'lp', + 'piecewise.cost_curve.points': 'bp_x', + 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']], + }, +) + +#: Every model the repository ships, which is what the sources invariant is +#: asserted over — the same corpus the LaTeX gate renders. +MODELS = models() + + +@pytest.mark.parametrize( + 'kinds', + [ + pytest.param(('reformulate',), id='a-verb-rather-than-a-kind'), + pytest.param(('piecewise', 'sos1'), id='one-known-and-one-not'), + ], +) +def test_a_kind_this_language_does_not_have_is_refused_naming_both(kinds): + with pytest.raises(ValueError, match=re.escape("is not a formulation. Expand 'piecewise' and 'sos'")): + schema_of(CURVE).expand(*kinds) + + +def test_the_order_is_the_languages_rather_than_the_callers(): + """A curve states a set, so asking for the set first would leave one behind.""" + asked_backwards = schema_of(CURVE).expand('sos', 'piecewise') + + assert not asked_backwards.sos and not asked_backwards.piecewise, 'both are written out either way round' + + +def test_a_model_with_nothing_to_write_out_is_the_one_that_comes_back(): + schema = schema_of(DISPATCH_MODEL) + + assert schema.expand() is schema + assert schema.expand('sos') is schema + + +def test_each_set_of_kinds_is_expanded_once(): + schema = schema_of(CURVE) + + assert schema.expand('piecewise') is schema.expand('piecewise') + assert schema.expand() is schema.expand('piecewise', 'sos') + assert schema.expand() is not schema.expand('piecewise'), 'a set left standing is a different model' + + +def test_an_expansion_that_derived_parameters_prints_rather_than_round_trips(): + """`model_dump` drops the record of what fills them, so the file would declare data nobody has.""" + expanded = schema_of(MASKED).expand() + + assert 'cost_curve_starts' in expanded.parameters + with pytest.raises(SchemaError, match=r"'cost_curve_starts'.*derived from a piecewise: block"): + expanded.to_yaml() + + +def test_an_expansion_that_derived_nothing_is_still_a_file(): + expanded = schema_of(CURVE).expand() + + assert expanded.to_yaml(), 'a curve with no mask emits no parameter, so nothing is lost by writing it out' + + +@pytest.mark.parametrize('model', MODELS, ids=[m.stem for m in MODELS]) +def test_the_same_sources_bind_a_model_and_its_expansion(model): + """What a formulation may emit, asserted on every model the repository ships: + a set emits no parameter, and every parameter a curve emits it derives. A + consumer's `sources` argument is therefore the same either way.""" + spec = schema_of(model) + supplied = {name for name, p in to_program(spec).parameters.items() if p.derivation is None} + written_out = {name for name, p in to_program(spec.expand()).parameters.items() if p.derivation is None} + + assert written_out == supplied, 'writing a formulation out asks for data the model it came from did not' diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 48d71537..3a7c1e77 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -4,9 +4,9 @@ """`piecewise:` expansion, judged at the door that decides it. -Every claim here is one `to_spec` or `expand_piecewise` reaches with no data -bound: which declarations a curve emits, which names it may not collide with, -which methods exist, and which gates a block will accept. +Every claim here is one `to_spec` or `Spec.expand` reaches with no data bound: +which declarations a curve emits, which names it may not collide with, which +methods exist, and which gates a block will accept. """ from __future__ import annotations @@ -18,7 +18,6 @@ from math_spec import CURVATURES from math_spec.errors import LanguageError, PiecewiseExpansionError, SchemaError from math_spec.lowering import lower_program, to_program -from math_spec.model import _ExpandedSpec from math_spec.piecewise import expand_piecewise from math_spec.program import ( AtLeastTwo, @@ -128,32 +127,33 @@ def test_a_method_this_project_does_not_have_is_refused(method): schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.method': method}) -def test_the_file_is_not_an_expansion_and_the_expansion_is(): - """A `Spec` may still owe declarations to a `piecewise:` block; an `_ExpandedSpec` owes none.""" +def test_the_file_keeps_its_curve_and_the_expansion_has_none(): + """The file is what it says; the expansion is the rows it stands for.""" schema = schema_of(NONCONVEX_YAML) - assert not isinstance(schema, _ExpandedSpec) - assert isinstance(expand_piecewise(schema), _ExpandedSpec) + assert 'cost_curve' in schema.piecewise, 'loading a model does not spend its blocks' + assert not schema.expand('piecewise').piecewise, 'the block is spent once its declarations are emitted' + + +def test_lowering_refuses_a_model_that_still_owes_rows_to_a_curve(): + """A curve states rows and a program holds them, so lowering one unexpanded would drop the whole block. + + The type used to carry this claim: the expanded spec was a class of its own + and validated that it held no `piecewise:`. + """ + with pytest.raises(AssertionError, match=r"pass spec.expand\('piecewise'\)"): + lower_program(schema_of(NONCONVEX_YAML)) def test_expansion_is_memoised_and_idempotent(): - """One object from every call: validation already built the expansion, and an `_ExpandedSpec` is its own.""" + """One object per set of formulations asked for, and a model with none to expand is its own expansion.""" schema = schema_of(NONCONVEX_YAML) - expanded = expand_piecewise(schema) - assert expand_piecewise(schema) is expanded - assert expand_piecewise(expanded) is expanded + expanded = schema.expand('piecewise') + assert schema.expand('piecewise') is expanded + assert expanded.expand('piecewise') is expanded curveless = schema_of(DISPATCH_MODEL) - expanded = expand_piecewise(curveless) - assert isinstance(expanded, _ExpandedSpec), 'a curve-free file is its own expansion, and says so in its type' - assert expand_piecewise(curveless) is expanded - assert expanded.constraints.keys() == curveless.constraints.keys(), 'retyping declares nothing new' - - -def test_an_expansion_will_not_be_built_around_a_curve(): - """`expand_piecewise` is the only thing that produces one; validated straight from a file, the type would lie.""" - with pytest.raises(SchemaError, match='expand_piecewise is what produces one'): - _ExpandedSpec.model_validate(raw_of(NONCONVEX_YAML)) + assert curveless.expand() is curveless, 'a model with no formulation is the one that comes back' @pytest.mark.parametrize( diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index 5a1ff4de..a7dbc0e8 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -16,7 +16,7 @@ import pytest import math_spec -from math_spec import program, typesetting +from math_spec import Spec, program, typesetting #: Every name `math_spec` promises. Grouped as a reader meets them, not #: alphabetically: the alphabetical form is `__all__` itself, and repeating it @@ -41,6 +41,12 @@ } ) # fmt: skip +#: What `Spec` promises beyond the sections a file declares: the two ways back +#: out, the verb that writes a formulation out, the relations of one dimension, +#: and the typed trees every reader in this package walks. A `model_`-prefixed +#: name is pydantic's, not a contract this project keeps. +SPEC_SURFACE = frozenset({'relations_of', 'to_dict', 'to_yaml', 'expand', 'resolved'}) + #: The modules whose `__all__` a consumer imports from. MODULES = [ pytest.param(math_spec, id='math_spec'), @@ -49,6 +55,14 @@ ] +def test_spec_promises_the_pinned_methods_and_nothing_else(): + """A method on `Spec` is as public as a name in `__all__`, so adding one is the same decision.""" + found = {name for name in vars(Spec) if not name.startswith(('_', 'model_')) and name not in Spec.model_fields} + assert found == SPEC_SURFACE, ( + f'only on Spec: {sorted(found - SPEC_SURFACE)}; only in SPEC_SURFACE: {sorted(SPEC_SURFACE - found)}' + ) + + def test_all_matches_the_pinned_surface(): """Both directions, because either alone rots.""" declared = set(math_spec.__all__) diff --git a/tests/test_reading_page.py b/tests/test_reading_page.py index 60725ac6..045ed6a9 100644 --- a/tests/test_reading_page.py +++ b/tests/test_reading_page.py @@ -55,6 +55,6 @@ def test_the_page_shows_the_declarations_the_expansion_emits(tmp_path, monkeypat exec(compile(code, str(PAGE), 'exec'), namespace) claims.extend(_claims(code)) - assert len(claims) == 12, 'every `expression # value` line on the page is checked; one without one is not' + assert len(claims) == 15, 'every `expression # value` line on the page is checked; one without one is not' for expression, claimed in claims: assert eval(expression, namespace) == claimed, f'reading.md says `{expression}` is {claimed}' diff --git a/tests/test_separability.py b/tests/test_separability.py index 961e3a07..211c1dc7 100644 --- a/tests/test_separability.py +++ b/tests/test_separability.py @@ -311,7 +311,7 @@ def test_a_row_reaches_the_border_only_when_no_one_block_holds_it(patch, rows): id='an-objective-that-wraps-around-the-axis', ), pytest.param( - {'sos': {'s': {'variable': 'p', 'over': 'h', 'type': 1, 'big_m': 10}}, **_rows('p >= 0')}, + {'sos': {'s': {'variable': 'p', 'over': 'h', 'type': 1, 'bound': 10}}, **_rows('p >= 0')}, "set 's'", id='a-set-the-axis-runs-through', ), diff --git a/tests/test_sos.py b/tests/test_sos.py new file mode 100644 index 00000000..6872f83f --- /dev/null +++ b/tests/test_sos.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""`sos:` as a formulation: what a set is written out as, and what it may not lose. + +Every claim here is one `Spec.expand` reaches with no data bound — which +declarations a set emits, which coefficient links them, and that the adjacency +method is the same rows under the same names. +""" + +from __future__ import annotations + +import pytest + +from math_spec.lowering import to_program +from tests.fixtures import SMALL_MODEL, override, schema_of + +#: A set over a bounded member, which is the smallest model `expand('sos')` acts on. +PICKED = override( + SMALL_MODEL, + **{ + 'variables.p.bounds': {'lower': 0, 'upper': 10}, + 'constraints': {'used': {'dims': ['g'], 'expression': 'p <= c'}}, + 'sos': {'pick': {'variable': 'p', 'over': 'g', 'type': 1}}, + }, +) + +#: The curve of `examples/sos.yaml`, as a dict a test can vary. +CURVE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bp': {'dtype': 'int'}}, + 'parameters': {'load': {'dims': ['snapshot']}, 'bp_x': {'dims': ['bp']}, 'bp_y': {'dims': ['bp']}}, + 'variables': { + 'p': {'dims': ['snapshot'], 'bounds': {'lower': 0, 'upper': 100}}, + 'op_cost': {'dims': ['snapshot'], 'bounds': {'lower': 0}}, + }, + 'piecewise': {'cost_curve': {'over': 'bp', 'method': 'sos2', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}}, + 'constraints': {'balance': {'dims': ['snapshot'], 'expression': 'p == load'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(op_cost, over=snapshot)'}, +} + + +def test_a_set_of_order_one_admits_a_member_only_where_its_own_binary_is_one(): + expanded = schema_of(PICKED).expand('sos') + + assert not expanded.sos, 'the block is spent once its declarations are emitted' + assert expanded.variables['pick_seg'].domain == 'binary' + assert expanded.variables['pick_seg'].dims == ['g'], "the binary runs over the member's own dims" + assert expanded.constraints['pick_pick'].expression == 'sum(pick_seg, over=g) <= 1' + assert expanded.constraints['pick_nonzero'].expression == 'p <= 10.0 * (pick_seg)' + + +def test_a_set_of_order_two_admits_a_member_in_either_half_of_one_segment(): + schema = schema_of(override(PICKED, **{'sos.pick.type': 2})) + expanded = schema.expand('sos') + + assert expanded.constraints['pick_adjacency'].expression == ( + 'p <= 10.0 * (pick_seg + shift(pick_seg, along=g, offset=1, edge=0))' + ) + assert 'pick_nonzero' not in expanded.constraints, 'the order decides which linking row is written' + + +def test_the_declared_bound_is_the_coefficient_rather_than_the_tighter_of_it_and_the_upper(): + """Two consumers took different sides of this and solved different models.""" + schema = schema_of(override(PICKED, **{'sos.pick.bound': 500})) + + assert schema.expand('sos').constraints['pick_nonzero'].expression == 'p <= 500.0 * (pick_seg)' + + +def test_a_binary_member_links_by_the_one_its_domain_fixes(): + """A binary carries no bounds block, and its upper bound is 1 all the same.""" + schema = schema_of(override(PICKED, **{'variables.p': {'dims': ['g'], 'domain': 'binary'}})) + + assert schema.expand('sos').constraints['pick_nonzero'].expression == 'p <= 1.0 * (pick_seg)' + + +def test_the_emitted_binary_carries_the_members_own_mask(): + """A member that does not exist is not in the set, so its binary is not there either.""" + schema = schema_of(override(PICKED, **{'variables.p.where': 'flag'})) + + assert schema.expand('sos').variables['pick_seg'].where == 'flag' + + +def test_a_set_emits_no_parameter_so_the_same_sources_bind_both(): + program, written_out = to_program(schema_of(PICKED)), to_program(schema_of(PICKED).expand('sos')) + + assert set(written_out.parameters) == set(program.parameters), 'a set states rows and columns, never data' + + +def test_the_adjacency_method_is_the_sos2_curve_with_its_set_written_out(): + """The one spelling of the binaries, so the two methods cannot drift apart.""" + sos2 = to_program(schema_of(CURVE).expand()) + adjacency = to_program(schema_of(override(CURVE, **{'piecewise.cost_curve.method': 'adjacency'}))) + + assert sos2.variables == adjacency.variables + assert sos2.constraints == adjacency.constraints + assert not sos2.sos and not adjacency.sos, 'neither hands a solver a set' + assert sos2.piecewise['cost_curve'].method == 'sos2', 'the block still records the method it declared' + assert adjacency.piecewise['cost_curve'].method == 'adjacency' + + +@pytest.mark.parametrize( + ('kinds', 'sets', 'curves'), + [ + pytest.param(('piecewise',), ['cost_curve'], [], id='curves-only-leaves-the-set-it-emitted'), + pytest.param(('sos',), [], ['cost_curve'], id='sets-only-reaches-no-set-a-curve-has-not-emitted-yet'), + pytest.param((), [], [], id='both-writes-out-the-set-the-curve-emitted'), + ], +) +def test_a_curve_emits_a_set_and_no_set_emits_a_curve(kinds, sets, curves): + """Which is why the order is fixed rather than the caller's.""" + expanded = schema_of(CURVE).expand(*kinds) + + assert sorted(expanded.sos) == sets + assert sorted(expanded.piecewise) == curves diff --git a/tests/test_validation.py b/tests/test_validation.py index 5724a311..76894723 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -627,14 +627,44 @@ class TestRulesDecidedWithoutData: id='sos-of-order-three', ), pytest.param( - {'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1, 'big_m': 0}}}, - ('big_m must be a positive, finite number',), - id='sos-big-m-zero', + {'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1, 'bound': 0}}}, + ('bound must be a positive, finite number',), + id='sos-bound-zero', ), pytest.param( - {'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1, 'big_m': float('inf')}}}, - ('big_m must be a positive, finite number',), - id='sos-big-m-infinite', + {'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1, 'bound': float('inf')}}}, + ('bound must be a positive, finite number',), + id='sos-bound-infinite', + ), + pytest.param( + {'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1}}, 'variables.p.bounds': {'lower': 0}}, + ("variable 'p' has no upper bound", 'bound: on the set'), + id='sos-over-a-member-with-no-coefficient', + ), + pytest.param( + { + 'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1, 'bound': 10}}, + 'variables.p.bounds': {'lower': -5}, + }, + ('bounds.lower -5.0', 'bound a member from above only'), + id='sos-over-a-member-that-may-go-negative', + ), + pytest.param( + { + 'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1, 'bound': 10}}, + 'variables.p.bounds': {'lower': 'c'}, + }, + ("bounds.lower is the parameter 'c'",), + id='sos-over-a-member-whose-floor-is-data', + ), + pytest.param( + { + 'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1, 'bound': 10}}, + 'variables.p.bounds': {'lower': 0}, + 'variables.s_seg': {'dims': ['g'], 'domain': 'binary'}, + }, + ("its expansion writes variable 's_seg'",), + id='sos-whose-expansion-collides-with-a-declaration', ), pytest.param( {'relations.tag': {'key': 'g', 'dtype': 'str'}}, diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index feb37d75..a9ec3f57 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -15,6 +15,7 @@ \item[{$\mathcal{Z}$}] index $z$ --- \texttt{zone} with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}$ \item[{$\mathcal{S}$}] index $s$ --- \texttt{season} with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ \item[{$\mathcal{E}$}] index $e$ --- \texttt{technology} with $\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}$ +\item[{$\mathcal{A}$}] index $a$ --- \texttt{bp} \end{description} \paragraph{Parameters} @@ -31,6 +32,10 @@ \item[{$\mathrm{lead}$}] \texttt{lead} over $\mathcal{G}$ \item[{$\mathrm{budget}$}] \texttt{budget} (scalar) \item[{$\mathrm{growth}$}] \texttt{growth} (scalar) +\item[{$\mathrm{bp\_x}$}] \texttt{bp\_x} over $\mathcal{G} \times \mathcal{A}$ +\item[{$\mathrm{bp\_y}$}] \texttt{bp\_y} over $\mathcal{G} \times \mathcal{A}$ +\item[{$\mathrm{bp\_heat}$}] \texttt{bp\_heat} over $\mathcal{G} \times \mathcal{A}$ +\item[{$\mathrm{bp\_run}$}] \texttt{bp\_run} over $\mathcal{G} \times \mathcal{A}$ \end{description} \paragraph{Variables} @@ -45,6 +50,10 @@ \item[{$\mathit{reserve}$}] \texttt{reserve} (scalar) \item[{$\mathit{headroom}$}] \texttt{headroom} (scalar) \item[{$\mathit{weight}$}] \texttt{weight} over $\mathcal{T} \times \mathcal{G}$ +\item[{$\mathit{fuel}$}] \texttt{fuel} over $\mathcal{T} \times \mathcal{G}$ +\item[{$\mathit{heat}$}] \texttt{heat} over $\mathcal{T} \times \mathcal{G}$ +\item[{$\mathit{op\_cost}$}] \texttt{op\_cost} over $\mathcal{T} \times \mathcal{G}$ +\item[{$\mathit{warm}$}] \texttt{warm} over $\mathcal{T} \times \mathcal{G}$ \end{description} \paragraph{Definitions} @@ -113,7 +122,10 @@ \text{ceiling} && \theta_{b} & \le \infty && \forall\, b \in \mathcal{B} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ \text{redundant} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \mathit{spill}_{t} \text{ exists} \\ -\text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot +\text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot \\ +\text{fuel\_curve} && \left( p_{t,g},\ \mathit{fuel}_{t,g},\ \mathit{heat}_{t,g} \right) & \in \mathit{on}_{t,g} \cdot \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a},\ \mathrm{bp\_heat}_{g,a}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{cost\_curve} && \mathit{op\_cost}_{t,g} & \ge \begin{cases} \mathit{warm}_{t,g} & \text{if } \mathrm{is\_flexible}_{g} \\ 1 & \text{otherwise} \end{cases} \cdot \mathrm{pwl}_{a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a})(p_{t,g}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{hull\_curve} && \left( p_{t,g},\ \mathit{fuel}_{t,g} \right) & \in \mathrm{conv}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \end{align} \paragraph{Definitions} @@ -136,7 +148,11 @@ \text{reserve} && \mathit{reserve} & \ge 0 \\ \text{headroom} && \mathit{headroom} & \ge 0 && \text{where } \mathrm{budget} \text{ is defined} \\ \text{weight} && 0 \le \mathit{weight}_{t,g} & \le 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{weight sos} && \left( \mathit{weight}_{t,g} \right)_{g \in \mathcal{G}} & \in \mathrm{SOS}2 && \forall\, t \in \mathcal{T} +\text{weight sos} && \left( \mathit{weight}_{t,g} \right)_{g \in \mathcal{G}} & \in \mathrm{SOS}2 && \forall\, t \in \mathcal{T} \\ +\text{fuel} && \mathit{fuel}_{t,g} & \ge 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{heat} && \mathit{heat}_{t,g} & \ge 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{op\_cost} && \mathit{op\_cost}_{t,g} & \ge 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{warm} && \mathit{warm}_{t,g} & \in \{0, 1\} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{is\_flexible}_{g} \end{align} \end{document} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 09439aee..06a3f08e 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -12,6 +12,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathcal{Z}`$ | index $`z`$ — `zone` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$ | | $`\mathcal{S}`$ | index $`s`$ — `season` with $`\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}`$ | | $`\mathcal{E}`$ | index $`e`$ — `technology` with $`\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$ | +| $`\mathcal{A}`$ | index $`a`$ — `bp` | #### Parameters @@ -29,6 +30,10 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathrm{lead}`$ | `lead` over $`\mathcal{G}`$ | | $`\mathrm{budget}`$ | `budget` (scalar) | | $`\mathrm{growth}`$ | `growth` (scalar) | +| $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_heat}`$ | `bp_heat` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$ | #### Variables @@ -44,6 +49,10 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathit{reserve}`$ | `reserve` (scalar) | | $`\mathit{headroom}`$ | `headroom` (scalar) | | $`\mathit{weight}`$ | `weight` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{fuel}`$ | `fuel` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{heat}`$ | `heat` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{warm}`$ | `warm` over $`\mathcal{T} \times \mathcal{G}`$ | #### Definitions @@ -304,6 +313,24 @@ p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\ \mathit{slack}_{t} \ge 0 \qquad \forall\, t \in \mathcal{T} \,:\, \bot ``` +**`fuel_curve`** + +```math +\left( p_{t,g},\ \mathit{fuel}_{t,g},\ \mathit{heat}_{t,g} \right) \in \mathit{on}_{t,g} \cdot \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a},\ \mathrm{bp\_heat}_{g,a}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`cost_curve`** + +```math +\mathit{op\_cost}_{t,g} \ge \begin{cases} \mathit{warm}_{t,g} & \text{if } \mathrm{is\_flexible}_{g} \\ 1 & \text{otherwise} \end{cases} \cdot \mathrm{pwl}_{a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a})(p_{t,g}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`hull_curve`** + +```math +\left( p_{t,g},\ \mathit{fuel}_{t,g} \right) \in \mathrm{conv}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + #### Definitions **`spend`** @@ -397,3 +424,27 @@ p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\ ```math \left( \mathit{weight}_{t,g} \right)_{g \in \mathcal{G}} \in \mathrm{SOS}2 \qquad \forall\, t \in \mathcal{T} ``` + +**`fuel`** + +```math +\mathit{fuel}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`heat`** + +```math +\mathit{heat}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`warm`** + +```math +\mathit{warm}_{t,g} \in \{0, 1\} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{is\_flexible}_{g} +``` diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 33ffee55..e17b8572 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -19,6 +19,7 @@ dimensions: zone: { dtype: str } season: { dtype: str } technology: { dtype: str } + bp: { dtype: int } # the breakpoints a curve runs through relations: gen_bus: { key: generator, values: bus } @@ -43,6 +44,10 @@ parameters: lead: { dims: [generator], dtype: int } budget: { dims: [] } # scalar: the legend says so rather than printing an empty product growth: { dims: [] } # the base of a power; the exponent is `lead`, a column + bp_x: { dims: [generator, bp] } # the x-axis of every curve below + bp_y: { dims: [generator, bp] } + bp_heat: { dims: [generator, bp] } + bp_run: { dims: [generator, bp], dtype: bool } # how far each curve runs, so a block has a mask to print variables: p: # both bounds, and a where with all three connectives @@ -77,6 +82,19 @@ variables: weight: # the family a sos runs along dims: [snapshot, generator] bounds: { lower: 0, upper: 1 } + fuel: # a curve's second axis + dims: [snapshot, generator] + bounds: { lower: 0 } + heat: # its third, so one curve ties three expressions + dims: [snapshot, generator] + bounds: { lower: 0 } + op_cost: # bounded by a curve rather than pinned to it + dims: [snapshot, generator] + bounds: { lower: 0 } + warm: # a gate not every unit has, so the curve it gates is ungated where it does not exist + dims: [snapshot, generator] + domain: binary + where: "is_flexible" sos: adjacent: # at most two adjacent members nonzero, one set per snapshot @@ -84,6 +102,29 @@ sos: over: generator type: 2 +piecewise: + fuel_curve: # three expressions on one curve, gated by a binary every unit has + over: bp + links: + - [p, bp_x] + - [fuel, bp_y] + - [heat, bp_heat] + activity: on + cost_curve: # a bounded link, which prints as the side of the curve it is on, and a gate only some units have + over: bp + method: sos2 + points: bp_run + activity: warm + links: + - [p, bp_x] + - [op_cost, bp_y, ">="] + hull_curve: # the convex method, whose weights range over the hull the breakpoints span rather than the curve + over: bp + method: convex + links: + - [p, bp_x] + - [fuel, bp_heat] + expressions: spend: # a plain named expression: its symbol prints where it is used, its body once as a definition description: what a snapshot's dispatch costs diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 3a437390..7918e119 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -10,6 +10,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $cal(Z)$: index $z$ --- `zone` with $upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z)$ / $cal(S)$: index $s$ --- `season` with $upright("season_of"): cal(T) arrow.r cal(S)$ / $cal(E)$: index $e$ --- `technology` with $upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E)$ +/ $cal(A)$: index $a$ --- `bp` == Parameters / $upright("p")^(upright("max"))$: `p_max` over $cal(G)$ @@ -24,6 +25,10 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $upright("lead")$: `lead` over $cal(G)$ / $upright("budget")$: `budget` (scalar) / $upright("growth")$: `growth` (scalar) +/ $upright("bp_x")$: `bp_x` over $cal(G) times cal(A)$ +/ $upright("bp_y")$: `bp_y` over $cal(G) times cal(A)$ +/ $upright("bp_heat")$: `bp_heat` over $cal(G) times cal(A)$ +/ $upright("bp_run")$: `bp_run` over $cal(G) times cal(A)$ == Variables / $p$: `p` over $cal(T) times cal(G)$ @@ -36,6 +41,10 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("reserve")$: `reserve` (scalar) / $italic("headroom")$: `headroom` (scalar) / $italic("weight")$: `weight` over $cal(T) times cal(G)$ +/ $italic("fuel")$: `fuel` over $cal(T) times cal(G)$ +/ $italic("heat")$: `heat` over $cal(T) times cal(G)$ +/ $italic("op_cost")$: `op_cost` over $cal(T) times cal(G)$ +/ $italic("warm")$: `warm` over $cal(T) times cal(G)$ == Definitions / $italic("spend")$: `spend` over $cal(T)$ --- what a snapshot's dispatch costs @@ -100,7 +109,10 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in upright("ceiling") & theta_(b) & <= infinity & forall b in cal(B) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ upright("redundant") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon italic("spill")_(t) upright(" exists") \ - upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ + upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot \ + upright("fuel_curve") & (p_(t,g), italic("fuel")_(t,g), italic("heat")_(t,g)) & in italic("on")_(t,g) dot upright("pwl")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_y")_(g,a), upright("bp_heat")_(g,a)) & forall t in cal(T), g in cal(G) \ + upright("cost_curve") & italic("op_cost")_(t,g) & >= cases(italic("warm")_(t,g) & upright("if ") upright("is_flexible")_(g), 1 & upright("otherwise")) dot upright("pwl")_(a in cal(A) colon upright("bp_run")_(g,a))(upright("bp_x")_(g,a), upright("bp_y")_(g,a))(p_(t,g)) & forall t in cal(T), g in cal(G) \ + upright("hull_curve") & (p_(t,g), italic("fuel")_(t,g)) & in upright("conv")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_heat")_(g,a)) & forall t in cal(T), g in cal(G) $ == Definitions #set math.equation(numbering: "(1)") @@ -121,4 +133,8 @@ $ upright("p") & upright("p")^(upright("min"))_(g) <= p_(t,g) & <= upright("p")^ upright("reserve") & italic("reserve") & >= 0 \ upright("headroom") & italic("headroom") & >= 0 & upright("where ") upright("budget") upright(" is defined") \ upright("weight") & 0 <= italic("weight")_(t,g) & <= 1 & forall t in cal(T), g in cal(G) \ - upright("weight sos") & (italic("weight")_(t,g))_(g in cal(G)) & in upright("SOS")2 & forall t in cal(T) $ + upright("weight sos") & (italic("weight")_(t,g))_(g in cal(G)) & in upright("SOS")2 & forall t in cal(T) \ + upright("fuel") & italic("fuel")_(t,g) & >= 0 & forall t in cal(T), g in cal(G) \ + upright("heat") & italic("heat")_(t,g) & >= 0 & forall t in cal(T), g in cal(G) \ + upright("op_cost") & italic("op_cost")_(t,g) & >= 0 & forall t in cal(T), g in cal(G) \ + upright("warm") & italic("warm")_(t,g) & in {0, 1} & forall t in cal(T), g in cal(G) colon upright("is_flexible")_(g) $ diff --git a/tests/typesetting/test_declaration.py b/tests/typesetting/test_declaration.py index d1872884..37e00dce 100644 --- a/tests/typesetting/test_declaration.py +++ b/tests/typesetting/test_declaration.py @@ -127,11 +127,13 @@ def test_a_body_naming_another_expression_inlines_it_on_its_own_and_names_it_in_ @pytest.mark.parametrize( ('name', 'match'), [ - pytest.param('spent', r"'spent' is not a named expression, constraint or variable.*spend", id='a-near-miss'), + pytest.param( + 'spent', r"'spent' is not a named expression, constraint, curve or variable.*spend", id='a-near-miss' + ), pytest.param('objective', r"'objective' is not a named expression", id='the-objective-has-no-name'), ], ) -def test_a_name_declared_as_none_of_the_three_is_refused(name: str, match: str): +def test_a_name_declared_as_none_of_the_four_is_refused(name: str, match: str): with pytest.raises(SchemaError, match=match): typeset_declaration(PLAIN, name, 'latex') diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index e93b0712..d0d7b95a 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -17,7 +17,6 @@ from math_spec._expression_parser import ArithmeticNode, ComparisonNode, DualNode, FunctionCallNode from math_spec.operators import BUILTIN_NAMES -from math_spec.piecewise import expand_piecewise from math_spec.program import WhereNode from math_spec.typesetting import FORMATS, to_latex, typeset, walk from math_spec.typesetting.format import OPERATOR_NAMES @@ -119,8 +118,13 @@ def _nodes(tree: object) -> Iterator[object]: def _rendered_trees() -> Iterator[object]: - """Every resolved tree the walk is handed for the golden model.""" - resolved = expand_piecewise(to_spec(golden.MODEL)).resolved + """Every resolved tree the walk is handed for the golden model. + + The model as the file declares it, because that is what the walk prints: a + curve's links are trees of its own, and the rows it stands for are not + printed at all. + """ + resolved = to_spec(golden.MODEL).resolved yield resolved.objective for expression, mask in resolved.constraints.values(): yield expression @@ -130,6 +134,8 @@ def _rendered_trees() -> Iterator[object]: if mask is not None: yield mask.root yield from resolved.expressions.values() + for links in resolved.piecewise.values(): + yield from links #: What resolution never hands the walk: the three nodes it types away, and the @@ -221,8 +227,9 @@ def test_the_golden_model_reaches_every_line_of_the_walk(tmp_path: Path): 'to_latex(model)\n' 'to_latex(model, inline_expressions=True)\n' 'spec = to_spec(model)\n' - 'for name in (*spec.expressions, *spec.constraints, *spec.variables):\n' + 'for name in (*spec.expressions, *spec.constraints, *spec.piecewise, *spec.variables):\n' " typeset_declaration(model, name, 'latex')\n" + 'to_latex(spec.expand())\n' ) subprocess.run( [ diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 1fa637a9..1a3cca1b 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -12,8 +12,7 @@ import pytest from math_spec.errors import LanguageError -from math_spec.piecewise import expand_piecewise -from math_spec.typesetting import FORMATS, SymbolTable, to_latex, typeset +from math_spec.typesetting import FORMATS, SymbolTable, to_latex, to_markdown, typeset, typeset_declaration from math_spec.typesetting.format import OPERATOR_NAMES from math_spec.typesetting.symbols import Symbols, _derive_name_symbol, chosen_expressions from math_spec.validation import to_spec @@ -512,7 +511,7 @@ def test_a_parameter_is_upright_and_a_variable_is_italic(name: FormatName, fmt: def test_nothing_the_model_is_given_prints_italic(): """The convention as a property of the whole document, not of a fragment: a rendering path added later reaches the page through its own call.""" - schema = expand_piecewise(to_spec(golden.MODEL)) + schema = to_spec(golden.MODEL) computed = set(schema.variables) | chosen_expressions(schema) italic = {m.replace(r'\_', '_') for m in re.findall(r'\\mathit\{([^}]*)\}', to_latex(golden.MODEL))} assert italic <= computed, ( @@ -825,3 +824,103 @@ def test_a_string_value_in_a_where_prints_as_a_quoted_label(name: FormatName, fm assert fmt.quoted('gas_ccgt') in text unquoted = text.replace(fmt.quoted('gas_ccgt'), '') assert fmt.prose('gas_ccgt') not in unquoted, 'a string value is data, never words inside math' + + +#: One curve, varied per case: two links pinned to it, over one breakpoint dim. +_CURVE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bp': {'dtype': 'int'}}, + 'parameters': { + 'bp_x': {'dims': ['bp']}, + 'bp_y': {'dims': ['bp']}, + 'reaches': {'dims': ['bp'], 'dtype': 'bool'}, + 'committable': {'dims': ['snapshot'], 'dtype': 'bool'}, + }, + 'variables': { + 'p': {'dims': ['snapshot'], 'bounds': {'lower': 0, 'upper': 100}}, + 'op_cost': {'dims': ['snapshot'], 'bounds': {'lower': 0}}, + 'on': {'dims': ['snapshot'], 'domain': 'binary'}, + 'warm': {'dims': ['snapshot'], 'domain': 'binary', 'where': 'committable'}, + }, + 'piecewise': {'curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(op_cost, over=snapshot)'}, +} + + +@pytest.mark.parametrize( + ('patch', 'expected'), + [ + pytest.param( + {}, + r'\left( p_{t},\ \mathit{op\_cost}_{t} \right) \in \mathrm{pwl}_{b \in \mathcal{B}}' + r'(\mathrm{bp\_x}_{b},\ \mathrm{bp\_y}_{b})', + id='the-links-are-a-point-on-the-curve', + ), + pytest.param( + {'piecewise.curve.method': 'convex'}, + r'\in \mathrm{conv}_{b \in \mathcal{B}}', + id='the-convex-method-relaxes-it-onto-the-hull', + ), + pytest.param( + {'piecewise.curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']], 'piecewise.curve.method': 'lp'}, + r'\mathit{op\_cost}_{t} \ge \mathrm{pwl}_{b \in \mathcal{B}}' + r'(\mathrm{bp\_x}_{b},\ \mathrm{bp\_y}_{b})(p_{t})', + id='a-bounded-link-states-one-side-of-the-curve', + ), + pytest.param( + {'piecewise.curve.points': 'reaches'}, + r'\mathrm{pwl}_{b \in \mathcal{B} \,:\, \mathrm{reaches}_{b}}', + id='points-narrows-the-breakpoints-to-the-ones-it-admits', + ), + pytest.param( + {'piecewise.curve.activity': 'on'}, + r'\in \mathit{on}_{t} \cdot \mathrm{pwl}', + id='a-gate-multiplies-the-curve', + ), + ], +) +def test_a_curve_prints_as_the_curve_it_states(patch: dict[str, Any], expected: str): + """The block, not the rows it stands for: `typeset(spec.expand())` prints those.""" + assert expected in typeset_declaration(override(_CURVE, **patch), 'curve', 'latex') + + +def test_a_gate_that_does_not_exist_everywhere_prints_the_two_arms_the_expansion_writes_two_rows_for(): + """The one place the walk decides what the weights sum to, which the expansion decides again.""" + spec = to_spec(override(_CURVE, **{'piecewise.curve.activity': 'warm'})) + + rows = [name for name in spec.expand('piecewise').constraints if name.startswith('curve_convexity')] + assert rows == ['curve_convexity', 'curve_convexity_ungated'], ( + 'one row where the gate exists and one where it does not, because a row with an absent variable is no row' + ) + assert ( + r'\begin{cases} \mathit{warm}_{t} & \text{if } \mathrm{committable}_{t} \\ 1 ' + r'& \text{otherwise} \end{cases} \cdot \mathrm{pwl}' + ) in typeset_declaration(spec, 'curve', 'latex'), 'and the factor on the curve carries the same two arms' + + +def test_a_curve_prints_over_the_frame_its_expansion_builds_one_per_coordinate_of(): + """Two homes for one union, so the line's quantifier is held to the rows the expansion emits.""" + model = override( + _CURVE, + **{ + 'dimensions.generator': {'dtype': 'str'}, + 'parameters.bp_x.dims': ['generator', 'bp'], + 'parameters.bp_y.dims': ['generator', 'bp'], + 'variables.p.dims': ['snapshot', 'generator'], + 'variables.op_cost.dims': ['snapshot', 'generator'], + 'objective.expression': 'sum(op_cost)', + }, + ) + spec = to_spec(model) + emitted = spec.expand('piecewise').constraints['curve_link0'].dims + + printed = typeset_declaration(spec, 'curve', 'latex') + assert printed.endswith(r'\forall\, t \in \mathcal{T},\ g \in \mathcal{G}') + assert emitted == ['snapshot', 'generator'], 'the quantifier above is that frame, in that order' + + +def test_the_expansion_prints_the_rows_the_block_states(): + """Which is the whole reason the block prints as one line: the two readings are one call apart.""" + spec = to_spec(_CURVE) + + assert 'curve_lam' not in to_markdown(spec), 'nothing a curve emits is named where the curve itself prints' + assert 'curve_convexity' in to_markdown(spec.expand()), 'and every row of it is named where the expansion prints' diff --git a/tools/notation.py b/tools/notation.py index affe89ef..9feb1106 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -28,16 +28,15 @@ PAGE = ROOT / 'docs' / 'reference' / 'notation.md' MODEL = ROOT / 'tests' / 'typesetting' / 'golden' / 'model.yaml' -#: One model per ``method:``, because the four expand to four different -#: formulations and a section showing one of them would be showing a quarter +#: One model per ``method:``, because the four restrict the weights four +#: different ways and a section showing one of them would be showing a quarter #: of the construct. ``tests/test_docs.py`` holds these keys to #: :data:`math_spec.model.PIECEWISE_METHODS`, so a method added to the #: language arrives here or the page stops claiming to be all of them. #: -#: They come from real models rather than from the fixture because expanding a -#: curve round-trips the model through ``model_dump``, which drops a bound of -#: ``.inf`` on the wrong side of the line — the one thing that makes the walk -#: print ∞ — so a fixture carrying a curve would stop covering two symbols. +#: They come from real models rather than from the fixture because a caption +#: saying what a method is for reads against a model that had a reason to +#: choose it. PIECEWISE = { 'adjacency': ROOT / 'examples' / 'ports' / 'transport_pwl.yaml', 'sos2': ROOT / 'examples' / 'sos.yaml', @@ -55,7 +54,7 @@ 'constraints': 'Constraints', 'expressions': 'Definitions', 'variables': 'Variable domains', - 'piecewise': 'Curves, as what they expand to', + 'piecewise': 'Curves', 'sos': 'Sets carried to the solver', } @@ -200,8 +199,9 @@ def block() -> str: parts.append(f'### {title}') if section == 'piecewise': parts.append( - 'A curve is sugar: what prints is the formulation it expands to, which is the math the solver ' - 'receives. One row per `method:`, each from the model named under it, so the symbols in this ' + 'A curve prints as the curve it states, over the frame the block builds one per coordinate of. ' + 'What it expands to is the math the solver receives, and `typeset(spec.expand())` prints that ' + 'instead. One row per `method:`, each from the model named under it, so the symbols in this ' "section are that model's." ) parts += _curves() @@ -234,18 +234,18 @@ def _curves() -> list[str]: def _table_shown(table: Path | None) -> str: """The symbol table, printed beside the math it renamed. - A curve expands to weights named after the block that declared them, which - an equation naming one six times cannot carry. Renaming them in the - typesetter would be a symbol a reader could not trace back to the file, so - the rename is a **declaration** — the same ``--symbols`` sidecar any reader - may write — and the page shows it rather than performing it. + A curve prints through its breakpoint parameters, whose names are the data + preparation's rather than the literature's. Renaming them in the typesetter + would be a symbol a reader could not trace back to the file, so the rename + is a **declaration** — the same ``--symbols`` sidecar any reader may write — + and the page shows it rather than performing it. """ if table is None: return '' body = without_header(table) return ( f'Rendered with the sidecar symbol table `{table.relative_to(ROOT)}`, ' - f'which is what the weights print as:\n\n```yaml\n{body}\n```\n\n' + f'which is what the breakpoints print as:\n\n```yaml\n{body}\n```\n\n' ) @@ -259,20 +259,18 @@ def _row(declaration: Declaration, printed: dict[str, str]) -> str: def _labels(declaration: Declaration, printed: dict[str, str]) -> list[str]: """Which printed equations belong to *declaration*. - Two blocks do not print under their own name. A ``sos:`` block restricts a - variable, so its line sits with that variable; a ``piecewise:`` block is - sugar, and what prints is the rows and columns it expands to — every one of - which the expander names after the block. A declaration printing nothing is - an error rather than an empty row: it means the walk stopped rendering - something the file still declares. + One block does not print under its own name: a ``sos:`` block restricts a + variable, so its line sits with that variable. A declaration printing + nothing is an error rather than an empty row: it means the walk stopped + rendering something the file still declares. """ if declaration.name in printed: return [declaration.name] - if (variable := declaration.field('variable')) and f'{variable} sos' in printed: - return [f'{variable} sos'] - expanded = [label for label in printed if label.startswith(f'{declaration.name}_')] - assert expanded, f'{declaration.name} declares math and the walk printed none of it' - return expanded + variable = declaration.field('variable') + assert variable and f'{variable} sos' in printed, ( + f'{declaration.name} declares math and the walk printed none of it' + ) + return [f'{variable} sos'] def rendered_page(page: str) -> str: From d88412534488e3a4a26c3f254f9b120417e5a78d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 16:25:37 +0000 Subject: [PATCH 2/4] feat(typesetting): the rows a formulation states are printable, and one symbol table spells both readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A symbol table entry may name what a `piecewise:` or `sos:` block emits, so the table that renames `_lam` to lambda renders the file it came from too — the expansion is consulted only where an entry needs it. The three typeset verbs take `--expand`, because a shell cannot compose `spec.expand()` the way a caller does. The notation page shows both readings of every formulation: the curve or the set as the file states it, and the rows it is written out as, per `method:`. A linking row leaves out a coefficient of 1, which is the common one — a weight and a binary are both bounded by 1 — so `lam <= seg + shift(seg, …)` reads as the literature writes it rather than carrying a factor that multiplies nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGvzXkPDrzi6f1PNmvebhc --- docs/howto/print.md | 13 +++- docs/reference/language/piecewise.md | 3 +- docs/reference/notation.md | 106 ++++++++++++++++++++++++++- docs/reference/typeset.md | 27 ++++++- examples/symbols/piecewise.yaml | 7 +- examples/symbols/sos.yaml | 11 ++- examples/symbols/transport_pwl.yaml | 8 +- src/math_spec/__main__.py | 12 ++- src/math_spec/sos.py | 16 +++- src/math_spec/typesetting/symbols.py | 19 ++++- tests/test_sos.py | 6 +- tests/typesetting/test_cli.py | 12 +++ tests/typesetting/test_symbols.py | 30 ++++++++ tools/notation.py | 49 ++++++++++--- 14 files changed, 284 insertions(+), 35 deletions(-) diff --git a/docs/howto/print.md b/docs/howto/print.md index 7b839de2..d94bc409 100644 --- a/docs/howto/print.md +++ b/docs/howto/print.md @@ -47,7 +47,18 @@ keep the document current as the file changes. `notation: typst` or none. Without `--standalone` the output is a fragment to `\input` or `#include` into a paper. -4. **Keep it current** with a rule in the paper's build: +4. **Print the rows a solver holds** with `--expand`, where the model states a + curve or a set and the reader wants the formulation rather than the + construct: + + ```bash + python -m math_spec markdown model.yaml --expand + ``` + + The same table serves both renders: a name the expansion emits, such as + `cost_curve_lam`, may be spelled in it. + +5. **Keep it current** with a rule in the paper's build: ```make model.tex: model.yaml model.symbols.yaml diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index bd307a6c..2db048d5 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -187,7 +187,8 @@ these, for a set `s` over variable `x` along `d`: | `2` | `s_adjacency`: `x <= bound * (s_seg + shift(s_seg, along=d, offset=1, edge=0))` | The coefficient is the block's `bound:` where it declares one, and the member's -own `bounds.upper` otherwise. A binary member's is 1, from its domain. +own `bounds.upper` otherwise. A binary member's is 1, from its domain, and a +coefficient of 1 is left out of the row rather than printed. The rewrite states the same feasible set as the set itself only for a member at or above zero linked by a finite coefficient, so a model is refused at load diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 71e82357..72eeb624 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -974,7 +974,7 @@ warm: ### Curves -A curve prints as the curve it states, over the frame the block builds one per coordinate of. What it expands to is the math the solver receives, and `typeset(spec.expand())` prints that instead. One row per `method:`, each from the model named under it, so the symbols in this section are that model's. +A curve prints as the curve it states, over the frame the block builds one per coordinate of, and its expansion prints the rows that curve stands for. One row per `method:`, each from the model named under it, so the symbols in this section are that model's. #### `economies_of_scale` @@ -986,6 +986,8 @@ Rendered with the sidecar symbol table `examples/symbols/transport_pwl.yaml`, wh notation: latex names: + economies_of_scale_lam: "\\lambda" + economies_of_scale_seg: "\\delta" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" ``` @@ -1002,6 +1004,36 @@ economies_of_scale: \left( \mathit{shipment}_{p,m},\ \mathit{scaled}_{p,m} \right) \in \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{b},\ \mathrm{y}_{b}) \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} ``` +Written out by `spec.expand()`: + +```math +\sum_{b \in \mathcal{B}} \lambda_{p,m,b} = 1 \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} +``` + +```math +\mathit{shipment}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot \mathrm{x}_{b} \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} +``` + +```math +\mathit{scaled}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot \mathrm{y}_{b} \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} +``` + +```math +\sum_{b \in \mathcal{B}} \delta_{p,m,b} \le 1 \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M} +``` + +```math +\lambda_{p,m,b} \le \delta_{p,m,b} + \delta_{p,m,b \boxminus_{0} 1} \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M},\ b \in \mathcal{B} +``` + +```math +0 \le \lambda_{p,m,b} \le 1 \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M},\ b \in \mathcal{B} +``` + +```math +\delta_{p,m,b} \in \{0, 1\} \qquad \forall\, p \in \mathcal{P},\ m \in \mathcal{M},\ b \in \mathcal{B} +``` + #### `cost_curve` **`method: sos2`** — the same weights, restricted by a set the solver branches on (the sos rules), in `examples/sos.yaml`. @@ -1012,6 +1044,7 @@ Rendered with the sidecar symbol table `examples/symbols/sos.yaml`, which is wha notation: latex names: + cost_curve_lam: "\\lambda" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" ``` @@ -1029,6 +1062,28 @@ cost_curve: \left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +Written out by `spec.expand()`: + +```math +\sum_{b \in \mathcal{B}} \lambda_{t,g,b} = 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +```math +\mathit{dispatch}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +```math +\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{y}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +```math +0 \le \lambda_{t,g,b} \le 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} +``` + +```math +\left( \lambda_{t,g,b} \right)_{b \in \mathcal{B}} \in \mathrm{SOS}2 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + #### `cost_curve` **`method: convex`** — nothing — the weights range over the hull, which is a pure LP, in `examples/piecewise.yaml`. @@ -1039,6 +1094,7 @@ Rendered with the sidecar symbol table `examples/symbols/piecewise.yaml`, which notation: latex names: + cost_curve_lam: "\\lambda" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" ``` @@ -1056,6 +1112,24 @@ cost_curve: \left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{conv}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +Written out by `spec.expand()`: + +```math +\sum_{b \in \mathcal{B}} \lambda_{t,g,b} = 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +```math +\mathit{dispatch}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +```math +\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{y}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +```math +0 \le \lambda_{t,g,b} \le 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} +``` + #### `cost_curve` **`method: lp`** — no weights at all — one row per segment line, plus the two rows holding the domain, in `examples/piecewise_lp.yaml`. @@ -1083,8 +1157,24 @@ cost_curve: \mathit{op\_cost}_{t,g} \ge \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b})(\mathit{dispatch}_{t,g}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +Written out by `spec.expand()`: + +```math +\mathit{op\_cost}_{t,g} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \ge \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( \mathit{dispatch}_{t,g} - \mathrm{x}_{g,b} \right) + \mathrm{y}_{g,b} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) \neq 0 +``` + +```math +\mathit{dispatch}_{t,g} \ge \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) = 0 +``` + +```math +\mathit{dispatch}_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) = \lvert \mathcal{B} \rvert - 1 +``` + ### Sets carried to the solver +A set prints beside the variable it restricts, because it restricts that variable rather than adding a row of its own. Under it are the rows it is written out as. + #### `adjacent` at most two adjacent members nonzero, one set per snapshot @@ -1099,4 +1189,18 @@ adjacent: ```math \left( \mathit{weight}_{t,g} \right)_{g \in \mathcal{G}} \in \mathrm{SOS}2 \qquad \forall\, t \in \mathcal{T} ``` + +Written out by `spec.expand()`: + +```math +\sum_{g \in \mathcal{G}} \mathit{adjacent\_seg}_{t,g} \le 1 \qquad \forall\, t \in \mathcal{T} +``` + +```math +\mathit{weight}_{t,g} \le \mathit{adjacent\_seg}_{t,g} + \mathit{adjacent\_seg}_{t,g \boxminus_{0} 1} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +```math +\mathit{adjacent\_seg}_{t,g} \in \{0, 1\} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` diff --git a/docs/reference/typeset.md b/docs/reference/typeset.md index 4bbd3177..eef52f21 100644 --- a/docs/reference/typeset.md +++ b/docs/reference/typeset.md @@ -42,6 +42,7 @@ a flag. | `legend` | `--no-legend` | Print the table of sets, parameters, variables and definitions above the math. Default: on | | `numbered` | `--no-numbers` | Number the equations. Default: on | | `inline_expressions` | `--inline-expressions` | Substitute each named expression that the math reads into the equations that read it, instead of defining it once. Default: off | +| — | `--expand` | Print the variables and constraints the `piecewise:` and `sos:` blocks state, rather than the blocks. Default: off | `-o FILE` writes to a file instead of stdout. @@ -104,6 +105,30 @@ A name that is none of the three kinds is refused with the near miss. A name tha is both a constraint and a variable is refused too, because one line can print only one of them. +## Printing what a formulation states + +A `piecewise:` block and a `sos:` block each state variables and constraints +([formulations](language/piecewise.md#writing-a-formulation-out)). Printing +those rows is printing a different model, so it is +[`expand()`](language/piecewise.md#writing-a-formulation-out) that produces it +and not an option on the render: + +```python +ms.to_latex(spec) # the curve, and the set beside its variable +ms.to_latex(spec.expand()) # the weights, the convexity row, the binaries +ms.to_latex(spec.expand('sos')) # the curves as curves, the sets as binaries +``` + +A shell cannot compose that, so the command line spells it as a flag: + +```bash +python -m math_spec latex model.yaml --expand --symbols model.symbols.yaml +``` + +One symbol table serves both, because a name a formulation emits counts as +declared — which is what lets `_lam` print as $\lambda$ in the expansion +and the same table render the file it came from. + ## Symbol tables With no table, the symbols are **derived** from the names in the file, such as @@ -149,7 +174,7 @@ names: Every spelling is printed as you wrote it, and nothing translates notation, so rendering a LaTeX table as Typst is refused. A key that names nothing in the -model is an error with the near miss. +model, and nothing a formulation of it emits, is an error with the near miss. Nothing in a symbol table changes what the file means. What a declaration _is_ stays in its own `description:`. diff --git a/examples/symbols/piecewise.yaml b/examples/symbols/piecewise.yaml index 52972877..195bba6f 100644 --- a/examples/symbols/piecewise.yaml +++ b/examples/symbols/piecewise.yaml @@ -2,11 +2,12 @@ # # SPDX-License-Identifier: MIT -# The sidecar symbol table for `examples/piecewise.yaml`. A curve prints through -# its breakpoints, and `x` and `y` are what the piecewise-linear literature -# calls them. +# The sidecar symbol table for `examples/piecewise.yaml`. `x` and `y` are what +# the piecewise-linear literature calls the breakpoints, and `lambda` the +# convex-combination weight the expansion writes out. notation: latex names: + cost_curve_lam: "\\lambda" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" diff --git a/examples/symbols/sos.yaml b/examples/symbols/sos.yaml index 1e051e10..fab51379 100644 --- a/examples/symbols/sos.yaml +++ b/examples/symbols/sos.yaml @@ -2,12 +2,15 @@ # # SPDX-License-Identifier: MIT -# The sidecar symbol table for `examples/sos.yaml`. A curve prints through its -# breakpoints, and `x` and `y` are what the piecewise-linear literature calls -# them — said out loud here rather than the typesetter renaming anything behind -# the reader's back. +# The sidecar symbol table for `examples/sos.yaml`. It spells both readings of +# the model: the breakpoints the curve prints through, and the weights the +# expansion writes out, which are named after the block that declared them and +# unreadable in an equation that names one six times. Papers write the +# convex-combination weight as lambda, so this says so out loud rather than the +# typesetter renaming anything behind the reader's back. notation: latex names: + cost_curve_lam: "\\lambda" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" diff --git a/examples/symbols/transport_pwl.yaml b/examples/symbols/transport_pwl.yaml index 3e722c50..1302af0c 100644 --- a/examples/symbols/transport_pwl.yaml +++ b/examples/symbols/transport_pwl.yaml @@ -2,11 +2,15 @@ # # SPDX-License-Identifier: MIT -# The sidecar symbol table for `examples/ports/transport_pwl.yaml`. A curve -# prints through its breakpoints, and `x` and `y` are what the piecewise-linear +# The sidecar symbol table for `examples/ports/transport_pwl.yaml`. The +# adjacency method writes out two families — the convex-combination weights and +# the binary that says which segment is live — and names both after the block, +# which no equation can carry. Lambda and delta are what the piecewise-linear # literature calls them. notation: latex names: + economies_of_scale_lam: "\\lambda" + economies_of_scale_seg: "\\delta" bp_x: "\\mathrm{x}" bp_y: "\\mathrm{y}" diff --git a/src/math_spec/__main__.py b/src/math_spec/__main__.py index bad13fa2..b090897d 100644 --- a/src/math_spec/__main__.py +++ b/src/math_spec/__main__.py @@ -5,7 +5,9 @@ """``python -m math_spec model.yaml`` — the shell front. ``check`` loads the file and prints the language's advice; one further verb -per typeset format, read off :data:`math_spec.typesetting.FORMATS`. +per typeset format, read off :data:`math_spec.typesetting.FORMATS`. Those verbs +take ``--expand``, because a shell cannot compose +:meth:`~math_spec.model.Spec.expand` the way a caller does. """ from __future__ import annotations @@ -17,6 +19,7 @@ from math_spec.advice import advice from math_spec.errors import MathSpecError from math_spec.typesetting import FORMATS, typeset +from math_spec.validation import to_spec def parser() -> argparse.ArgumentParser: @@ -38,6 +41,11 @@ def parser() -> argparse.ArgumentParser: verb.add_argument( '--inline-expressions', action='store_true', help='substitute each named expression where it is used' ) + verb.add_argument( + '--expand', + action='store_true', + help='print the variables and constraints the piecewise: and sos: blocks state, not the blocks', + ) return front @@ -56,7 +64,7 @@ def main(argv: list[str] | None = None) -> int: sys.stdout.write(''.join(f'{note}\n' for note in notes)) return 0 text = typeset( - args.model, + to_spec(args.model).expand() if args.expand else args.model, args.verb, symbols=args.symbols, standalone=args.standalone, diff --git a/src/math_spec/sos.py b/src/math_spec/sos.py index d481c96e..64cc217d 100644 --- a/src/math_spec/sos.py +++ b/src/math_spec/sos.py @@ -112,10 +112,18 @@ def emit(raw: dict[str, object], name: str) -> None: 'dims': [d for d in dims if d != over], 'expression': f'sum({emitted.seg}, over={over}) <= 1', } - constraints[emitted.link] = { - 'dims': dims, - 'expression': f'{variable} <= {_multiplier(block, member)} * ({picked})', - } + constraints[emitted.link] = {'dims': dims, 'expression': f'{variable} <= {_linked(block, member, picked)}'} + + +def _linked(block: dict[str, object], member: dict[str, object], picked: str) -> str: + """What a member is at most: the binaries it is admitted by, times the coefficient. + + A coefficient of 1 is left out. It is the common one — a weight and a binary + are both bounded by 1 — and ``x <= 1 * (seg)`` is a factor every reader of + the row has to work out means nothing. + """ + factor = _multiplier(block, member) + return f'({picked})' if factor == 1.0 else f'{factor} * ({picked})' def _multiplier(block: dict[str, object], member: dict[str, object]) -> float | str: diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index 203dbf83..2d58fe30 100644 --- a/src/math_spec/typesetting/symbols.py +++ b/src/math_spec/typesetting/symbols.py @@ -237,11 +237,17 @@ def load(cls, source: str | Path | Mapping[str, object]) -> SymbolTable: ) def checked_against(self, schema: Spec) -> SymbolTable: - """Reject entries naming nothing in *schema*, with the near miss.""" + """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 | set(schema.parameters) | set(schema.variables) | set(schema.expressions) | set(schema.constraints) - ) + 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), @@ -251,6 +257,11 @@ def checked_against(self, schema: Spec) -> SymbolTable: return self +def _declared(schema: Spec) -> set[str]: + """Every name *schema* declares that a table entry may spell.""" + return set(schema.parameters) | set(schema.variables) | set(schema.expressions) | set(schema.constraints) + + def _section(raw: Mapping[str, object], name: str) -> Mapping[str, object]: """The *name* section of a symbol table as the mapping it has to be, empty where it is absent or null. diff --git a/tests/test_sos.py b/tests/test_sos.py index 6872f83f..66a2da64 100644 --- a/tests/test_sos.py +++ b/tests/test_sos.py @@ -67,11 +67,11 @@ def test_the_declared_bound_is_the_coefficient_rather_than_the_tighter_of_it_and assert schema.expand('sos').constraints['pick_nonzero'].expression == 'p <= 500.0 * (pick_seg)' -def test_a_binary_member_links_by_the_one_its_domain_fixes(): - """A binary carries no bounds block, and its upper bound is 1 all the same.""" +def test_a_coefficient_of_one_is_left_out_of_the_row_rather_than_printed(): + """A binary carries no bounds block, and its upper bound is 1 all the same — which multiplies nothing.""" schema = schema_of(override(PICKED, **{'variables.p': {'dims': ['g'], 'domain': 'binary'}})) - assert schema.expand('sos').constraints['pick_nonzero'].expression == 'p <= 1.0 * (pick_seg)' + assert schema.expand('sos').constraints['pick_nonzero'].expression == 'p <= (pick_seg)' def test_the_emitted_binary_carries_the_members_own_mask(): diff --git a/tests/typesetting/test_cli.py b/tests/typesetting/test_cli.py index ef4f65a9..03e82560 100644 --- a/tests/typesetting/test_cli.py +++ b/tests/typesetting/test_cli.py @@ -132,6 +132,18 @@ def test_inline_expressions_substitutes_the_named_expressions_away(capsys): assert r'\mathit{spend}' in defined and r'\mathit{spend}' not in expanded +def test_expand_prints_the_rows_the_blocks_state_rather_than_the_blocks(capsys): + """A shell cannot write `spec.expand()`, so the flag is the composition.""" + assert front.main(['latex', MODEL, '--no-legend']) == 0 + stated = capsys.readouterr().out + assert front.main(['latex', MODEL, '--no-legend', '--expand']) == 0 + written = capsys.readouterr().out + + assert r'\mathrm{pwl}' in stated and r'\mathrm{pwl}' not in written, 'the curve prints as a curve, once' + assert r'\mathrm{SOS}' in stated and r'\mathrm{SOS}' not in written, 'and the set as a set, once' + assert r'\mathit{hull\_curve\_lam}' in written, 'the weights print where the rows do' + + def test_a_format_nothing_can_render_is_refused_rather_than_guessed(): """The failure worth excluding is a front that writes an empty file. diff --git a/tests/typesetting/test_symbols.py b/tests/typesetting/test_symbols.py index b0ea7db1..872d3219 100644 --- a/tests/typesetting/test_symbols.py +++ b/tests/typesetting/test_symbols.py @@ -12,6 +12,7 @@ from math_spec.errors import SchemaError from math_spec.typesetting import SymbolTable, to_latex, to_markdown, to_typst, typeset +from math_spec.validation import to_spec from tests.fixtures import DISPATCH_MODEL, override from tests.typesetting.fixtures import EVERY_FORMAT, TYPST_SYMBOLS @@ -93,6 +94,35 @@ def test_a_named_expression_has_a_legend_row_exactly_while_its_symbol_prints(nam assert 'what a snapshot costs' not in typeset(DESCRIBED, name, inline_expressions=True) +#: The dispatch model with a curve on it, so one model has two readings and one +#: table has to spell both. +CURVED = override( + DISPATCH_MODEL, + **{ + 'dimensions.bp': {'dtype': 'int'}, + 'parameters.bp_x': {'dims': ['generator', 'bp']}, + 'parameters.bp_y': {'dims': ['generator', 'bp']}, + 'variables.op_cost': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}, + 'piecewise.curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}, + }, +) + + +def test_one_table_spells_the_blocks_a_file_states_and_the_rows_they_state(): + """The weights are named after the block, which no equation can carry, and the + table that renames them has to render the file they came from too.""" + spec = to_spec(CURVED) + table = {'notation': 'latex', 'names': {'curve_lam': r'\lambda'}} + + assert r'\lambda' not in to_latex(spec, symbols=table, legend=False), 'no weight stands where the curve prints' + assert r'\lambda_{t,g,b}' in to_latex(spec.expand(), symbols=table, legend=False) + + +def test_a_misspelled_name_is_still_a_typo_where_a_formulation_could_have_emitted_it(): + with pytest.raises(SchemaError, match="Did you mean 'curve_lam'"): + to_latex(CURVED, symbols={'notation': 'latex', 'names': {'curve_laam': 'x'}}) + + @pytest.mark.parametrize( ('symbols', 'match'), [ diff --git a/tools/notation.py b/tools/notation.py index 9feb1106..4b08cda3 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -20,6 +20,7 @@ from math_spec.model import PIECEWISE_METHODS from math_spec.typesetting import to_markdown +from math_spec.validation import to_spec from tools._page import ROOT, sidecar_for, splice, without_header from tools._page import main as page_main @@ -195,27 +196,42 @@ def block() -> str: legend(rendered), ] printed = equations(rendered) + written = equations(to_markdown(to_spec(MODEL).expand('sos'), numbered=False)) for section, title in SECTIONS.items(): parts.append(f'### {title}') + found = declarations(MODEL.read_text())[section] if section == 'piecewise': parts.append( - 'A curve prints as the curve it states, over the frame the block builds one per coordinate of. ' - 'What it expands to is the math the solver receives, and `typeset(spec.expand())` prints that ' - 'instead. One row per `method:`, each from the model named under it, so the symbols in this ' - "section are that model's." + 'A curve prints as the curve it states, over the frame the block builds one per coordinate of, ' + 'and its expansion prints the rows that curve stands for. One row per `method:`, each from the ' + "model named under it, so the symbols in this section are that model's." ) parts += _curves() continue - parts += [_row(found, printed) for found in declarations(MODEL.read_text())[section]] + if section == 'sos': + parts.append( + 'A set prints beside the variable it restricts, because it restricts that variable rather than ' + 'adding a row of its own. Under it are the rows it is written out as.' + ) + parts += [f'{_row(one, printed)}\n\n{_written_out(one.name, written)}' for one in found] + continue + parts += [_row(one, printed) for one in found] return '\n\n'.join(parts) def _curves() -> list[str]: - """One row per ``method:``, each captioned with what that method restricts.""" + """One row per ``method:``, each captioned with what that method restricts. + + Both readings come from one model and one symbol table: the block as the + file states it, and the rows ``expand('piecewise')`` writes out — which for + ``sos2`` keeps the set and for ``adjacency`` is the binaries that set states. + """ rows = [] for method, source in PIECEWISE.items(): table = sidecar_for(source) - printed = equations(to_markdown(source, symbols=table, numbered=False)) + spec = to_spec(source) + stated = equations(to_markdown(spec, symbols=table, numbered=False)) + written = equations(to_markdown(spec.expand('piecewise'), symbols=table, numbered=False)) found = [ block for block in declarations(source.read_text())['piecewise'] @@ -223,14 +239,29 @@ def _curves() -> list[str]: ] assert found, f'{source.name} declares no piecewise block with method: {method}' for block in found: - row = _row(block, printed) + row = _row(block, stated) caption = ( f'**`method: {method}`** \N{EM DASH} {PIECEWISE_METHODS[method]}, in `{source.relative_to(ROOT)}`.' ) - rows.append(row.replace('\n\n', f'\n\n{caption}\n\n{_table_shown(table)}', 1)) + rows.append( + row.replace('\n\n', f'\n\n{caption}\n\n{_table_shown(table)}', 1) + + f'\n\n{_written_out(block.name, written)}' + ) return rows +def _written_out(name: str, printed: dict[str, str]) -> str: + """The rows the formulation *name* states, as its expansion prints them. + + Everything an expansion writes is named after the block that stated it, so + the block's own name is what collects the lines back together. + """ + rows = [math for label, math in printed.items() if label.startswith(f'{name}_')] + assert rows, f'{name} states rows and its expansion printed none of them' + body = '\n\n'.join(rows) + return f'Written out by `spec.expand()`:\n\n{body}' + + def _table_shown(table: Path | None) -> str: """The symbol table, printed beside the math it renamed. From 83cda5b883230cc3e3fb0fe93b59bee9d329aebf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 17:43:47 +0000 Subject: [PATCH 3/4] test(expand): what a curve assumes of its numbers is pinned across writing it out `lp` and `convex` are exact only for a curve of the right shape, which the program carries as a check for the consumer holding the data. Nothing said that `expand()` keeps it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGvzXkPDrzi6f1PNmvebhc --- tests/test_expand.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_expand.py b/tests/test_expand.py index be36ad69..737a2064 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -17,7 +17,7 @@ from math_spec.errors import SchemaError from math_spec.lowering import to_program -from tests.fixtures import DISPATCH_MODEL, override, schema_of +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, override, schema_of from tests.test_sos import CURVE from tools.render_tex import models @@ -86,6 +86,29 @@ def test_an_expansion_that_derived_nothing_is_still_a_file(): assert expanded.to_yaml(), 'a curve with no mask emits no parameter, so nothing is lost by writing it out' +#: The two methods a curve is exact for only under a condition on its numbers, +#: which is the contract an expansion must not drop. +ASSUMED = [ + pytest.param(EXAMPLES / 'piecewise_lp.yaml', id='lp'), + pytest.param(EXAMPLES / 'piecewise.yaml', id='convex'), +] + + +@pytest.mark.parametrize('model', ASSUMED) +def test_what_a_curve_assumes_of_its_numbers_rides_on_the_expansion_too(model): + """`lp` and `convex` are exact only for a curve of the right shape, which no load + decides. The program carries the condition for the consumer that has the numbers, + and writing the curve out must not be the way a model loses it.""" + spec = schema_of(model) + stated = to_program(spec).piecewise['cost_curve'].checks + written_out = to_program(spec.expand()).piecewise['cost_curve'].checks + + assert {type(check).__name__ for check in stated} >= {'Increasing', 'Curved'}, ( + 'the breakpoints increase and the curve bends one way, both checked where the data is' + ) + assert written_out == stated, 'and the expansion carries every condition the block came with' + + @pytest.mark.parametrize('model', MODELS, ids=[m.stem for m in MODELS]) def test_the_same_sources_bind_a_model_and_its_expansion(model): """What a formulation may emit, asserted on every model the repository ships: From 67d4966e75fe9d080b4f5e8d8914d34a454fe871 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 19:14:20 +0000 Subject: [PATCH 4/4] feat(language): a set admits a member whose lower bound is negative or carried by the data The expansion writes a second linking row, `x >= lower * admitted`, so an unpicked member is held at zero from below as well as above. A row multiplies by its coefficient rather than reading it, so a parameter-valued bound needs no knowledge of its value and the sign of the lower bound stops mattering. The load rule is symmetric and shorter for it: each side of a member carries a coefficient, or the set is refused. A `lower` of 0 writes no row, because the variable's own bound already states it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGvzXkPDrzi6f1PNmvebhc --- docs/reference/language/piecewise.md | 56 +++++++++++++-------- schema/math-spec.schema.json | 2 +- src/math_spec/model.py | 39 +++++++-------- src/math_spec/sos.py | 75 +++++++++++++++++----------- tests/test_sos.py | 32 ++++++++++++ tests/test_validation.py | 17 ++----- 6 files changed, 134 insertions(+), 87 deletions(-) diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 2db048d5..8c7f5c88 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -176,28 +176,40 @@ the `over` dimension. ### What a set is written out as `spec.expand('sos')` states the set as binaries: one per member for `type: 1`, -one per segment for `type: 2`. The names are the block's own, and the rows are -these, for a set `s` over variable `x` along `d`: - -| `type` | Emitted | -| ------ | ------------------------------------------------------------------------------- | -| both | `s_seg`, a binary over `x`'s own dims, masked as `x` is | -| both | `s_pick`: `sum(s_seg, over=d) <= 1` | -| `1` | `s_nonzero`: `x <= bound * (s_seg)` | -| `2` | `s_adjacency`: `x <= bound * (s_seg + shift(s_seg, along=d, offset=1, edge=0))` | - -The coefficient is the block's `bound:` where it declares one, and the member's -own `bounds.upper` otherwise. A binary member's is 1, from its domain, and a -coefficient of 1 is left out of the row rather than printed. - -The rewrite states the same feasible set as the set itself only for a member at -or above zero linked by a finite coefficient, so a model is refused at load -unless both hold: - -- `bounds.lower` is a number of at least zero. A parameter there is data, and - whether it is at or above zero is not decidable without it. -- the set declares `bound:`, or the member declares `bounds.upper`, or the - member is `domain: binary`. +one per segment for `type: 2`. A member the binaries do not admit is held at +zero, from above and from below. The names are the block's own, and the rows are +these, for a set `s` over variable `x` along `d`, writing `admitted` for +`(s_seg)` at `type: 1` and `(s_seg + shift(s_seg, along=d, offset=1, edge=0))` +at `type: 2`: + +| Emitted | | +| -------------------------------------------------- | ------------------------------------------------- | +| `s_seg` | a binary over `x`'s own dims, masked as `x` is | +| `s_pick`: `sum(s_seg, over=d) <= 1` | at most one is picked | +| `s_nonzero` (`type: 1`), `s_adjacency` (`type: 2`) | `x <= upper * admitted` | +| the same name plus `_below` | `x >= lower * admitted`, where `lower` is not `0` | + +Each coefficient is read off the member's own `bounds:`, and the set's `bound:` +replaces the one above where it declares one. A binary member's are `0` and `1`, +from its domain. A row multiplies by its coefficient rather than reading it, so +a bound the data carries is a coefficient like any other: +`bounds: {lower: floor, upper: cap}` states `x >= floor * admitted` and +`x <= cap * admitted`. + +Two coefficients are left out rather than printed, because the row would state +what another row already does: a `1` above, and a `lower` of `0`, which the +variable's own bound states. + +So each side needs a coefficient, and a model is refused at load without one: + +- `bounds.lower`, a number or a parameter. An omitted lower bound leaves the + member free below zero, which no row can pull back. +- `bounds.upper`, a number or a parameter, or the set's `bound:`, or + `domain: binary`. + +A positive `bounds.lower` loads and is infeasible, as it is on a solver that +takes the set: an unpicked member has to be `0`, and its own bound says it is +above that. A name the expansion writes that the file already declares is refused at load too. diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 5fff517b..850fa88f 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -517,7 +517,7 @@ }, "SosBlock": { "additionalProperties": false, - "description": "A special-ordered set over one dimension of one variable.\n\nOne set per coordinate of the variable's ``dims`` minus ``over``; the\nmembers are the variable's *existing* coordinates along ``over``, in that\ndimension's declared order.\n\n``type: 1`` admits at most one nonzero member, ``type: 2`` at most two,\nand those two consecutive. A consumer with the concept takes the set as\none; :meth:`Spec.expand` states it as binaries instead, and ``bound`` is\nthe coefficient those rows link a member by, where the member's own\n``upper`` is not the one to use.", + "description": "A special-ordered set over one dimension of one variable.\n\nOne set per coordinate of the variable's ``dims`` minus ``over``; the\nmembers are the variable's *existing* coordinates along ``over``, in that\ndimension's declared order.\n\n``type: 1`` admits at most one nonzero member, ``type: 2`` at most two,\nand those two consecutive. A consumer with the concept takes the set as\none; :meth:`Spec.expand` states it as binaries instead, and ``bound`` is\nthe coefficient those rows link a member by from above, where the member's\nown ``upper`` is not the one to use.", "properties": { "bound": { "anyOf": [ diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 3e1de16c..8b0687dd 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -32,7 +32,7 @@ from math_spec._expression_parser import NAME, ComparisonOperator from math_spec.errors import SchemaError, did_you_mean, schema_error from math_spec.operators import BUILTIN_NAMES -from math_spec.sos import Emitted, coefficient +from math_spec.sos import Emitted, coefficients if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping @@ -628,8 +628,8 @@ class SosBlock(_StrictBlock): ``type: 1`` admits at most one nonzero member, ``type: 2`` at most two, and those two consecutive. A consumer with the concept takes the set as one; :meth:`Spec.expand` states it as binaries instead, and ``bound`` is - the coefficient those rows link a member by, where the member's own - ``upper`` is not the one to use. + the coefficient those rows link a member by from above, where the member's + own ``upper`` is not the one to use. """ _label: ClassVar[str] = 'a sos declaration' @@ -1068,35 +1068,30 @@ def _sos_shapes(self) -> Iterator[str]: claimed[block.variable] = sname def _sos_bounds(self) -> Iterator[str]: - """A set states what the binaries it expands to state: its members start at zero, and one coefficient links them. + """A set states what the binaries it expands to state: each side of a member carries a coefficient. - Decided here rather than where the rewrite runs, so a set the language - cannot state twice is refused before any data exists and no consumer - asks the question again. A parameter-valued ``lower`` is data, so - whether it is at or above zero is not decidable here. + The rewrite holds an unpicked member at zero from both sides, so a side + the model leaves open leaves the member free of it. Either coefficient + may be a parameter, because a row multiplies by it rather than reading + it. Decided here rather than where the rewrite runs, so a set the + language cannot state twice is refused before any data exists. """ for sname, block in self.sos.items(): if (member := self.variables.get(block.variable)) is None: continue context = f"Sos '{sname}'" - lower = 0.0 if member.domain == 'binary' else member.bounds.lower - if isinstance(lower, str): + below, above = coefficients(block.bound, member.domain, member.bounds.lower, member.bounds.upper) + if below is None: yield ( - f"{context}: variable '{block.variable}' bounds.lower is the parameter '{lower}', and the " - f'set expands to rows that bound a member from above only, so a member below zero stays ' - f'free of them. Declare a literal bounds.lower of at least zero.' + f"{context}: variable '{block.variable}' has no lower bound, and the set expands to rows " + f'that hold an unpicked member at zero from below as well as above. Declare bounds.lower, ' + f'as a number or a parameter.' ) - elif lower < 0: - yield ( - f"{context}: variable '{block.variable}' has bounds.lower {lower}, and the set expands to " - f'rows that bound a member from above only, so a member below zero stays free of them. ' - f'Declare bounds.lower of at least zero.' - ) - if coefficient(block.bound, member.domain, member.bounds.upper) is None: + if above is None: yield ( f"{context}: variable '{block.variable}' has no upper bound, and the set expands to rows " - f'that link each member to a binary by one coefficient. Declare bounds.upper on the ' - f'variable, or bound: on the set.' + f'that hold an unpicked member at zero from above as well as below. Declare bounds.upper, ' + f'as a number or a parameter, or bound: on the set.' ) def _sos_emitted_names(self) -> Iterator[str]: diff --git a/src/math_spec/sos.py b/src/math_spec/sos.py index 64cc217d..9f36eacb 100644 --- a/src/math_spec/sos.py +++ b/src/math_spec/sos.py @@ -6,9 +6,10 @@ A set becomes ordinary declarations under names prefixed with the block's own, the way a ``piecewise:`` block becomes weights and rows; what it emits is -tabled in ``docs/reference/language/piecewise.md``. The rewrite states the same -feasible set as the set itself only for a member at or above zero linked by a -finite coefficient, so a model declaring a set without those is refused at load +tabled in ``docs/reference/language/piecewise.md``. An unpicked member is held +at zero from both sides, so the rewrite states the same feasible set whatever +sign the member takes — what it needs is a coefficient on each side, which a +model declaring a set without is refused at load for (:meth:`math_spec.model.Spec` validates it) rather than here. """ @@ -21,19 +22,26 @@ from math_spec.model import SosType, Spec -def coefficient(bound: float | None, domain: str, upper: float | str) -> float | str | None: - """What a member's linking row multiplies its binary by, or ``None`` where the model states none that is finite. +#: One member's two linking coefficients, below and above. ``None`` on a side is +#: a side the model leaves open, where no rewrite can hold the member at zero. +Coefficients = tuple[float | str | None, float | str | None] - The set's own ``bound:`` first, so the coefficient a reader sees is the one - the file chose rather than the tighter of two; then the 1 a binary's domain - fixes, which no bounds block carries; then the member's declared ``upper``, - a number or the name of a parameter. + +def coefficients(bound: float | None, domain: str, lower: float | str, upper: float | str) -> Coefficients: + """What a member's two linking rows multiply its binary by, ``None`` on a side the model leaves open. + + The set's own ``bound:`` is the one above, so the coefficient a reader sees + is the one the file chose rather than the tighter of two; then the 0 and 1 a + binary's domain fixes, which no bounds block carries; then the member's own + declared bounds, each a number or the name of a parameter. A parameter is a + coefficient like any other: it is what the row multiplies by, and no rewrite + needs to know its value. """ + fixed = domain == 'binary' + below = 0.0 if fixed else (None if lower == float('-inf') else lower) if bound is not None: - return bound - if domain == 'binary': - return 1.0 - return None if upper == float('inf') else upper + return below, bound + return below, (1.0 if fixed else (None if upper == float('inf') else upper)) @dataclass(frozen=True) @@ -42,7 +50,8 @@ class Emitted: The linking row is named after what it says, which the two orders do not share: order 2 admits a member in either half of one segment, and order 1 - admits it alone. + admits it alone. ``below`` is the same row from underneath, written only + where the member may be nonzero below zero. """ seg: str @@ -52,12 +61,18 @@ class Emitted: @classmethod def of(cls, name: str, order: SosType) -> Emitted: """The names set *name* of *order* writes.""" - return cls(f'{name}_seg', f'{name}_pick', f'{name}_{"adjacency" if order == 2 else "nonzero"}') + admits = 'adjacency' if order == 2 else 'nonzero' + return cls(f'{name}_seg', f'{name}_pick', f'{name}_{admits}') + + @property + def below(self) -> str: + """The linking row from underneath.""" + return f'{self.link}_below' @property def by_kind(self) -> tuple[tuple[str, tuple[str, ...]], ...]: """Each name by the kind of declaration it would collide with.""" - return (('variable', (self.seg,)), ('constraint', (self.pick, self.link))) + return (('variable', (self.seg,)), ('constraint', (self.pick, self.link, self.below))) #: What the binary says, per order — the description its legend entry carries. @@ -112,32 +127,36 @@ def emit(raw: dict[str, object], name: str) -> None: 'dims': [d for d in dims if d != over], 'expression': f'sum({emitted.seg}, over={over}) <= 1', } - constraints[emitted.link] = {'dims': dims, 'expression': f'{variable} <= {_linked(block, member, picked)}'} + below, above = _coefficients(block, member) + constraints[emitted.link] = {'dims': dims, 'expression': f'{variable} <= {_scaled(above, picked)}'} + if below != 0.0: + constraints[emitted.below] = {'dims': dims, 'expression': f'{variable} >= {_scaled(below, picked)}'} -def _linked(block: dict[str, object], member: dict[str, object], picked: str) -> str: - """What a member is at most: the binaries it is admitted by, times the coefficient. +def _scaled(factor: float | str, picked: str) -> str: + """The binaries a member is admitted by, times *factor*. A coefficient of 1 is left out. It is the common one — a weight and a binary are both bounded by 1 — and ``x <= 1 * (seg)`` is a factor every reader of the row has to work out means nothing. """ - factor = _multiplier(block, member) return f'({picked})' if factor == 1.0 else f'{factor} * ({picked})' -def _multiplier(block: dict[str, object], member: dict[str, object]) -> float | str: - """The coefficient as an expression writes it, read off the set and its member.""" - bounds: dict[str, object] = {'upper': float('inf')} +def _coefficients(block: dict[str, object], member: dict[str, object]) -> tuple[float | str, float | str]: + """The two coefficients as an expression writes them, read off the set and its member.""" + bounds: dict[str, object] = {'lower': float('-inf'), 'upper': float('inf')} declared = member.get('bounds') assert declared is None or isinstance(declared, dict), 'a validated model carries a bounds block as a mapping' bounds.update(declared or {}) - upper, bound = bounds['upper'], block.get('bound') - assert isinstance(upper, float | str), 'a bound is a number or the name of a parameter' + lower, upper, bound = bounds['lower'], bounds['upper'], block.get('bound') + assert isinstance(lower, float | str) and isinstance(upper, float | str), ( + 'a bound is a number or the name of a parameter' + ) assert bound is None or isinstance(bound, float), 'the schema holds bound: to a number' - found = coefficient(bound, str(member.get('domain', 'continuous')), upper) - assert found is not None, 'a set whose member has no finite coefficient is refused at load' - return found + below, above = coefficients(bound, str(member.get('domain', 'continuous')), lower, upper) + assert below is not None and above is not None, 'a set with a side left open is refused at load' + return below, above def _section(raw: dict[str, object], name: str) -> dict[str, object]: diff --git a/tests/test_sos.py b/tests/test_sos.py index 66a2da64..8be14acf 100644 --- a/tests/test_sos.py +++ b/tests/test_sos.py @@ -20,6 +20,7 @@ PICKED = override( SMALL_MODEL, **{ + 'parameters.floor': {'dims': ['g']}, 'variables.p.bounds': {'lower': 0, 'upper': 10}, 'constraints': {'used': {'dims': ['g'], 'expression': 'p <= c'}}, 'sos': {'pick': {'variable': 'p', 'over': 'g', 'type': 1}}, @@ -60,6 +61,37 @@ def test_a_set_of_order_two_admits_a_member_in_either_half_of_one_segment(): assert 'pick_nonzero' not in expanded.constraints, 'the order decides which linking row is written' +@pytest.mark.parametrize( + ('bounds', 'rows'), + [ + pytest.param( + {'lower': 0, 'upper': 10}, + {'pick_nonzero': 'p <= 10.0 * (pick_seg)'}, + id='a-member-that-starts-at-zero-needs-one-row', + ), + pytest.param( + {'lower': -5, 'upper': 10}, + {'pick_nonzero': 'p <= 10.0 * (pick_seg)', 'pick_nonzero_below': 'p >= -5.0 * (pick_seg)'}, + id='a-member-that-may-go-negative-is-held-from-below-too', + ), + pytest.param( + {'lower': 'floor', 'upper': 'c'}, + {'pick_nonzero': 'p <= c * (pick_seg)', 'pick_nonzero_below': 'p >= floor * (pick_seg)'}, + id='a-bound-the-data-carries-is-a-coefficient-like-any-other', + ), + ], +) +def test_an_unpicked_member_is_held_at_zero_from_the_sides_its_bounds_state(bounds, rows): + """A row multiplies by a bound rather than reading it, so a parameter needs no + load-time knowledge of its value; and `x >= 0 * seg` is what the variable's own + bound already says, so the second row is written only where it says more.""" + schema = schema_of(override(PICKED, **{'variables.p.bounds': bounds})) + expanded = schema.expand('sos') + + written = {name: c.expression for name, c in expanded.constraints.items() if name.startswith('pick_nonzero')} + assert written == rows, 'the rows a set states, and no row that states nothing' + + def test_the_declared_bound_is_the_coefficient_rather_than_the_tighter_of_it_and_the_upper(): """Two consumers took different sides of this and solved different models.""" schema = schema_of(override(PICKED, **{'sos.pick.bound': 500})) diff --git a/tests/test_validation.py b/tests/test_validation.py index 76894723..fabe8484 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -642,20 +642,9 @@ class TestRulesDecidedWithoutData: id='sos-over-a-member-with-no-coefficient', ), pytest.param( - { - 'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1, 'bound': 10}}, - 'variables.p.bounds': {'lower': -5}, - }, - ('bounds.lower -5.0', 'bound a member from above only'), - id='sos-over-a-member-that-may-go-negative', - ), - pytest.param( - { - 'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1, 'bound': 10}}, - 'variables.p.bounds': {'lower': 'c'}, - }, - ("bounds.lower is the parameter 'c'",), - id='sos-over-a-member-whose-floor-is-data', + {'sos': {'s': {'variable': 'p', 'over': 'g', 'type': 1}}, 'variables.p.bounds': {'upper': 10}}, + ("variable 'p' has no lower bound", 'Declare bounds.lower'), + id='sos-over-a-member-with-no-floor', ), pytest.param( {