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.
Files changed (80) hide show
  1. lp2graph/__init__.py +54 -0
  2. lp2graph/cli.py +238 -0
  3. lp2graph/codec/__init__.py +41 -0
  4. lp2graph/codec/latex.py +884 -0
  5. lp2graph/codec/normalize.py +82 -0
  6. lp2graph/core/__init__.py +35 -0
  7. lp2graph/core/graph.py +183 -0
  8. lp2graph/core/loader.py +63 -0
  9. lp2graph/core/model.py +437 -0
  10. lp2graph/core/validate.py +237 -0
  11. lp2graph/export/__init__.py +13 -0
  12. lp2graph/export/dgl.py +51 -0
  13. lp2graph/export/latex.py +126 -0
  14. lp2graph/export/networkx_adapter.py +50 -0
  15. lp2graph/export/pyg.py +79 -0
  16. lp2graph/export/pyomo_stub.py +81 -0
  17. lp2graph/metrics/__init__.py +58 -0
  18. lp2graph/metrics/classification.py +113 -0
  19. lp2graph/metrics/flags.py +122 -0
  20. lp2graph/metrics/result.py +26 -0
  21. lp2graph/metrics/structural.py +236 -0
  22. lp2graph/mining/__init__.py +47 -0
  23. lp2graph/mining/cluster/__init__.py +65 -0
  24. lp2graph/mining/cluster/agglomerative.py +82 -0
  25. lp2graph/mining/cluster/distance.py +65 -0
  26. lp2graph/mining/cluster/operator.py +218 -0
  27. lp2graph/mining/cluster/silhouette.py +88 -0
  28. lp2graph/mining/cluster/stability.py +178 -0
  29. lp2graph/mining/cluster/taxonomy.py +268 -0
  30. lp2graph/mining/corpusmgr/__init__.py +70 -0
  31. lp2graph/mining/corpusmgr/dedup.py +183 -0
  32. lp2graph/mining/corpusmgr/manager.py +79 -0
  33. lp2graph/mining/corpusmgr/manifest.py +82 -0
  34. lp2graph/mining/corpusmgr/record.py +101 -0
  35. lp2graph/mining/corpusmgr/select.py +128 -0
  36. lp2graph/mining/homologize/__init__.py +82 -0
  37. lp2graph/mining/homologize/concept.py +134 -0
  38. lp2graph/mining/homologize/entity.py +217 -0
  39. lp2graph/mining/homologize/lemmatize.py +80 -0
  40. lp2graph/mining/homologize/signature.py +166 -0
  41. lp2graph/mining/homologize/thesaurus.py +70 -0
  42. lp2graph/mining/homologize/tokenize.py +255 -0
  43. lp2graph/mining/homologize/vectorize.py +141 -0
  44. lp2graph/mining/ingest/__init__.py +59 -0
  45. lp2graph/mining/ingest/code_importers.py +104 -0
  46. lp2graph/mining/ingest/dispatch.py +148 -0
  47. lp2graph/mining/ingest/latex_normalizer.py +243 -0
  48. lp2graph/mining/ingest/pyomo_importer.py +297 -0
  49. lp2graph/mining/ingest/result.py +124 -0
  50. lp2graph/mining/isomorphism/__init__.py +26 -0
  51. lp2graph/mining/isomorphism/report.py +178 -0
  52. lp2graph/mining/label/__init__.py +70 -0
  53. lp2graph/mining/label/classifier.py +161 -0
  54. lp2graph/mining/label/features.py +35 -0
  55. lp2graph/mining/label/guardrails.py +176 -0
  56. lp2graph/mining/label/loop.py +314 -0
  57. lp2graph/mining/label/rules.py +92 -0
  58. lp2graph/mining/label/store.py +164 -0
  59. lp2graph/mining/label/vocab.py +64 -0
  60. lp2graph/mining/provenance.py +90 -0
  61. lp2graph/mining/versions.py +51 -0
  62. lp2graph/nl/__init__.py +15 -0
  63. lp2graph/nl/describe.py +301 -0
  64. lp2graph/render/__init__.py +11 -0
  65. lp2graph/render/palette.py +80 -0
  66. lp2graph/render/svg.py +220 -0
  67. lp2graph/solve/__init__.py +50 -0
  68. lp2graph/solve/grounder.py +405 -0
  69. lp2graph/solve/instance.py +76 -0
  70. lp2graph/transform/__init__.py +30 -0
  71. lp2graph/transform/bigm.py +173 -0
  72. lp2graph/views/__init__.py +17 -0
  73. lp2graph/views/ground.py +477 -0
  74. lp2graph/views/hybrid.py +202 -0
  75. lp2graph/views/schema.py +208 -0
  76. lp2graph-0.3.0.dist-info/METADATA +206 -0
  77. lp2graph-0.3.0.dist-info/RECORD +80 -0
  78. lp2graph-0.3.0.dist-info/WHEEL +4 -0
  79. lp2graph-0.3.0.dist-info/entry_points.txt +2 -0
  80. 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"]