mathspec 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mathspec/model.py ADDED
@@ -0,0 +1,905 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """The YAML surface's types — every block a file may contain, rooted at :class:`Spec`.
6
+
7
+ Nothing here has seen data.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import re
14
+ from functools import cached_property
15
+ from typing import TYPE_CHECKING, Annotated, ClassVar, Literal, Self, cast, get_args, override
16
+
17
+ from pydantic import (
18
+ BaseModel,
19
+ BeforeValidator,
20
+ ConfigDict,
21
+ Field,
22
+ ValidationError,
23
+ ValidationInfo,
24
+ ValidatorFunctionWrapHandler,
25
+ field_validator,
26
+ model_serializer,
27
+ model_validator,
28
+ )
29
+
30
+ from mathspec._expression_parser import NAME, ComparisonOperator
31
+ from mathspec.errors import did_you_mean, schema_error
32
+ from mathspec.program import (
33
+ DimensionDtype,
34
+ ObjectiveSense,
35
+ ParameterDtype,
36
+ PiecewiseMethod,
37
+ Program,
38
+ SosType,
39
+ VariableAbsence,
40
+ VariableDomain,
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Mapping
45
+
46
+ from pydantic import GetJsonSchemaHandler, SerializerFunctionWrapHandler
47
+ from pydantic.config import ExtraValues
48
+ from pydantic_core import CoreSchema
49
+
50
+
51
+ class _StrictBlock(BaseModel):
52
+ """Base for every schema model: unknown keys are an error, not a shrug.
53
+
54
+ A misspelled optional key would otherwise be dropped and its declaration
55
+ fall back to a default — ``boundz:`` leaves the variable unbounded,
56
+ ``wher:`` leaves it unmasked — building a model the file does not describe.
57
+ """
58
+
59
+ model_config = ConfigDict(extra='forbid')
60
+
61
+ #: What this model is called in a YAML file, for the error message.
62
+ _label: ClassVar[str]
63
+
64
+ @model_validator(mode='before')
65
+ @classmethod
66
+ def _reject_unknown_keys(cls, data: object) -> object:
67
+ """Name the near-miss, which is what a typo actually needs.
68
+
69
+ pydantic's own ``extra='forbid'`` is the backstop; this runs first
70
+ only for the wording.
71
+ """
72
+ if not isinstance(data, dict):
73
+ return data
74
+ known = set(cls.model_fields)
75
+ unknown = [k for k in data if isinstance(k, str) and k not in known]
76
+ if unknown:
77
+ raise ValueError(
78
+ '\n'.join(
79
+ f"unknown key '{k}' in {cls._label}. {did_you_mean(k, known, label='Valid keys')}" for k in unknown
80
+ )
81
+ )
82
+ return data
83
+
84
+
85
+ #: A block that states rows rather than being one, which :meth:`Spec.expand`
86
+ #: writes out on request.
87
+ Formulation = Literal['piecewise', 'sos']
88
+
89
+ #: The shape a method needs a curve to have to be exact on it, which the
90
+ #: ``<block>_curvature`` assumption states. ``convex`` and ``concave`` name the
91
+ #: side a bounded link binds from; ``either`` is the weaker condition a block
92
+ #: with both links pinned states — any single bend will do, and only a *mixed*
93
+ #: curve fails it.
94
+ Curvature = Literal['convex', 'concave', 'either']
95
+
96
+ #: The parameter dtypes that stand where a number belongs — a coefficient, a
97
+ #: term, a divisor, a bound. A label selects and a flag masks; neither is one.
98
+ NUMERIC_DTYPES: frozenset[ParameterDtype] = frozenset({'float', 'int'})
99
+
100
+ #: Every formulation, in the order :meth:`Spec.expand` writes them out: a curve
101
+ #: emits a set, and no set emits a curve.
102
+ FORMULATIONS: tuple[Formulation, ...] = ('piecewise', 'sos')
103
+
104
+
105
+ def _also_written_as(
106
+ core_schema: CoreSchema, handler: GetJsonSchemaHandler, shorthand: Mapping[str, object]
107
+ ) -> dict[str, object]:
108
+ """The block's own schema, widened to a *shorthand* its before-validator takes.
109
+
110
+ A ``mode='before'`` rewrite is invisible to pydantic, which generates the
111
+ schema from the post-rewrite fields alone, so the shorthand has to be added
112
+ back by hand or an editor red-squiggles the form the file is written in.
113
+
114
+ ``handler`` returns the definition itself on most versions and a ``$ref``
115
+ to it on pydantic 2.10; the ref is followed, because an ``anyOf`` branch
116
+ pointing at its own entry is a loop with the mapping form unreachable.
117
+ """
118
+ generated = handler(core_schema)
119
+ if set(generated) == {'$ref'}:
120
+ generated = handler.resolve_ref_schema(generated)
121
+ return {'anyOf': [dict(generated), shorthand]}
122
+
123
+
124
+ def side_columns(written: str | list[str] | dict[str, str] | None) -> tuple[tuple[str, str], ...]:
125
+ """``(role, dimension)`` per column of one side of a relation, in written order.
126
+
127
+ A bare name or a list names each column after the dimension it is over; a
128
+ mapping names the roles, which is what two columns over one dimension need.
129
+ """
130
+ if written is None:
131
+ return ()
132
+ if isinstance(written, dict):
133
+ return tuple(written.items())
134
+ return tuple((d, d) for d in ((written,) if isinstance(written, str) else written))
135
+
136
+
137
+ class RelationBlock(_StrictBlock):
138
+ """A named relation between dimensions: the columns a row is keyed by, and the columns that key determines.
139
+
140
+ Each side is a dimension, a list of them, or a mapping of column name to
141
+ dimension where two columns share one. ``key:`` is the claim the language
142
+ checks when the data is attached: one row per key tuple, so every ``values:`` column is a
143
+ function of it. A relation with no ``values:`` is **bare** — every column is
144
+ in its key, a row is its own identity, and nothing reads it::
145
+
146
+ relations:
147
+ gen_bus: {key: generator, values: bus}
148
+ gen_bt: {key: [generator], values: [bus, technology]}
149
+ zone_of: {key: [generator, period], values: zone}
150
+ ends: {key: line, values: {bus0: bus, bus1: bus}}
151
+ connection: {key: [generator, bus]}
152
+
153
+ An operator reads the table in the direction the call names
154
+ (``over=``, ``into=``), joining on the other key columns; the
155
+ declaration fixes no direction. The map itself is data, and arrives with the rest of it,
156
+ under the relation's name, one column per role.
157
+ """
158
+
159
+ _label: ClassVar[str] = 'a relation declaration'
160
+
161
+ key: str | list[str] | dict[str, str]
162
+ values: str | list[str] | dict[str, str] | None = None
163
+ description: str | None = None
164
+
165
+ @property
166
+ def pairs(self) -> tuple[tuple[str, str], ...]:
167
+ """``(role, dimension)`` per column, the key's columns first.
168
+
169
+ The program calls the same thing :attr:`~mathspec.program.RelationDeclaration.columns`;
170
+ here the table has no field of its own, being what the two sides make.
171
+ """
172
+ return (*side_columns(self.key), *side_columns(self.values))
173
+
174
+ @property
175
+ def roles(self) -> tuple[str, ...]:
176
+ return tuple(role for role, _ in self.pairs)
177
+
178
+ @property
179
+ def dims(self) -> tuple[str, ...]:
180
+ return tuple(dim for _, dim in self.pairs)
181
+
182
+ @property
183
+ def key_roles(self) -> tuple[str, ...]:
184
+ """The key roles, however ``key:`` was written."""
185
+ return tuple(role for role, _ in side_columns(self.key))
186
+
187
+ @property
188
+ def value_roles(self) -> tuple[str, ...]:
189
+ """The roles the key determines; empty for a bare relation."""
190
+ return tuple(role for role, _ in side_columns(self.values))
191
+
192
+
193
+ class DimensionBlock(_StrictBlock):
194
+ """A declared dimension, and the dtype its coordinates must be.
195
+
196
+ A dimension is an axis and nothing else: it declares that the axis exists
197
+ and what its coordinates are typed as, never which coordinates there are —
198
+ those are data, and arrive when the data is attached. The maps its members carry — a
199
+ generator's bus, a snapshot's period — are top-level ``relations:``
200
+ (:class:`RelationBlock`), keyed by their own name.
201
+ """
202
+
203
+ _label: ClassVar[str] = 'a dimension declaration'
204
+
205
+ dtype: DimensionDtype = 'str'
206
+ description: str | None = None
207
+
208
+
209
+ class ParameterBlock(_StrictBlock):
210
+ """A declared parameter with dims and dtype."""
211
+
212
+ _label: ClassVar[str] = 'a parameter declaration'
213
+
214
+ dims: list[str]
215
+ dtype: ParameterDtype = 'float'
216
+ description: str | None = None
217
+
218
+
219
+ class BoundsBlock(_StrictBlock):
220
+ """Variable bounds — each side is a finite number, a parameter name, or ``None`` where it is open.
221
+
222
+ An omitted bound leaves the variable unbounded on that side, not
223
+ implicitly non-negative. An infinity is refused: an open side is ``null``,
224
+ and the other infinity leaves no value at all.
225
+ """
226
+
227
+ _label: ClassVar[str] = 'a bounds block'
228
+
229
+ lower: float | str | None = None
230
+ upper: float | str | None = None
231
+
232
+ @field_validator('lower', 'upper', mode='before')
233
+ @classmethod
234
+ def _a_number_or_a_name(cls, v: object, info: ValidationInfo[object]) -> object:
235
+ if isinstance(v, bool):
236
+ msg = f'bounds.{info.field_name} is a boolean, and a bound is a number or a parameter name.'
237
+ raise ValueError(msg)
238
+ if isinstance(v, float) and math.isnan(v):
239
+ msg = f'bounds.{info.field_name} is nan, which no value compares to. Write a number, or omit the bound.'
240
+ raise ValueError(msg)
241
+ if isinstance(v, float | int) and math.isinf(v):
242
+ msg = (
243
+ f'bounds.{info.field_name} is {v}, and a bound is finite. An open side is null: '
244
+ f'write {info.field_name}: null, or leave it out.'
245
+ )
246
+ raise ValueError(msg)
247
+ return v
248
+
249
+ @model_validator(mode='after')
250
+ def _literals_do_not_cross(self) -> BoundsBlock:
251
+ """Two numbers that leave no value between them are refused; a named bound is data."""
252
+ if isinstance(self.lower, float) and isinstance(self.upper, float) and self.lower > self.upper:
253
+ msg = (
254
+ f'bounds.lower {self.lower} is above bounds.upper {self.upper}, so no value satisfies them. '
255
+ f'Swap them, or drop one.'
256
+ )
257
+ raise ValueError(msg)
258
+ return self
259
+
260
+
261
+ class VariableBlock(_StrictBlock):
262
+ """A declared decision variable."""
263
+
264
+ _label: ClassVar[str] = 'a variable declaration'
265
+
266
+ dims: list[str]
267
+ where: str | None = None
268
+ bounds: BoundsBlock = BoundsBlock()
269
+ domain: VariableDomain = 'continuous'
270
+ absence: VariableAbsence = 'undefined'
271
+ description: str | None = None
272
+
273
+ @model_validator(mode='after')
274
+ def _absence_needs_a_mask(self) -> VariableBlock:
275
+ """``absence:`` says what a *missing* coordinate means, so one must be missable."""
276
+ if self.absence != 'undefined' and self.where is None:
277
+ msg = (
278
+ f'absence: {self.absence} needs a `where:` — a variable with no mask exists at every '
279
+ f'coordinate of its dims, so there is no absence for it to describe. Add the mask, '
280
+ f'or drop the key.'
281
+ )
282
+ raise ValueError(msg)
283
+ return self
284
+
285
+
286
+ class ConstraintBlock(_StrictBlock):
287
+ """A declared constraint: one rule, over one frame."""
288
+
289
+ _label: ClassVar[str] = 'a constraint declaration'
290
+
291
+ dims: list[str]
292
+ where: str | None = None
293
+ expression: str
294
+ description: str | None = None
295
+
296
+
297
+ class ObjectiveBlock(_StrictBlock):
298
+ """A declared objective function."""
299
+
300
+ _label: ClassVar[str] = 'an objective declaration'
301
+
302
+ sense: ObjectiveSense = 'minimize'
303
+ expression: str
304
+ description: str | None = None
305
+
306
+
307
+ class MacroBlock(_StrictBlock):
308
+ """A parameterised expression template, defined in the YAML itself.
309
+
310
+ Language, not code: formals (``args`` positional, ``kwargs`` keyword)
311
+ shadow model names inside the template, and every call site expands in
312
+ the syntax tree before resolution reads the expression.
313
+ """
314
+
315
+ _label: ClassVar[str] = 'a macro declaration'
316
+
317
+ args: list[str] = []
318
+ kwargs: list[str] = []
319
+ template: str
320
+ description: str | None = None
321
+
322
+ @model_validator(mode='after')
323
+ def _check_formals(self) -> MacroBlock:
324
+ formals = [*self.args, *self.kwargs]
325
+ if len(set(formals)) != len(formals):
326
+ msg = f'duplicate formal names: {formals}'
327
+ raise ValueError(msg)
328
+ return self
329
+
330
+
331
+ def _number_is_an_expression(value: object) -> object:
332
+ """``expression: 0`` is how a file writes a constant — YAML reads it as an int.
333
+
334
+ Booleans are left to fail: ``true`` is not arithmetic, and an error naming
335
+ the type reads better than one naming ``'True'``.
336
+ """
337
+ return str(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else value
338
+
339
+
340
+ #: An expression string, or a number written as one.
341
+ Expression = Annotated[str, BeforeValidator(_number_is_an_expression, json_schema_input_type=str | float)]
342
+
343
+
344
+ class ExpressionCase(_StrictBlock):
345
+ """One region of a named expression: the value, and when it is the value.
346
+
347
+ Every case says where it applies. The value wherever none of them does is
348
+ the block's ``otherwise:``, which is written outside ``cases:`` because it
349
+ is not a region like these — it is what is left::
350
+
351
+ cases:
352
+ opening: { when: "position(snapshot) == 0", expression: p_max }
353
+ otherwise: 0
354
+ """
355
+
356
+ _label: ClassVar[str] = 'an expression case'
357
+
358
+ when: str
359
+ expression: Expression
360
+
361
+
362
+ class ExpressionBlock(_StrictBlock):
363
+ """A named quantity: one arithmetic expression, referenced by the math or read back after a solve.
364
+
365
+ Written in YAML as a bare string, or as a mapping once it carries a
366
+ ``description:`` — and serialised back to whichever form it was written in,
367
+ so a round trip through :meth:`Spec.to_yaml` reproduces the file::
368
+
369
+ expressions:
370
+ total_generation: sum(p, over=generator)
371
+ emissions:
372
+ expression: sum(p * rate, over=generator)
373
+ description: CO2 released, the quantity the cap bounds
374
+
375
+ A quantity whose value varies by region is written as ``cases:`` over a
376
+ declared ``dims:``, with an ``otherwise:`` for the rest — see the
377
+ language reference.
378
+ """
379
+
380
+ _label: ClassVar[str] = 'a named expression'
381
+
382
+ expression: Expression | None = None
383
+ #: The frame the cases are read over — required with them, refused without.
384
+ dims: list[str] | None = None
385
+ #: The regions, keyed by the name labelling the row each prints; every ``when`` is proved apart from the others.
386
+ cases: Annotated[dict[str, ExpressionCase], Field(min_length=1)] = {}
387
+ #: The value wherever no case's ``when`` holds, printed as the last row.
388
+ otherwise: Expression | None = None
389
+ description: str | None = None
390
+
391
+ @model_validator(mode='before')
392
+ @classmethod
393
+ def _from_string(cls, data: object) -> object:
394
+ return {'expression': data} if isinstance(data, str) else data
395
+
396
+ @model_validator(mode='after')
397
+ def _one_form_or_the_other(self) -> Self:
398
+ """One ``expression:``, or ``cases:`` with the ``otherwise:`` and ``dims:`` they need."""
399
+ if bool(self.cases) == (self.expression is not None):
400
+ got = 'both' if self.cases else 'neither'
401
+ msg = (
402
+ f'a named expression is one `expression:` or a set of `cases:`, and this has {got}. '
403
+ f'Cases are for a quantity whose value varies by region; one expression is everything else.'
404
+ )
405
+ raise ValueError(msg)
406
+ if self.cases and self.dims is None:
407
+ msg = (
408
+ '`cases:` needs a `dims:` — it is the frame the cases are read over, and no one '
409
+ "case's body gives it, since a case may be a scalar while the condition selecting it is not."
410
+ )
411
+ raise ValueError(msg)
412
+ if self.dims is not None and not self.cases:
413
+ msg = (
414
+ '`dims:` is only for a named expression with `cases:`. Without them the dims fall '
415
+ 'out of the body, and declaring a second answer is a second thing to keep true.'
416
+ )
417
+ raise ValueError(msg)
418
+ if self.cases and self.otherwise is None:
419
+ msg = (
420
+ 'a `cases:` block needs an `otherwise:` — the value wherever no `when` holds, and '
421
+ 'the row that prints as "otherwise". Without it the quantity would have no value '
422
+ 'there, and absence spreads to every constraint that names it.'
423
+ )
424
+ raise ValueError(msg)
425
+ if self.otherwise is not None and not self.cases:
426
+ msg = (
427
+ '`otherwise:` is what is left once the `cases:` have taken their regions, and there '
428
+ 'are none here. A value that holds everywhere is a plain `expression:`.'
429
+ )
430
+ raise ValueError(msg)
431
+ return self
432
+
433
+ @classmethod
434
+ @override
435
+ def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> dict[str, object]:
436
+ """The published schema admits the bare string the one-line form is written as."""
437
+ return _also_written_as(core_schema, handler, {'type': 'string'})
438
+
439
+ @model_serializer
440
+ def _as_written(self) -> str | dict[str, object]:
441
+ if self.cases:
442
+ written: dict[str, object] = {'dims': list(self.dims or [])}
443
+ if self.description is not None:
444
+ written['description'] = self.description
445
+ written['cases'] = {name: case.model_dump() for name, case in self.cases.items()}
446
+ written['otherwise'] = self.otherwise
447
+ return written
448
+ assert self.expression is not None
449
+ if self.description is None:
450
+ return self.expression
451
+ return {'expression': self.expression, 'description': self.description}
452
+
453
+
454
+ class AssumptionBlock(_StrictBlock):
455
+ """What the model assumes of its data: a predicate every coordinate it is checked at has to satisfy.
456
+
457
+ Written in YAML as a bare where string, or as a mapping once it carries a
458
+ ``where:`` or a ``description:``, and serialised back to whichever form it
459
+ was written in::
460
+
461
+ assumptions:
462
+ efficiency_is_a_fraction: "efficiency > 0 AND efficiency <= 1"
463
+ bounds_do_not_cross:
464
+ holds: "p_min <= p_max"
465
+ where: "p_min"
466
+ description: a unit with no minimum is unconstrained below
467
+
468
+ The language decides nothing about the numbers, so the consumer attaching
469
+ the data checks it, and refuses the data where it does not hold.
470
+ """
471
+
472
+ _label: ClassVar[str] = 'an assumption declaration'
473
+
474
+ #: The predicate, in the where grammar. It holds at every coordinate of
475
+ #: its own frame that ``where`` admits.
476
+ holds: str
477
+ #: Which coordinates it is checked at, in the same grammar; absent means every one.
478
+ where: str | None = None
479
+ #: Why the rule is there, in the author's words. The sentence a consumer
480
+ #: refuses with quotes it.
481
+ description: str | None = None
482
+
483
+ @model_validator(mode='before')
484
+ @classmethod
485
+ def _from_string(cls, data: object) -> object:
486
+ return {'holds': data} if isinstance(data, str) else data
487
+
488
+ @classmethod
489
+ @override
490
+ def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> dict[str, object]:
491
+ """The published schema admits the bare string the one-line form is written as."""
492
+ return _also_written_as(core_schema, handler, {'type': 'string'})
493
+
494
+ @model_serializer
495
+ def _as_written(self) -> str | dict[str, object]:
496
+ if self.where is None and self.description is None:
497
+ return self.holds
498
+ written: dict[str, object] = {'holds': self.holds}
499
+ if self.where is not None:
500
+ written['where'] = self.where
501
+ if self.description is not None:
502
+ written['description'] = self.description
503
+ return written
504
+
505
+
506
+ class PiecewiseLink(_StrictBlock):
507
+ """One link of a piecewise block: an expression pinned to a values curve.
508
+
509
+ Written in YAML as ``[expression, values]`` or ``[expression, values,
510
+ sign]`` and serialised back to exactly that form, so a round trip through
511
+ :meth:`Spec.to_yaml` reproduces the file.
512
+ """
513
+
514
+ _label: ClassVar[str] = 'a piecewise link'
515
+
516
+ expression: str
517
+ values: str
518
+ sign: ComparisonOperator = '=='
519
+
520
+ @model_validator(mode='before')
521
+ @classmethod
522
+ def _from_list(cls, data: object) -> object:
523
+ if isinstance(data, list):
524
+ if not 2 <= len(data) <= 3:
525
+ msg = f'each link must be [expression, values] or [expression, values, sign], got {data!r}'
526
+ raise ValueError(msg)
527
+ return dict(zip(('expression', 'values', 'sign'), data, strict=False))
528
+ return data
529
+
530
+ @classmethod
531
+ @override
532
+ def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> dict[str, object]:
533
+ """The published schema admits the ``[expression, values, sign?]`` form every link is written as."""
534
+ list_form = {'type': 'array', 'items': {'type': 'string'}, 'minItems': 2, 'maxItems': 3}
535
+ return _also_written_as(core_schema, handler, list_form)
536
+
537
+ @model_serializer
538
+ def _as_list(self) -> list[str]:
539
+ return [self.expression, self.values] if self.sign == '==' else [self.expression, self.values, self.sign]
540
+
541
+
542
+ #: How a ``piecewise:`` block restricts its interpolation weights, and what
543
+ #: each one emits. The key is ``method:`` because that is
544
+ #: ``linopy.Spec.add_piecewise_formulation``'s (#695); ``sos2`` and ``lp`` are
545
+ #: its words too, and mean the same things. ``adjacency`` and ``convex`` are
546
+ #: ours, linopy having no name for the first and reaching the second only as a
547
+ #: fallback.
548
+ PIECEWISE_METHODS = {
549
+ 'adjacency': 'a binary per segment, and a row making the two nonzero weights neighbours',
550
+ 'sos2': 'the same weights, restricted by a set the solver branches on (the sos rules)',
551
+ 'convex': 'nothing — the weights range over the hull, which is a pure LP',
552
+ 'lp': 'no weights at all — one row per segment line, plus the two rows holding the domain',
553
+ }
554
+
555
+
556
+ class PiecewiseBlock(_StrictBlock):
557
+ """N expressions jointly pinned to a breakpoint-indexed piecewise curve.
558
+
559
+ Mirrors ``linopy.Spec.add_piecewise_formulation``. Each link is
560
+ ``[expression, values_parameter]`` or ``[expression, values_parameter,
561
+ sign]``: *expression* is any affine expression string, *values_parameter*
562
+ names a parameter carrying the ``over`` dim, and *sign* bounds the link by
563
+ the curve instead of pinning it (at most one non-``"=="``, and only with
564
+ exactly two links).
565
+ """
566
+
567
+ _label: ClassVar[str] = 'a piecewise declaration'
568
+
569
+ #: The breakpoint dimension.
570
+ over: str
571
+ links: list[PiecewiseLink]
572
+ #: Which of :data:`PIECEWISE_METHODS` restricts the weights.
573
+ method: PiecewiseMethod = 'adjacency'
574
+ #: What the weights sum to — 1 where absent, or a binary that pins the formulation to 0 when it is 0.
575
+ activity: str | None = None
576
+ #: A boolean parameter saying how far each curve runs, for curves of unequal length.
577
+ points: str | None = None
578
+ description: str | None = None
579
+
580
+ @property
581
+ def nominated(self) -> str | None:
582
+ """The block's own values parameter ``points:`` names, so the mask is derived from it — or ``None``."""
583
+ return self.points if self.points in {link.values for link in self.links} else None
584
+
585
+ @property
586
+ def curve(self) -> tuple[PiecewiseLink, PiecewiseLink]:
587
+ """The two links as ``(x, y)``, the bounded one last.
588
+
589
+ Two-link blocks only.
590
+ """
591
+ x, y = self.links
592
+ return (y, x) if x.sign != '==' else (x, y)
593
+
594
+ @field_validator('method', mode='wrap')
595
+ @classmethod
596
+ def _check_method(cls, v: object, handler: ValidatorFunctionWrapHandler) -> PiecewiseMethod:
597
+ try:
598
+ return cast('PiecewiseMethod', handler(v))
599
+ except ValidationError:
600
+ options = '\n'.join(f' {name}: {what}' for name, what in PIECEWISE_METHODS.items())
601
+ msg = f'unknown piecewise method {v!r}. The formulations are:\n{options}'
602
+ raise ValueError(msg) from None
603
+
604
+ @model_validator(mode='after')
605
+ def _check_method_shape(self) -> PiecewiseBlock:
606
+ if self.method == 'convex' and len(self.links) != 2:
607
+ msg = (
608
+ 'method: convex requires exactly two links (the hull relaxation '
609
+ 'is only well-defined for a single y=f(x) curve).'
610
+ )
611
+ raise ValueError(msg)
612
+ if self.method == 'lp' and sum(link.sign != '==' for link in self.links) != 1:
613
+ msg = (
614
+ "method: lp needs exactly one link bounded by the curve — a '<=' or '>=' third "
615
+ 'element on it. With every link pinned the segment lines have nothing to bound.'
616
+ )
617
+ raise ValueError(msg)
618
+ if self.activity is not None and self.method in ('convex', 'lp'):
619
+ msg = f'activity is not supported with method: {self.method}.'
620
+ raise ValueError(msg)
621
+ return self
622
+
623
+ @field_validator('links')
624
+ @classmethod
625
+ def _check_links(cls, v: list[PiecewiseLink]) -> list[PiecewiseLink]:
626
+ if len(v) < 2:
627
+ msg = 'piecewise needs at least two links ([expression, values, sign?]).'
628
+ raise ValueError(msg)
629
+ non_eq = [link.sign for link in v if link.sign != '==']
630
+ if len(non_eq) > 1:
631
+ msg = "at most one link may carry a non-'==' sign."
632
+ raise ValueError(msg)
633
+ if non_eq and len(v) != 2:
634
+ msg = "a non-'==' sign is only supported with exactly two links."
635
+ raise ValueError(msg)
636
+ return v
637
+
638
+
639
+ #: The orders of special ordered set — nothing else is a construct solvers
640
+ #: have.
641
+ SOS_TYPES = frozenset(get_args(SosType))
642
+
643
+
644
+ class SosBlock(_StrictBlock):
645
+ """A special-ordered set over one dimension of one variable.
646
+
647
+ One set per coordinate of the variable's ``dims`` minus ``along``; the
648
+ members are the variable's *existing* coordinates along ``along``, in that
649
+ dimension's declared order.
650
+
651
+ ``type: 1`` admits at most one nonzero member, ``type: 2`` at most two,
652
+ and those two consecutive. A consumer with the concept takes the set as
653
+ one; :meth:`Spec.expand` states it as binaries instead, and the rows it
654
+ writes multiply by the member's own ``bounds``, which is why a member
655
+ needs both.
656
+ """
657
+
658
+ _label: ClassVar[str] = 'a sos declaration'
659
+
660
+ variable: str
661
+ along: str
662
+ type: SosType
663
+ description: str | None = None
664
+
665
+ @field_validator('type', mode='wrap')
666
+ @classmethod
667
+ def _check_type(cls, v: object, handler: ValidatorFunctionWrapHandler) -> SosType:
668
+ orders = ' or '.join(str(t) for t in sorted(SOS_TYPES))
669
+ msg = f'sos type must be {orders}, got {v!r}. A set of any other order is not a construct solvers carry.'
670
+ if type(v) is not int: # True == 1 == 1.0, and a set of order True is nothing
671
+ raise ValueError(msg)
672
+ try:
673
+ return cast('SosType', handler(v))
674
+ except ValidationError:
675
+ raise ValueError(msg) from None
676
+
677
+
678
+ #: The language surfaces this reader understands. A **language** version, not a
679
+ #: package one: it moves when the accepted YAML surface moves, which most
680
+ #: releases do not, so deriving it from the package version would be automatic
681
+ #: and wrong. `0` is the unstable surface — no compatibility promise, per
682
+ #: *breaking changes are free* in CONTRIBUTING.
683
+ SUPPORTED_VERSIONS: tuple[int, ...] = (0,)
684
+
685
+
686
+ def _without_absence(value: object) -> object:
687
+ """*value* with every absent entry stripped, recursively — see :meth:`Spec._drop_absence`."""
688
+ if not isinstance(value, dict):
689
+ return value
690
+ kept = {}
691
+ for key, before in value.items():
692
+ after = _without_absence(before)
693
+ if not _is_absent(after) and after != {}:
694
+ kept[key] = after
695
+ return kept
696
+
697
+
698
+ def _is_absent(value: object) -> bool:
699
+ """Whether *value* is a null."""
700
+ return value is None
701
+
702
+
703
+ class Spec(_StrictBlock):
704
+ """The declared math — one YAML file, or one dict, validated. Nothing here has seen data.
705
+
706
+ A ``Spec`` that exists has passed the whole language: constructing one by
707
+ any route — ``to_spec``, :meth:`model_validate`, the constructor — runs
708
+ every load-time check, expression pass included, and raises
709
+ :class:`~mathspec.errors.LanguageError` on a model the language refuses.
710
+ Holding one is the proof, so nothing downstream checks it again.
711
+
712
+ The API is the eleven declaration sections plus ``version`` and
713
+ ``description``, three ways back out — :meth:`to_dict` for the model as
714
+ data, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the
715
+ model with its formulations written out as plain rows — and :attr:`program`, the
716
+ model typed, which every reader after load walks. Everything else on this
717
+ class is pydantic's, not a contract this package keeps.
718
+ """
719
+
720
+ _label: ClassVar[str] = 'the top level of the file'
721
+
722
+ #: Which language surface this file is written against. Absent means 0, so
723
+ #: the field is additive. **0 means unstable** — the surface may change in
724
+ #: any release — and declaring it is what lets a later reader refuse a file
725
+ #: it cannot read rather than misinterpret it.
726
+ version: int = 0
727
+ #: What the file as a whole is, in the same plain prose a declaration's
728
+ #: ``description:`` takes. The typeset document opens with it.
729
+ description: str | None = None
730
+ dimensions: dict[str, DimensionBlock] = {}
731
+ relations: dict[str, RelationBlock] = {}
732
+ parameters: dict[str, ParameterBlock] = {}
733
+ variables: dict[str, VariableBlock] = {}
734
+ constraints: dict[str, ConstraintBlock] = {}
735
+ objective: ObjectiveBlock | None = None
736
+ expressions: dict[str, ExpressionBlock] = {}
737
+ macros: dict[str, MacroBlock] = {}
738
+ piecewise: dict[str, PiecewiseBlock] = {}
739
+ sos: dict[str, SosBlock] = {}
740
+ assumptions: dict[str, AssumptionBlock] = {}
741
+
742
+ @cached_property
743
+ def program(self) -> Program:
744
+ """This model typed, section for section — what every reader after load walks.
745
+
746
+ Computing it *is* the expression pass, so a model the language refuses
747
+ raises here; loading forces it, so every ask on a model in hand is the
748
+ one object. It mirrors the model: a ``piecewise:`` block still in it is
749
+ a curve under ``program.piecewise`` and a ``sos:`` block a set under
750
+ ``program.sos``, and :meth:`expand` is what writes either out as rows,
751
+ so a consumer building rows reads ``spec.expand(...).program`` and
752
+ refuses a block it does not take.
753
+ """
754
+ from mathspec.lowering import lower
755
+
756
+ return lower(self)
757
+
758
+ @classmethod
759
+ @override
760
+ def model_validate(
761
+ cls,
762
+ obj: object,
763
+ *,
764
+ strict: bool | None = None,
765
+ extra: ExtraValues | None = None,
766
+ from_attributes: bool | None = None,
767
+ context: object = None,
768
+ by_alias: bool | None = None,
769
+ by_name: bool | None = None,
770
+ ) -> Self:
771
+ """Validate a mapping, raising this package's exception tree rather than pydantic's.
772
+
773
+ ``__init__`` is not wrapped the same way, because defining one makes
774
+ pydantic run every after-validator twice.
775
+ """
776
+ try:
777
+ return super().model_validate(
778
+ obj,
779
+ strict=strict,
780
+ extra=extra,
781
+ from_attributes=from_attributes,
782
+ context=context,
783
+ by_alias=by_alias,
784
+ by_name=by_name,
785
+ )
786
+ except ValidationError as exc:
787
+ raise schema_error(exc) from None
788
+
789
+ @field_validator('version')
790
+ @classmethod
791
+ def _check_version(cls, v: int) -> int:
792
+ """Refuse a surface this reader does not know — never interpret it."""
793
+ if v in SUPPORTED_VERSIONS:
794
+ return v
795
+ from mathspec import __version__ as installed
796
+
797
+ supported = ', '.join(str(s) for s in SUPPORTED_VERSIONS)
798
+ msg = (
799
+ f'model declares version {v}, and mathspec {installed} understands [{supported}]. '
800
+ f'Upgrade mathspec, or write the version this file actually targets.'
801
+ )
802
+ raise ValueError(msg)
803
+
804
+ @model_serializer(mode='wrap')
805
+ def _drop_absence(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]:
806
+ """Absence is not serialised: a null, a mapping that stripping emptied, a section declaring nothing.
807
+
808
+ An empty list stays, being a value rather than an absence (``dims:
809
+ []`` is a scalar). On the serializer so that ``model_dump``,
810
+ :meth:`to_dict` and :meth:`to_yaml` agree.
811
+ """
812
+ return cast('dict[str, object]', _without_absence(handler(self)))
813
+
814
+ def to_dict(self) -> dict[str, object]:
815
+ """The model as plain data. ``to_spec(m.to_dict())`` reproduces it."""
816
+ return self.model_dump()
817
+
818
+ def to_yaml(self) -> str:
819
+ """The file a reviewer reads — including for a model that never had one."""
820
+ import yaml
821
+
822
+ return yaml.safe_dump(self.to_dict(), sort_keys=False, allow_unicode=True)
823
+
824
+ def expand(self, *kinds: Formulation) -> Spec:
825
+ """This model with its formulations written out as plain variables and constraints.
826
+
827
+ A formulation states rows rather than being one — ``piecewise:`` states
828
+ a curve, ``sos:`` states which members of a family may be nonzero.
829
+ Expanding one writes those rows under names prefixed with the block's
830
+ own, and drops the block. The result is a different model: it declares
831
+ more variables and constraints, so it does not compare equal to this
832
+ one. It declares the same dimensions and parameters, so the same data
833
+ attaches to both. Nothing is cached, so a second call builds the
834
+ expansion again.
835
+
836
+ Args:
837
+ kinds: Which formulations to write out — ``'piecewise'``,
838
+ ``'sos'``, or none of them for every one. They go in
839
+ :data:`FORMULATIONS` order whatever order they are asked in,
840
+ because a ``method: sos2`` curve emits a set and no set emits a
841
+ curve.
842
+
843
+ Returns:
844
+ The model with those blocks written out, or this same object where
845
+ it declares none of them, so an expansion asked for the same kinds
846
+ again returns itself. It is a model like any other: :meth:`to_yaml`
847
+ writes it, and :attr:`program` holds its rows.
848
+
849
+ Raises:
850
+ ValueError: *kinds* names something that is not a formulation.
851
+ """
852
+ wanted = _formulations(kinds)
853
+ from mathspec.piecewise import expand_piecewise
854
+ from mathspec.sos import expand_sets
855
+
856
+ expanded = expand_piecewise(self) if 'piecewise' in wanted else self
857
+ if 'sos' in wanted and expanded.sos:
858
+ expanded = expand_sets(expanded)
859
+ return expanded
860
+
861
+ @model_validator(mode='after')
862
+ def _names_are_names(self) -> Spec:
863
+ """Every declaration is keyed by something an expression could write.
864
+
865
+ Read off the model's own mappings rather than a list of sections, so a
866
+ section added later cannot be forgotten here — every mapping a Spec
867
+ carries is keyed by a declaration name.
868
+ """
869
+ errors = [
870
+ f'{section}: {name!r} is not a name. A declaration is named the way an expression '
871
+ f'writes it — a letter or an underscore, then letters, digits or underscores — so '
872
+ f'nothing can refer to this one. Rename it.'
873
+ for section, value in self
874
+ if isinstance(value, dict)
875
+ for name in value
876
+ if not re.fullmatch(NAME, name)
877
+ ]
878
+ if errors:
879
+ raise ValueError('\n'.join(errors))
880
+
881
+ return self
882
+
883
+ @model_validator(mode='after')
884
+ def _lower(self) -> Spec:
885
+ """Every rule that reads across declarations, then every expression and where string.
886
+
887
+ A fault in a curve's link is named against the link the file wrote. The
888
+ rows a curve states are held to the language when :meth:`expand`
889
+ writes them out, since an expansion is a model like any other.
890
+ """
891
+ _ = self.program
892
+ return self
893
+
894
+
895
+ def _formulations(asked: tuple[str, ...]) -> tuple[Formulation, ...]:
896
+ """What *asked* names, in :data:`FORMULATIONS` order — all of them where it names none.
897
+
898
+ Raises:
899
+ ValueError: A name that is not a formulation.
900
+ """
901
+ if unknown := [kind for kind in asked if kind not in FORMULATIONS]:
902
+ spelled = ' and '.join(repr(kind) for kind in FORMULATIONS)
903
+ msg = f'{unknown[0]!r} is not a formulation. Expand {spelled}, or pass none of them for every one.'
904
+ raise ValueError(msg)
905
+ return tuple(kind for kind in FORMULATIONS if not asked or kind in asked)