lp2graph 0.3.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.
- lp2graph/__init__.py +54 -0
- lp2graph/cli.py +238 -0
- lp2graph/codec/__init__.py +41 -0
- lp2graph/codec/latex.py +884 -0
- lp2graph/codec/normalize.py +82 -0
- lp2graph/core/__init__.py +35 -0
- lp2graph/core/graph.py +183 -0
- lp2graph/core/loader.py +63 -0
- lp2graph/core/model.py +437 -0
- lp2graph/core/validate.py +237 -0
- lp2graph/export/__init__.py +13 -0
- lp2graph/export/dgl.py +51 -0
- lp2graph/export/latex.py +126 -0
- lp2graph/export/networkx_adapter.py +50 -0
- lp2graph/export/pyg.py +79 -0
- lp2graph/export/pyomo_stub.py +81 -0
- lp2graph/metrics/__init__.py +58 -0
- lp2graph/metrics/classification.py +113 -0
- lp2graph/metrics/flags.py +122 -0
- lp2graph/metrics/result.py +26 -0
- lp2graph/metrics/structural.py +236 -0
- lp2graph/mining/__init__.py +47 -0
- lp2graph/mining/cluster/__init__.py +65 -0
- lp2graph/mining/cluster/agglomerative.py +82 -0
- lp2graph/mining/cluster/distance.py +65 -0
- lp2graph/mining/cluster/operator.py +218 -0
- lp2graph/mining/cluster/silhouette.py +88 -0
- lp2graph/mining/cluster/stability.py +178 -0
- lp2graph/mining/cluster/taxonomy.py +268 -0
- lp2graph/mining/corpusmgr/__init__.py +70 -0
- lp2graph/mining/corpusmgr/dedup.py +183 -0
- lp2graph/mining/corpusmgr/manager.py +79 -0
- lp2graph/mining/corpusmgr/manifest.py +82 -0
- lp2graph/mining/corpusmgr/record.py +101 -0
- lp2graph/mining/corpusmgr/select.py +128 -0
- lp2graph/mining/homologize/__init__.py +82 -0
- lp2graph/mining/homologize/concept.py +134 -0
- lp2graph/mining/homologize/entity.py +217 -0
- lp2graph/mining/homologize/lemmatize.py +80 -0
- lp2graph/mining/homologize/signature.py +166 -0
- lp2graph/mining/homologize/thesaurus.py +70 -0
- lp2graph/mining/homologize/tokenize.py +255 -0
- lp2graph/mining/homologize/vectorize.py +141 -0
- lp2graph/mining/ingest/__init__.py +59 -0
- lp2graph/mining/ingest/code_importers.py +104 -0
- lp2graph/mining/ingest/dispatch.py +148 -0
- lp2graph/mining/ingest/latex_normalizer.py +243 -0
- lp2graph/mining/ingest/pyomo_importer.py +297 -0
- lp2graph/mining/ingest/result.py +124 -0
- lp2graph/mining/isomorphism/__init__.py +26 -0
- lp2graph/mining/isomorphism/report.py +178 -0
- lp2graph/mining/label/__init__.py +70 -0
- lp2graph/mining/label/classifier.py +161 -0
- lp2graph/mining/label/features.py +35 -0
- lp2graph/mining/label/guardrails.py +176 -0
- lp2graph/mining/label/loop.py +314 -0
- lp2graph/mining/label/rules.py +92 -0
- lp2graph/mining/label/store.py +164 -0
- lp2graph/mining/label/vocab.py +64 -0
- lp2graph/mining/provenance.py +90 -0
- lp2graph/mining/versions.py +51 -0
- lp2graph/nl/__init__.py +15 -0
- lp2graph/nl/describe.py +301 -0
- lp2graph/render/__init__.py +11 -0
- lp2graph/render/palette.py +80 -0
- lp2graph/render/svg.py +220 -0
- lp2graph/solve/__init__.py +50 -0
- lp2graph/solve/grounder.py +405 -0
- lp2graph/solve/instance.py +76 -0
- lp2graph/transform/__init__.py +30 -0
- lp2graph/transform/bigm.py +173 -0
- lp2graph/views/__init__.py +17 -0
- lp2graph/views/ground.py +477 -0
- lp2graph/views/hybrid.py +202 -0
- lp2graph/views/schema.py +208 -0
- lp2graph-0.3.0.dist-info/METADATA +206 -0
- lp2graph-0.3.0.dist-info/RECORD +80 -0
- lp2graph-0.3.0.dist-info/WHEEL +4 -0
- lp2graph-0.3.0.dist-info/entry_points.txt +2 -0
- lp2graph-0.3.0.dist-info/licenses/LICENSE +205 -0
lp2graph/core/model.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Canonical pydantic models for LP/MIP/MILP formulations.
|
|
2
|
+
|
|
3
|
+
Mirrors ``schema/canonical.schema.json`` one-to-one. Every public class
|
|
4
|
+
here corresponds to a ``$defs`` entry in the schema; field names are
|
|
5
|
+
identical. The canonical model is the *single source of truth* — every
|
|
6
|
+
view, metric, render, and export consumes a ``Formulation`` instance.
|
|
7
|
+
|
|
8
|
+
The model is deliberately conservative about what it stores: only what
|
|
9
|
+
cannot be derived. Anything that is a function of the canonical fields
|
|
10
|
+
(grounding, metrics, renders) is computed on demand.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Annotated, Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Primitives
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
Identifier = Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")]
|
|
24
|
+
"""A Python-like identifier. Used for index, parameter, variable, and
|
|
25
|
+
constraint names."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Frozen(BaseModel):
|
|
29
|
+
"""Base model — strict, frozen, forbid-extra. Determinism by default."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(
|
|
32
|
+
frozen=True,
|
|
33
|
+
extra="forbid",
|
|
34
|
+
populate_by_name=True,
|
|
35
|
+
validate_assignment=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Index families and parameters
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Index(_Frozen):
|
|
45
|
+
"""An index family (set) over which templates may range.
|
|
46
|
+
|
|
47
|
+
Examples: ``I`` (trains), ``T`` (time slots). An *ordered* index has
|
|
48
|
+
a natural sequence so that offsets like ``t-1`` are meaningful. A
|
|
49
|
+
*cyclic* index wraps modulo cardinality (PESP-style).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name: Identifier
|
|
53
|
+
description: str = ""
|
|
54
|
+
ordered: bool = False
|
|
55
|
+
cyclic: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Domain facet (additive): semantic class of a parameter, independent of 'kind'.
|
|
59
|
+
ParameterDomainClass = Literal[
|
|
60
|
+
"cost_weight",
|
|
61
|
+
"time_duration",
|
|
62
|
+
"capacity",
|
|
63
|
+
"demand",
|
|
64
|
+
"network_structure",
|
|
65
|
+
"penalty_bigM",
|
|
66
|
+
"count_limit",
|
|
67
|
+
"unclassified",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Parameter(_Frozen):
|
|
72
|
+
"""A constant input to the formulation.
|
|
73
|
+
|
|
74
|
+
Parameters carry a *shape* — the index families they are indexed by —
|
|
75
|
+
and a *kind* tag that classifies them (e.g. ``big_m`` for tightening
|
|
76
|
+
constants, ``tolerance`` for slacks). Concrete numeric values are
|
|
77
|
+
*not* stored; they are supplied at grounding time when needed.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
name: Identifier
|
|
81
|
+
description: str = ""
|
|
82
|
+
shape: tuple[Identifier, ...] = ()
|
|
83
|
+
kind: Literal["scalar", "vector", "matrix", "big_m", "tolerance"] = "scalar"
|
|
84
|
+
domain_class: ParameterDomainClass | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Variable templates
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
VariableDomain = Literal["continuous", "non_negative", "integer", "binary"]
|
|
93
|
+
VariableRole = Literal["primary", "auxiliary", "slack", "indicator"]
|
|
94
|
+
# Domain facet (additive): semantic role, independent of the structural 'role'.
|
|
95
|
+
VariableDomainRole = Literal[
|
|
96
|
+
"selection_assignment",
|
|
97
|
+
"ordering_precedence",
|
|
98
|
+
"routing_path_column",
|
|
99
|
+
"timing",
|
|
100
|
+
"flow_quantity",
|
|
101
|
+
"auxiliary_linearization",
|
|
102
|
+
"unclassified",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class VariableTemplate(_Frozen):
|
|
107
|
+
"""A template for a family of decision variables.
|
|
108
|
+
|
|
109
|
+
A template ``x[I, T]`` with ``domain="binary"`` represents the
|
|
110
|
+
``|I| * |T|`` binary variables ``x_{i,t}`` that appear in the
|
|
111
|
+
grounded model. The schema view exposes the template; the ground
|
|
112
|
+
view exposes the instances.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
name: Identifier
|
|
116
|
+
description: str = ""
|
|
117
|
+
shape: tuple[Identifier, ...] = ()
|
|
118
|
+
domain: VariableDomain
|
|
119
|
+
lower: float | None = None
|
|
120
|
+
upper: float | None = None
|
|
121
|
+
role: VariableRole = "primary"
|
|
122
|
+
domain_role: VariableDomainRole | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Quantifiers, bindings, terms
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
QuantifierRestriction = Literal[
|
|
131
|
+
"none", "ne_other", "lt_other", "le_other", "gt_other", "ge_other", "ordered_pair"
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class QuantifierWhere(_Frozen):
|
|
136
|
+
"""Attribute-based selection predicate on a quantifier.
|
|
137
|
+
|
|
138
|
+
Restricts the tuples admitted by the quantifier to those for which a
|
|
139
|
+
parameter (shaped over the quantifier's ``over`` index family)
|
|
140
|
+
equals a given value. For example, with a binary parameter
|
|
141
|
+
``is_local`` of shape ``[T]``, ``QuantifierWhere(parameter="is_local",
|
|
142
|
+
equals=True)`` keeps only the values of the index where
|
|
143
|
+
``is_local`` is true. Schema and hybrid views surface this as a
|
|
144
|
+
label on the quantifier; the ground view applies it as a filter at
|
|
145
|
+
materialization time.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
parameter: Identifier
|
|
149
|
+
equals: bool | int | float | str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Quantifier(_Frozen):
|
|
153
|
+
"""A constraint-template quantifier: ``index ∈ over``.
|
|
154
|
+
|
|
155
|
+
Optional ``restriction`` references another quantifier (via
|
|
156
|
+
``restriction_other``). For example, ``j ∈ I, j != i`` is encoded as
|
|
157
|
+
``Quantifier(index="j", over="I", restriction="ne_other",
|
|
158
|
+
restriction_other="i")``.
|
|
159
|
+
|
|
160
|
+
Optional ``where`` adds an attribute-based selection predicate
|
|
161
|
+
(e.g. ``is_local[t] == True``).
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
index: Identifier
|
|
165
|
+
over: Identifier
|
|
166
|
+
restriction: QuantifierRestriction = "none"
|
|
167
|
+
restriction_other: Identifier | None = None
|
|
168
|
+
where: QuantifierWhere | None = None
|
|
169
|
+
|
|
170
|
+
@model_validator(mode="after")
|
|
171
|
+
def _check_restriction_pair(self) -> Quantifier:
|
|
172
|
+
if self.restriction != "none" and self.restriction_other is None:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"quantifier {self.index!r} has restriction {self.restriction!r} "
|
|
175
|
+
"but no restriction_other"
|
|
176
|
+
)
|
|
177
|
+
if self.restriction == "none" and self.restriction_other is not None:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"quantifier {self.index!r} sets restriction_other={self.restriction_other!r} "
|
|
180
|
+
"but restriction is 'none'"
|
|
181
|
+
)
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Binding(_Frozen):
|
|
186
|
+
"""The binding of one index slot of a referenced template.
|
|
187
|
+
|
|
188
|
+
For a template ``x[I, T]`` referenced inside a constraint quantified
|
|
189
|
+
over ``i ∈ I, t ∈ T``, the binding ``index="T", expr="t-1",
|
|
190
|
+
offset=-1`` resolves to ``x_{i, t-1}``. The ``modulo`` field, when
|
|
191
|
+
set to an index name, indicates that the offset should wrap modulo
|
|
192
|
+
that index's cardinality (PESP).
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
index: Identifier
|
|
196
|
+
expr: str
|
|
197
|
+
offset: int = 0
|
|
198
|
+
modulo: Identifier | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
TermRefKind = Literal["variable", "parameter", "literal"]
|
|
202
|
+
TermRole = Literal["lhs", "rhs", "objective", "slack", "aux"]
|
|
203
|
+
TermOperator = Literal["none", "sum", "max", "min", "abs", "indicator", "modulo"]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class Term(_Frozen):
|
|
207
|
+
"""A single term in a constraint LHS, constraint RHS, or objective.
|
|
208
|
+
|
|
209
|
+
The four-tuple ``(ref, bindings, role, sign)`` is what makes the
|
|
210
|
+
schema/hybrid/ground views derivable from a single source of truth.
|
|
211
|
+
|
|
212
|
+
- ``ref`` names the referenced entity (variable, parameter, or
|
|
213
|
+
literal).
|
|
214
|
+
- ``bindings`` resolves each index slot of the referenced template
|
|
215
|
+
to an expression in the enclosing quantifier scope.
|
|
216
|
+
- ``coefficient`` is a parameter name or a numeric literal.
|
|
217
|
+
- ``sign`` is multiplied with the coefficient at evaluation time but
|
|
218
|
+
kept separate so renderers can show it explicitly.
|
|
219
|
+
- ``role`` drives edge coloring in rendered graphs.
|
|
220
|
+
- ``operator`` and ``operator_over`` capture aggregations like
|
|
221
|
+
``sum_{t in T}`` so they are visible in the schema view.
|
|
222
|
+
|
|
223
|
+
For constant terms, prefer the ``constant`` shorthand
|
|
224
|
+
(``{"constant": 3599, "role": "rhs"}``); it is normalized at parse
|
|
225
|
+
time to a literal term whose ``coefficient`` carries the value.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
ref: Identifier = "_const"
|
|
229
|
+
ref_kind: TermRefKind = "variable"
|
|
230
|
+
bindings: tuple[Binding, ...] = ()
|
|
231
|
+
coefficient: float | str | None = 1
|
|
232
|
+
sign: Literal[1, -1] = 1
|
|
233
|
+
role: TermRole
|
|
234
|
+
operator: TermOperator = "none"
|
|
235
|
+
operator_over: tuple[Identifier, ...] = ()
|
|
236
|
+
|
|
237
|
+
@model_validator(mode="before")
|
|
238
|
+
@classmethod
|
|
239
|
+
def _normalize_constant(cls, data: object) -> object:
|
|
240
|
+
if not isinstance(data, dict):
|
|
241
|
+
return data
|
|
242
|
+
if "constant" not in data:
|
|
243
|
+
return data
|
|
244
|
+
if data.get("ref") is not None or data.get("ref_kind") not in (None, "literal"):
|
|
245
|
+
raise ValueError(
|
|
246
|
+
"term: 'constant' is mutually exclusive with 'ref'/'ref_kind' "
|
|
247
|
+
"(other than ref_kind='literal')"
|
|
248
|
+
)
|
|
249
|
+
if "coefficient" in data and data["coefficient"] not in (None, 1):
|
|
250
|
+
raise ValueError(
|
|
251
|
+
"term: 'constant' is mutually exclusive with 'coefficient'; "
|
|
252
|
+
"the constant's value is its own coefficient"
|
|
253
|
+
)
|
|
254
|
+
if data.get("bindings"):
|
|
255
|
+
raise ValueError("term: constant term must not carry bindings")
|
|
256
|
+
out = {k: v for k, v in data.items() if k != "constant"}
|
|
257
|
+
out["ref"] = "_const"
|
|
258
|
+
out["ref_kind"] = "literal"
|
|
259
|
+
out["coefficient"] = data["constant"]
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Constraint and objective
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
Comparator = Literal["le", "ge", "eq"]
|
|
269
|
+
ConstraintKind = Literal[
|
|
270
|
+
"linear",
|
|
271
|
+
"big_m",
|
|
272
|
+
"indicator",
|
|
273
|
+
"ordering",
|
|
274
|
+
"headway",
|
|
275
|
+
"capacity",
|
|
276
|
+
"flow_balance",
|
|
277
|
+
"modulo",
|
|
278
|
+
"soft",
|
|
279
|
+
"robust",
|
|
280
|
+
"set_packing",
|
|
281
|
+
"block_occupation",
|
|
282
|
+
"moving_block",
|
|
283
|
+
"dwell",
|
|
284
|
+
]
|
|
285
|
+
# Domain facet (additive): semantic function, independent of structural 'kind'.
|
|
286
|
+
ConstraintDomainClass = Literal[
|
|
287
|
+
"assignment_covering",
|
|
288
|
+
"flow_conservation",
|
|
289
|
+
"capacity_resource",
|
|
290
|
+
"precedence_ordering",
|
|
291
|
+
"headway_separation",
|
|
292
|
+
"timing_window",
|
|
293
|
+
"coupling_linking_definition",
|
|
294
|
+
"periodic_modulo_pesp",
|
|
295
|
+
"subtour_connectivity",
|
|
296
|
+
"variable_bound_fix",
|
|
297
|
+
"objective_defining",
|
|
298
|
+
"unclassified",
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class IndicatorTrigger(_Frozen):
|
|
303
|
+
"""Logical gate for an indicator constraint: the body is enforced only
|
|
304
|
+
when ``binary`` equals ``active_value``. A back-end deterministically
|
|
305
|
+
emits either a native indicator constraint or a tightened big-M
|
|
306
|
+
linearization (see :mod:`lp2graph.transform.bigm`)."""
|
|
307
|
+
|
|
308
|
+
binary: Identifier
|
|
309
|
+
active_value: Literal[0, 1] = 1
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class ConstraintTemplate(_Frozen):
|
|
313
|
+
"""A quantified constraint template.
|
|
314
|
+
|
|
315
|
+
The template's body is a comparison ``Σ lhs ⋄ Σ rhs`` where ``⋄`` is
|
|
316
|
+
one of ``≤``, ``≥``, ``=``. Each side is a list of :class:`Term`
|
|
317
|
+
instances; the side they appear in determines their default role,
|
|
318
|
+
but a term may carry its own role override (e.g. a slack term
|
|
319
|
+
appearing in the LHS of a soft constraint).
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
name: Identifier
|
|
323
|
+
description: str = ""
|
|
324
|
+
quantifiers: tuple[Quantifier, ...] = ()
|
|
325
|
+
comparator: Comparator
|
|
326
|
+
lhs: tuple[Term, ...]
|
|
327
|
+
rhs: tuple[Term, ...]
|
|
328
|
+
kind: ConstraintKind = "linear"
|
|
329
|
+
domain_class: ConstraintDomainClass | None = None
|
|
330
|
+
indicator: IndicatorTrigger | None = None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
ObjectiveSense = Literal["min", "max"]
|
|
334
|
+
ObjectiveCombination = Literal["sum", "lexicographic", "weighted_sum"]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class Objective(_Frozen):
|
|
338
|
+
"""The objective function as a first-class section.
|
|
339
|
+
|
|
340
|
+
Mirrors the structure of constraints but without quantifiers (the
|
|
341
|
+
objective itself is scalar; its terms may carry ``sum`` operators
|
|
342
|
+
over index families to express ``min Σ_i c_i x_i``).
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
sense: ObjectiveSense
|
|
346
|
+
name: str = "objective"
|
|
347
|
+
description: str = ""
|
|
348
|
+
terms: tuple[Term, ...]
|
|
349
|
+
combination: ObjectiveCombination = "sum"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# Provenance
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class Provenance(_Frozen):
|
|
358
|
+
"""Optional metadata about where the formulation came from."""
|
|
359
|
+
|
|
360
|
+
source: str = ""
|
|
361
|
+
reference: str = ""
|
|
362
|
+
author: str = ""
|
|
363
|
+
date: str = ""
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# Formulation
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
Family = Literal["lp", "mip", "milp"]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class Formulation(_Frozen):
|
|
375
|
+
"""The canonical container.
|
|
376
|
+
|
|
377
|
+
A ``Formulation`` is the input to every view, metric, render, and
|
|
378
|
+
export in the library.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
382
|
+
id: Annotated[str, Field(pattern=r"^[a-z0-9][a-z0-9_.-]*$")]
|
|
383
|
+
name: str
|
|
384
|
+
family: Family
|
|
385
|
+
description: str = ""
|
|
386
|
+
tags: tuple[str, ...] = ()
|
|
387
|
+
provenance: Provenance | None = None
|
|
388
|
+
indices: tuple[Index, ...] = ()
|
|
389
|
+
parameters: tuple[Parameter, ...] = ()
|
|
390
|
+
variables: tuple[VariableTemplate, ...]
|
|
391
|
+
constraints: tuple[ConstraintTemplate, ...] = ()
|
|
392
|
+
objective: Objective | None = None
|
|
393
|
+
|
|
394
|
+
# Convenience indexes for fast lookups -------------------------------
|
|
395
|
+
|
|
396
|
+
def index_map(self) -> dict[str, Index]:
|
|
397
|
+
return {i.name: i for i in self.indices}
|
|
398
|
+
|
|
399
|
+
def parameter_map(self) -> dict[str, Parameter]:
|
|
400
|
+
return {p.name: p for p in self.parameters}
|
|
401
|
+
|
|
402
|
+
def variable_map(self) -> dict[str, VariableTemplate]:
|
|
403
|
+
return {v.name: v for v in self.variables}
|
|
404
|
+
|
|
405
|
+
def constraint_map(self) -> dict[str, ConstraintTemplate]:
|
|
406
|
+
return {c.name: c for c in self.constraints}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
__all__ = [
|
|
410
|
+
"Binding",
|
|
411
|
+
"Comparator",
|
|
412
|
+
"ConstraintKind",
|
|
413
|
+
"ConstraintTemplate",
|
|
414
|
+
"Family",
|
|
415
|
+
"Formulation",
|
|
416
|
+
"Identifier",
|
|
417
|
+
"Index",
|
|
418
|
+
"Objective",
|
|
419
|
+
"ObjectiveCombination",
|
|
420
|
+
"ObjectiveSense",
|
|
421
|
+
"Parameter",
|
|
422
|
+
"Provenance",
|
|
423
|
+
"Quantifier",
|
|
424
|
+
"QuantifierRestriction",
|
|
425
|
+
"QuantifierWhere",
|
|
426
|
+
"Term",
|
|
427
|
+
"TermOperator",
|
|
428
|
+
"TermRefKind",
|
|
429
|
+
"TermRole",
|
|
430
|
+
"VariableDomain",
|
|
431
|
+
"VariableRole",
|
|
432
|
+
"VariableTemplate",
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# Union type for term references (variable templates or parameters).
|
|
437
|
+
TermReferent = VariableTemplate | Parameter
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Cross-cutting invariants beyond what pydantic and JSON Schema enforce.
|
|
2
|
+
|
|
3
|
+
Validation runs in two phases:
|
|
4
|
+
|
|
5
|
+
1. **Schema validation** (handled by :mod:`lp2graph.core.loader` via
|
|
6
|
+
pydantic). Catches malformed JSON, wrong types, missing fields,
|
|
7
|
+
invalid enum values.
|
|
8
|
+
2. **Semantic validation** (this module). Catches references to
|
|
9
|
+
undefined entities, bindings that do not match a referenced
|
|
10
|
+
template's shape, quantifiers that reference an unknown index, etc.
|
|
11
|
+
|
|
12
|
+
Semantic errors are aggregated and raised as a single
|
|
13
|
+
:class:`ValidationError` carrying a list of human-readable messages.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from lp2graph.core.model import (
|
|
22
|
+
ConstraintTemplate,
|
|
23
|
+
Formulation,
|
|
24
|
+
Objective,
|
|
25
|
+
Term,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ValidationError(Exception):
|
|
30
|
+
"""Aggregated semantic validation error.
|
|
31
|
+
|
|
32
|
+
The ``errors`` attribute lists every issue found in a single pass,
|
|
33
|
+
so callers see all problems instead of fixing them one at a time.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, message: str, errors: list[str] | None = None) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.errors: list[str] = errors or []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate(f: Formulation) -> None:
|
|
42
|
+
"""Validate cross-cutting invariants of a formulation.
|
|
43
|
+
|
|
44
|
+
Raises :class:`ValidationError` if any invariant is violated.
|
|
45
|
+
"""
|
|
46
|
+
errors: list[str] = []
|
|
47
|
+
|
|
48
|
+
index_names = {i.name for i in f.indices}
|
|
49
|
+
parameter_names = {p.name for p in f.parameters}
|
|
50
|
+
variable_names = {v.name for v in f.variables}
|
|
51
|
+
|
|
52
|
+
# 1. Variable shapes reference declared indices.
|
|
53
|
+
for v in f.variables:
|
|
54
|
+
for s in v.shape:
|
|
55
|
+
if s not in index_names:
|
|
56
|
+
errors.append(f"variable {v.name!r}: shape index {s!r} is not declared")
|
|
57
|
+
|
|
58
|
+
# 2. Parameter shapes reference declared indices.
|
|
59
|
+
for p in f.parameters:
|
|
60
|
+
for s in p.shape:
|
|
61
|
+
if s not in index_names:
|
|
62
|
+
errors.append(f"parameter {p.name!r}: shape index {s!r} is not declared")
|
|
63
|
+
|
|
64
|
+
# 3. Constraints: quantifiers, references, bindings.
|
|
65
|
+
for c in f.constraints:
|
|
66
|
+
_validate_constraint(
|
|
67
|
+
c,
|
|
68
|
+
index_names=index_names,
|
|
69
|
+
parameter_names=parameter_names,
|
|
70
|
+
variable_names=variable_names,
|
|
71
|
+
variable_shapes={v.name: v.shape for v in f.variables},
|
|
72
|
+
parameter_shapes={p.name: p.shape for p in f.parameters},
|
|
73
|
+
errors=errors,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# 4. Objective.
|
|
77
|
+
if f.objective is not None:
|
|
78
|
+
_validate_objective(
|
|
79
|
+
f.objective,
|
|
80
|
+
index_names=index_names,
|
|
81
|
+
parameter_names=parameter_names,
|
|
82
|
+
variable_names=variable_names,
|
|
83
|
+
variable_shapes={v.name: v.shape for v in f.variables},
|
|
84
|
+
parameter_shapes={p.name: p.shape for p in f.parameters},
|
|
85
|
+
errors=errors,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 5. Family consistency: lp must not have integer/binary variables.
|
|
89
|
+
if f.family == "lp":
|
|
90
|
+
for v in f.variables:
|
|
91
|
+
if v.domain in ("integer", "binary"):
|
|
92
|
+
errors.append(f"family is 'lp' but variable {v.name!r} has domain {v.domain!r}")
|
|
93
|
+
if f.family == "mip":
|
|
94
|
+
# mip permits both integer and continuous; nothing to enforce.
|
|
95
|
+
pass
|
|
96
|
+
if f.family == "milp" and not any(v.domain in ("integer", "binary") for v in f.variables):
|
|
97
|
+
errors.append("family is 'milp' but no variable has integer or binary domain")
|
|
98
|
+
|
|
99
|
+
if errors:
|
|
100
|
+
raise ValidationError(
|
|
101
|
+
f"{len(errors)} validation error(s) in formulation {f.id!r}",
|
|
102
|
+
errors=errors,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_constraint(
|
|
107
|
+
c: ConstraintTemplate,
|
|
108
|
+
*,
|
|
109
|
+
index_names: set[str],
|
|
110
|
+
parameter_names: set[str],
|
|
111
|
+
variable_names: set[str],
|
|
112
|
+
variable_shapes: dict[str, tuple[str, ...]],
|
|
113
|
+
parameter_shapes: dict[str, tuple[str, ...]],
|
|
114
|
+
errors: list[str],
|
|
115
|
+
) -> None:
|
|
116
|
+
quantifier_indices = {q.index for q in c.quantifiers}
|
|
117
|
+
|
|
118
|
+
# Quantifier 'over' is a declared index family.
|
|
119
|
+
for q in c.quantifiers:
|
|
120
|
+
if q.over not in index_names:
|
|
121
|
+
errors.append(
|
|
122
|
+
f"constraint {c.name!r}: quantifier {q.index!r} ranges over "
|
|
123
|
+
f"undeclared index {q.over!r}"
|
|
124
|
+
)
|
|
125
|
+
if q.restriction != "none":
|
|
126
|
+
assert q.restriction_other is not None # checked by pydantic
|
|
127
|
+
if q.restriction_other not in quantifier_indices:
|
|
128
|
+
errors.append(
|
|
129
|
+
f"constraint {c.name!r}: quantifier {q.index!r} restriction "
|
|
130
|
+
f"references unknown quantifier {q.restriction_other!r}"
|
|
131
|
+
)
|
|
132
|
+
if q.where is not None:
|
|
133
|
+
if q.where.parameter not in parameter_names:
|
|
134
|
+
errors.append(
|
|
135
|
+
f"constraint {c.name!r}: quantifier {q.index!r} where-clause "
|
|
136
|
+
f"references undeclared parameter {q.where.parameter!r}"
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
p_shape = parameter_shapes[q.where.parameter]
|
|
140
|
+
if p_shape != (q.over,):
|
|
141
|
+
errors.append(
|
|
142
|
+
f"constraint {c.name!r}: quantifier {q.index!r} where-clause "
|
|
143
|
+
f"parameter {q.where.parameter!r} has shape {list(p_shape)} "
|
|
144
|
+
f"but must be shaped [{q.over!r}]"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Each side's terms validate against the available scope.
|
|
148
|
+
scope = quantifier_indices | index_names # bindings may use any of these
|
|
149
|
+
for term in c.lhs:
|
|
150
|
+
_validate_term(
|
|
151
|
+
term,
|
|
152
|
+
where=f"constraint {c.name!r} lhs",
|
|
153
|
+
parameter_names=parameter_names,
|
|
154
|
+
variable_names=variable_names,
|
|
155
|
+
variable_shapes=variable_shapes,
|
|
156
|
+
parameter_shapes=parameter_shapes,
|
|
157
|
+
quantifier_scope=scope,
|
|
158
|
+
errors=errors,
|
|
159
|
+
)
|
|
160
|
+
for term in c.rhs:
|
|
161
|
+
_validate_term(
|
|
162
|
+
term,
|
|
163
|
+
where=f"constraint {c.name!r} rhs",
|
|
164
|
+
parameter_names=parameter_names,
|
|
165
|
+
variable_names=variable_names,
|
|
166
|
+
variable_shapes=variable_shapes,
|
|
167
|
+
parameter_shapes=parameter_shapes,
|
|
168
|
+
quantifier_scope=scope,
|
|
169
|
+
errors=errors,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _validate_objective(
|
|
174
|
+
o: Objective,
|
|
175
|
+
*,
|
|
176
|
+
index_names: set[str],
|
|
177
|
+
parameter_names: set[str],
|
|
178
|
+
variable_names: set[str],
|
|
179
|
+
variable_shapes: dict[str, tuple[str, ...]],
|
|
180
|
+
parameter_shapes: dict[str, tuple[str, ...]],
|
|
181
|
+
errors: list[str],
|
|
182
|
+
) -> None:
|
|
183
|
+
# Objective has no quantifiers; aggregations live in operator_over.
|
|
184
|
+
for term in o.terms:
|
|
185
|
+
scope = set(term.operator_over) | index_names
|
|
186
|
+
_validate_term(
|
|
187
|
+
term,
|
|
188
|
+
where=f"objective {o.name!r}",
|
|
189
|
+
parameter_names=parameter_names,
|
|
190
|
+
variable_names=variable_names,
|
|
191
|
+
variable_shapes=variable_shapes,
|
|
192
|
+
parameter_shapes=parameter_shapes,
|
|
193
|
+
quantifier_scope=scope,
|
|
194
|
+
errors=errors,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _validate_term(
|
|
199
|
+
term: Term,
|
|
200
|
+
*,
|
|
201
|
+
where: str,
|
|
202
|
+
parameter_names: set[str],
|
|
203
|
+
variable_names: set[str],
|
|
204
|
+
variable_shapes: dict[str, tuple[str, ...]],
|
|
205
|
+
parameter_shapes: dict[str, tuple[str, ...]],
|
|
206
|
+
quantifier_scope: set[str],
|
|
207
|
+
errors: list[str],
|
|
208
|
+
) -> None:
|
|
209
|
+
if term.ref_kind == "variable":
|
|
210
|
+
if term.ref not in variable_names:
|
|
211
|
+
errors.append(f"{where}: term references unknown variable {term.ref!r}")
|
|
212
|
+
return
|
|
213
|
+
shape = variable_shapes[term.ref]
|
|
214
|
+
elif term.ref_kind == "parameter":
|
|
215
|
+
if term.ref not in parameter_names:
|
|
216
|
+
errors.append(f"{where}: term references unknown parameter {term.ref!r}")
|
|
217
|
+
return
|
|
218
|
+
shape = parameter_shapes[term.ref]
|
|
219
|
+
else: # literal
|
|
220
|
+
if term.bindings:
|
|
221
|
+
errors.append(f"{where}: literal term must not carry bindings")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
# Bindings must cover exactly the referenced template's shape.
|
|
225
|
+
binding_indices = [b.index for b in term.bindings]
|
|
226
|
+
if set(binding_indices) != set(shape):
|
|
227
|
+
errors.append(
|
|
228
|
+
f"{where}: term ref {term.ref!r} has shape {shape!r} but bindings "
|
|
229
|
+
f"cover {sorted(binding_indices)!r}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Coefficient: if string, must be a parameter name.
|
|
233
|
+
if isinstance(term.coefficient, str) and term.coefficient not in parameter_names:
|
|
234
|
+
errors.append(f"{where}: term coefficient {term.coefficient!r} is not a declared parameter")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = ["ValidationError", "validate"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Export adapters: NetworkX, PyG, DGL, LaTeX, Pyomo stubs.
|
|
2
|
+
|
|
3
|
+
Heavy framework imports happen lazily inside each adapter to keep
|
|
4
|
+
``lp2graph`` importable without optional dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from lp2graph.export.dgl import to_dgl
|
|
8
|
+
from lp2graph.export.latex import to_latex
|
|
9
|
+
from lp2graph.export.networkx_adapter import to_networkx
|
|
10
|
+
from lp2graph.export.pyg import to_pyg
|
|
11
|
+
from lp2graph.export.pyomo_stub import to_pyomo_stub
|
|
12
|
+
|
|
13
|
+
__all__ = ["to_dgl", "to_latex", "to_networkx", "to_pyg", "to_pyomo_stub"]
|