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
@@ -0,0 +1,884 @@
1
+ r"""Reversible LaTeX codec for the canonical model.
2
+
3
+ The emitted document has two parts:
4
+
5
+ 1. A ``%@`` *annotation header* — LaTeX comments carrying the metadata
6
+ that has no algebraic surface form (ids, names, descriptions, index
7
+ ``ordered``/``cyclic`` flags, parameter kinds, variable domains/roles,
8
+ constraint kinds/domain-classes, objective combination). These lines
9
+ are invisible when the document is typeset.
10
+
11
+ 2. An ``align`` *body* — genuine paper-style LaTeX: ``\mathcal`` index
12
+ sets, ``\sum`` aggregations, ``\forall`` quantifiers, ``\le``/``\ge``,
13
+ big-M terms, ``\left| \cdot \right|`` for absolute values, and so on.
14
+ The algebra alone determines the solvable model.
15
+
16
+ The parser reconstructs the structured model from the body using the
17
+ *symbol table* declared in the header: because every variable and
18
+ parameter is declared with its index shape, a natural subscripted symbol
19
+ like ``t_{j}`` or ``x_{i,t}`` is unambiguously resolved to a referent of
20
+ the right kind with the right index-family bindings.
21
+
22
+ Grammar of a body term (one summand)::
23
+
24
+ term := ['-'|'+'] [aggreg] [coef '\cdot'] referent
25
+ aggreg := '\sum_{' binder (',' binder)* '}'
26
+ | '\left|' ... '\right|' (abs)
27
+ | '\max\left(' ... '\right)' | '\min\left(' ... '\right)'
28
+ | '\mathbb{1}\left[' ... '\right]' (indicator)
29
+ binder := ident '\in' '\mathcal{' SET '}'
30
+ coef := number | symbol
31
+ referent := symbol ['_{' expr (',' expr)* '}'] | number
32
+ symbol := letter | '\mathit{' name '}'
33
+ expr := ident ['-'|'+' integer] (index, optional offset)
34
+
35
+ See :mod:`lp2graph.codec` for the round-trip guarantees.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import re
41
+ from typing import Any
42
+
43
+ from lp2graph.core.model import (
44
+ Binding,
45
+ ConstraintTemplate,
46
+ Formulation,
47
+ Index,
48
+ Objective,
49
+ Parameter,
50
+ Quantifier,
51
+ QuantifierWhere,
52
+ Term,
53
+ VariableTemplate,
54
+ )
55
+
56
+ SCHEMA = "0.1.0"
57
+
58
+ _CMP_OUT = {"le": r"\le", "ge": r"\ge", "eq": "="}
59
+ _RESTR_OUT = {
60
+ "ne_other": r"\neq",
61
+ "lt_other": "<",
62
+ "le_other": r"\leq",
63
+ "gt_other": ">",
64
+ "ge_other": r"\geq",
65
+ "ordered_pair": r"\prec",
66
+ }
67
+ _RESTR_IN = {v: k for k, v in _RESTR_OUT.items()}
68
+
69
+ # ===========================================================================
70
+ # Emitter
71
+ # ===========================================================================
72
+
73
+
74
+ def to_canonical_latex(f: Formulation) -> str:
75
+ """Render ``f`` as a reversible, paper-style LaTeX document."""
76
+ lines: list[str] = []
77
+ a = lines.append
78
+
79
+ a("% lp2graph canonical LaTeX")
80
+ a(f"% Reversible with lp2graph.codec.from_canonical_latex (schema {SCHEMA}).")
81
+ a(f"%@ meta id={f.id} family={f.family} schema={SCHEMA}")
82
+ a(f"%@ name :: {_oneline(f.name)}")
83
+ if f.description:
84
+ a(f"%@ desc :: {_oneline(f.description)}")
85
+ if f.tags:
86
+ a(f"%@ tags :: {' | '.join(f.tags)}")
87
+ if f.provenance is not None:
88
+ prov = f.provenance
89
+ for key, val in (
90
+ ("source", prov.source),
91
+ ("reference", prov.reference),
92
+ ("author", prov.author),
93
+ ("date", prov.date),
94
+ ):
95
+ if val:
96
+ a(f"%@ prov {key} :: {_oneline(val)}")
97
+ for idx in f.indices:
98
+ a(
99
+ f"%@ index {idx.name} ordered={int(idx.ordered)} "
100
+ f"cyclic={int(idx.cyclic)} :: {_oneline(idx.description)}"
101
+ )
102
+ for p in f.parameters:
103
+ a(
104
+ f"%@ param {p.name} shape={_shape_tok(p.shape)} kind={p.kind} "
105
+ f"domain={p.domain_class or '-'} :: {_oneline(p.description)}"
106
+ )
107
+ for v in f.variables:
108
+ a(
109
+ f"%@ var {v.name} shape={_shape_tok(v.shape)} domain={v.domain} "
110
+ f"role={v.role} drole={v.domain_role or '-'} "
111
+ f"lo={_num_tok(v.lower)} hi={_num_tok(v.upper)} "
112
+ f":: {_oneline(v.description)}"
113
+ )
114
+ if f.objective is not None:
115
+ o = f.objective
116
+ a(
117
+ f"%@ obj sense={o.sense} name={_tok(o.name)} "
118
+ f"combination={o.combination} :: {_oneline(o.description)}"
119
+ )
120
+ for c in f.constraints:
121
+ ind = "-"
122
+ if c.indicator is not None:
123
+ ind = f"{c.indicator.binary}@{c.indicator.active_value}"
124
+ a(
125
+ f"%@ con {c.name} kind={c.kind} domain={c.domain_class or '-'} "
126
+ f"indicator={ind} :: {_oneline(c.description)}"
127
+ )
128
+
129
+ a(r"\begin{align}")
130
+ if f.objective is not None:
131
+ sense = r"\min" if f.objective.sense == "min" else r"\max"
132
+ body = _emit_sum(f.objective.terms)
133
+ a(rf" {sense}\quad & {body} \tag{{{_tag(f.objective.name)}}} \\")
134
+ for c in f.constraints:
135
+ lhs = _emit_sum(c.lhs)
136
+ rhs = _emit_sum(c.rhs) if c.rhs else "0"
137
+ cmp = _CMP_OUT[c.comparator]
138
+ quant = _emit_quantifiers(c.quantifiers)
139
+ qpart = rf" \qquad {quant}" if quant else ""
140
+ a(rf" & {lhs} {cmp} {rhs}{qpart} \tag{{{_tag(c.name)}}} \\")
141
+ a(r"\end{align}")
142
+ return "\n".join(lines) + "\n"
143
+
144
+
145
+ def _emit_sum(terms: tuple[Term, ...]) -> str:
146
+ if not terms:
147
+ return "0"
148
+ parts: list[str] = []
149
+ for i, t in enumerate(terms):
150
+ sign, body = _emit_term(t)
151
+ if i == 0:
152
+ parts.append(("- " + body) if sign < 0 else body)
153
+ else:
154
+ parts.append(("- " if sign < 0 else "+ ") + body)
155
+ return " ".join(parts)
156
+
157
+
158
+ def _emit_term(t: Term) -> tuple[int, str]:
159
+ """Return ``(display_sign, body_without_sign)``."""
160
+ sign = t.sign
161
+ if t.ref_kind == "literal":
162
+ val = t.coefficient if t.coefficient is not None else 1
163
+ if isinstance(val, (int, float)) and val < 0:
164
+ sign = -sign
165
+ val = -val
166
+ return sign, _num(val)
167
+
168
+ base = _sym(t.ref)
169
+ sub = _subscript(t.bindings)
170
+ body = base + sub
171
+
172
+ coef = t.coefficient
173
+ if isinstance(coef, str):
174
+ body = f"{_sym(coef)} \\cdot {body}"
175
+ elif isinstance(coef, (int, float)) and coef != 1:
176
+ if coef < 0:
177
+ sign = -sign
178
+ coef = -coef
179
+ body = f"{_num(coef)} \\cdot {body}"
180
+
181
+ op = t.operator
182
+ if op == "sum":
183
+ body = rf"\sum_{{{_emit_sum_sub(t)}}} {body}"
184
+ elif op == "abs":
185
+ body = rf"\left| {body} \right|"
186
+ elif op == "max":
187
+ body = rf"\max\left( {body} \right)"
188
+ elif op == "min":
189
+ body = rf"\min\left( {body} \right)"
190
+ elif op == "indicator":
191
+ body = rf"\mathbb{{1}}\left[ {body} \right]"
192
+ elif op == "modulo":
193
+ body = rf"\left( {body} \right)"
194
+ return sign, body
195
+
196
+
197
+ def _emit_sum_sub(t: Term) -> str:
198
+ r"""Render the ``\sum`` binder set, pairing each summed family with a
199
+ binder variable taken from the term's bindings."""
200
+ remaining = list(t.bindings)
201
+ binders: list[tuple[str, str]] = []
202
+ for fam in t.operator_over:
203
+ pick = next((b for b in remaining if b.index == fam), None)
204
+ if pick is not None:
205
+ remaining.remove(pick)
206
+ binders.append((pick.expr, fam))
207
+ else:
208
+ binders.append((fam.lower(), fam))
209
+ return ", ".join(rf"{expr} \in {_set(fam)}" for expr, fam in binders)
210
+
211
+
212
+ def _emit_quantifiers(quantifiers: tuple[Quantifier, ...]) -> str:
213
+ if not quantifiers:
214
+ return ""
215
+ parts = [rf"\forall {q.index} \in {_set(q.over)}" for q in quantifiers]
216
+ extra: list[str] = []
217
+ for q in quantifiers:
218
+ if q.restriction != "none":
219
+ extra.append(f"{q.index} {_RESTR_OUT[q.restriction]} {q.restriction_other}")
220
+ if q.where is not None:
221
+ extra.append(f"{_sym(q.where.parameter)}_{{{q.index}}} = {_where_val(q.where.equals)}")
222
+ return ",\\; ".join(parts + extra)
223
+
224
+
225
+ # --- emit helpers ----------------------------------------------------------
226
+
227
+
228
+ def _sym(name: str) -> str:
229
+ if re.fullmatch(r"[A-Za-z]", name):
230
+ return name
231
+ return r"\mathit{" + name.replace("_", r"\_") + "}"
232
+
233
+
234
+ def _set(name: str) -> str:
235
+ return r"\mathcal{" + name.replace("_", r"\_") + "}"
236
+
237
+
238
+ def _subscript(bindings: tuple[Binding, ...]) -> str:
239
+ if not bindings:
240
+ return ""
241
+ return "_{" + ",".join(b.expr for b in bindings) + "}"
242
+
243
+
244
+ def _num(x: float | int | str) -> str:
245
+ if isinstance(x, str):
246
+ return x
247
+ if isinstance(x, bool):
248
+ return str(int(x))
249
+ if float(x).is_integer():
250
+ return str(int(x))
251
+ return repr(x)
252
+
253
+
254
+ def _tag(name: str) -> str:
255
+ return name.replace("_", r"\_")
256
+
257
+
258
+ def _oneline(s: str) -> str:
259
+ return " ".join(s.split())
260
+
261
+
262
+ def _tok(s: str) -> str:
263
+ """A space-free token for ``key=value`` annotations; '-' marks empty."""
264
+ s = s.strip()
265
+ if s == "":
266
+ return "-"
267
+ return s
268
+
269
+
270
+ def _shape_tok(shape: tuple[str, ...]) -> str:
271
+ return ",".join(shape) if shape else "-"
272
+
273
+
274
+ def _num_tok(x: float | None) -> str:
275
+ return "-" if x is None else _num(x)
276
+
277
+
278
+ def _where_val(v: bool | int | float | str) -> str:
279
+ if isinstance(v, bool):
280
+ return r"\mathrm{true}" if v else r"\mathrm{false}"
281
+ if isinstance(v, (int, float)):
282
+ return _num(v)
283
+ return r"\mathrm{" + str(v) + "}"
284
+
285
+
286
+ # ===========================================================================
287
+ # Parser
288
+ # ===========================================================================
289
+
290
+
291
+ class _SymTab:
292
+ def __init__(self) -> None:
293
+ self.var_shape: dict[str, tuple[str, ...]] = {}
294
+ self.param_shape: dict[str, tuple[str, ...]] = {}
295
+
296
+ def kind(self, name: str) -> str:
297
+ if name in self.var_shape:
298
+ return "variable"
299
+ if name in self.param_shape:
300
+ return "parameter"
301
+ return "literal"
302
+
303
+ def shape(self, name: str) -> tuple[str, ...]:
304
+ if name in self.var_shape:
305
+ return self.var_shape[name]
306
+ return self.param_shape.get(name, ())
307
+
308
+
309
+ def from_canonical_latex(text: str) -> Formulation:
310
+ """Parse a document produced by :func:`to_canonical_latex` back into a
311
+ :class:`Formulation`. Deterministic — no model in the loop."""
312
+ ann = _parse_annotations(text)
313
+ sym = _SymTab()
314
+ for name, info in ann["param"].items():
315
+ sym.param_shape[name] = info["shape"]
316
+ for name, info in ann["var"].items():
317
+ sym.var_shape[name] = info["shape"]
318
+
319
+ body_rows = _body_rows(text)
320
+ objective = None
321
+ constraints: list[ConstraintTemplate] = []
322
+ for row in body_rows:
323
+ name, kind = _row_tag(row)
324
+ if kind == "objective":
325
+ objective = _parse_objective_row(row, ann, sym)
326
+ else:
327
+ constraints.append(_parse_constraint_row(row, name, ann, sym))
328
+
329
+ meta = ann["meta"]
330
+ kwargs: dict[str, object] = {
331
+ "id": meta["id"],
332
+ "name": ann["name"],
333
+ "family": meta["family"],
334
+ "description": ann.get("desc", ""),
335
+ "tags": tuple(ann.get("tags", ())),
336
+ "indices": tuple(
337
+ Index(name=n, description=i["desc"], ordered=i["ordered"], cyclic=i["cyclic"])
338
+ for n, i in ann["index"].items()
339
+ ),
340
+ "parameters": tuple(
341
+ Parameter(
342
+ name=n,
343
+ description=i["desc"],
344
+ shape=i["shape"],
345
+ kind=i["kind"],
346
+ domain_class=i["domain"],
347
+ )
348
+ for n, i in ann["param"].items()
349
+ ),
350
+ "variables": tuple(
351
+ VariableTemplate(
352
+ name=n,
353
+ description=i["desc"],
354
+ shape=i["shape"],
355
+ domain=i["domain"],
356
+ role=i["role"],
357
+ domain_role=i["drole"],
358
+ lower=i["lo"],
359
+ upper=i["hi"],
360
+ )
361
+ for n, i in ann["var"].items()
362
+ ),
363
+ "constraints": tuple(constraints),
364
+ "objective": objective,
365
+ }
366
+ if "prov" in ann:
367
+ from lp2graph.core.model import Provenance
368
+
369
+ kwargs["provenance"] = Provenance(**ann["prov"])
370
+ return Formulation(**kwargs)
371
+
372
+
373
+ # --- annotation parsing ----------------------------------------------------
374
+
375
+
376
+ def _parse_annotations(text: str) -> dict[str, Any]:
377
+ out: dict[str, Any] = {"index": {}, "param": {}, "var": {}, "con": {}}
378
+ for raw in text.splitlines():
379
+ line = raw.strip()
380
+ if not line.startswith("%@"):
381
+ continue
382
+ line = line[2:].strip()
383
+ if "::" in line:
384
+ head, desc = line.split("::", 1)
385
+ else:
386
+ head, desc = line, None
387
+ head = head.strip()
388
+ desc = desc.strip() if desc is not None else None
389
+ toks = head.split()
390
+ rec = toks[0]
391
+ if rec == "meta":
392
+ out["meta"] = _kv(toks[1:])
393
+ elif rec == "name":
394
+ out["name"] = desc or ""
395
+ elif rec == "desc":
396
+ out["desc"] = desc or ""
397
+ elif rec == "tags":
398
+ out["tags"] = tuple(t.strip() for t in (desc or "").split("|") if t.strip())
399
+ elif rec == "prov":
400
+ out.setdefault("prov", {})[toks[1]] = desc or ""
401
+ elif rec == "index":
402
+ kv = _kv(toks[2:])
403
+ out["index"][toks[1]] = {
404
+ "ordered": kv.get("ordered") == "1",
405
+ "cyclic": kv.get("cyclic") == "1",
406
+ "desc": desc or "",
407
+ }
408
+ elif rec == "param":
409
+ kv = _kv(toks[2:])
410
+ out["param"][toks[1]] = {
411
+ "shape": _shape(kv.get("shape", "-")),
412
+ "kind": kv.get("kind", "scalar"),
413
+ "domain": _dash(kv.get("domain", "-")),
414
+ "desc": desc or "",
415
+ }
416
+ elif rec == "var":
417
+ kv = _kv(toks[2:])
418
+ out["var"][toks[1]] = {
419
+ "shape": _shape(kv.get("shape", "-")),
420
+ "domain": kv.get("domain", "continuous"),
421
+ "role": kv.get("role", "primary"),
422
+ "drole": _dash(kv.get("drole", "-")),
423
+ "lo": _numopt(kv.get("lo", "-")),
424
+ "hi": _numopt(kv.get("hi", "-")),
425
+ "desc": desc or "",
426
+ }
427
+ elif rec == "obj":
428
+ kv = _kv(toks[1:])
429
+ out["obj"] = {
430
+ "sense": kv.get("sense", "min"),
431
+ "name": _untok(kv.get("name", "objective")),
432
+ "combination": kv.get("combination", "sum"),
433
+ "desc": desc or "",
434
+ }
435
+ elif rec == "con":
436
+ kv = _kv(toks[2:])
437
+ out["con"][toks[1]] = {
438
+ "kind": kv.get("kind", "linear"),
439
+ "domain": _dash(kv.get("domain", "-")),
440
+ "indicator": _dash(kv.get("indicator", "-")),
441
+ "desc": desc or "",
442
+ }
443
+ return out
444
+
445
+
446
+ def _kv(toks: list[str]) -> dict[str, str]:
447
+ out: dict[str, str] = {}
448
+ for t in toks:
449
+ if "=" in t:
450
+ k, v = t.split("=", 1)
451
+ out[k] = v
452
+ return out
453
+
454
+
455
+ def _dash(v: str) -> str | None:
456
+ return None if v == "-" else v
457
+
458
+
459
+ def _untok(v: str) -> str:
460
+ return "" if v == "-" else v.replace(" ", " ")
461
+
462
+
463
+ def _shape(v: str) -> tuple[str, ...]:
464
+ if v == "-" or v == "":
465
+ return ()
466
+ return tuple(v.split(","))
467
+
468
+
469
+ def _numopt(v: str) -> float | None:
470
+ if v == "-":
471
+ return None
472
+ return float(v)
473
+
474
+
475
+ # --- body row parsing ------------------------------------------------------
476
+
477
+
478
+ def _body_rows(text: str) -> list[str]:
479
+ m = re.search(r"\\begin\{align\}(.*?)\\end\{align\}", text, re.DOTALL)
480
+ if not m:
481
+ return []
482
+ inner = m.group(1)
483
+ rows = [r.strip() for r in inner.split(r"\\")]
484
+ return [r for r in rows if r and not r.startswith("%")]
485
+
486
+
487
+ def _row_tag(row: str) -> tuple[str, str]:
488
+ m = re.search(r"\\tag\{(.*?)\}", row)
489
+ name = m.group(1).replace(r"\_", "_") if m else ""
490
+ body = row[: m.start()] if m else row
491
+ if r"\min" in body or r"\max" in body:
492
+ return name, "objective"
493
+ return name, "constraint"
494
+
495
+
496
+ def _strip_tag(row: str) -> str:
497
+ return re.sub(r"\\tag\{.*?\}", "", row).strip()
498
+
499
+
500
+ def _parse_objective_row(row: str, ann: dict[str, Any], sym: _SymTab) -> Objective:
501
+ info = ann.get("obj", {})
502
+ body = _strip_tag(row).replace("&", " ")
503
+ body = re.sub(r"\\min\\quad|\\max\\quad|\\min|\\max|\\quad", " ", body).strip()
504
+ terms = _parse_term_sum(body, "objective", sym)
505
+ return Objective(
506
+ sense=info.get("sense", "min"),
507
+ name=info.get("name", "objective"),
508
+ description=info.get("desc", ""),
509
+ combination=info.get("combination", "sum"),
510
+ terms=tuple(terms),
511
+ )
512
+
513
+
514
+ def _parse_constraint_row(
515
+ row: str, name: str, ann: dict[str, Any], sym: _SymTab
516
+ ) -> ConstraintTemplate:
517
+ info = ann["con"].get(name, {})
518
+ body = _strip_tag(row)
519
+ # Split body from quantifier on \qquad.
520
+ qpart = ""
521
+ if r"\qquad" in body:
522
+ body, qpart = body.split(r"\qquad", 1)
523
+ body = body.replace("&", " ").strip()
524
+
525
+ cmp, lhs_s, rhs_s = _split_comparison(body)
526
+ lhs = _parse_term_sum(lhs_s, "lhs", sym)
527
+ rhs = _parse_term_sum(rhs_s, "rhs", sym)
528
+ quantifiers = _parse_quantifiers(qpart)
529
+
530
+ indicator = None
531
+ ind = info.get("indicator")
532
+ if ind:
533
+ binary, active = ind.split("@")
534
+ from lp2graph.core.model import IndicatorTrigger
535
+
536
+ indicator = IndicatorTrigger(binary=binary, active_value=int(active))
537
+
538
+ return ConstraintTemplate(
539
+ name=name,
540
+ description=info.get("desc", ""),
541
+ quantifiers=tuple(quantifiers),
542
+ comparator=cmp,
543
+ lhs=tuple(lhs),
544
+ rhs=tuple(rhs),
545
+ kind=info.get("kind", "linear"),
546
+ domain_class=info.get("domain"),
547
+ indicator=indicator,
548
+ )
549
+
550
+
551
+ def _split_comparison(body: str) -> tuple[str, str, str]:
552
+ for tok, cmp in ((r"\le", "le"), (r"\ge", "ge")):
553
+ idx = _find_top(body, tok)
554
+ if idx >= 0:
555
+ return cmp, body[:idx], body[idx + len(tok) :]
556
+ idx = _find_top_eq(body)
557
+ if idx >= 0:
558
+ return "eq", body[:idx], body[idx + 1 :]
559
+ raise ValueError(f"no comparator in constraint body: {body!r}")
560
+
561
+
562
+ def _find_top(body: str, tok: str) -> int:
563
+ depth = 0
564
+ i = 0
565
+ while i < len(body):
566
+ ch = body[i]
567
+ if ch == "{":
568
+ depth += 1
569
+ elif ch == "}":
570
+ depth -= 1
571
+ elif depth == 0 and body.startswith(tok, i):
572
+ # avoid matching \leq/\geq tails when looking for \le/\ge is fine
573
+ return i
574
+ i += 1
575
+ return -1
576
+
577
+
578
+ def _find_top_eq(body: str) -> int:
579
+ depth = 0
580
+ for i, ch in enumerate(body):
581
+ if ch == "{":
582
+ depth += 1
583
+ elif ch == "}":
584
+ depth -= 1
585
+ elif depth == 0 and ch == "=":
586
+ return i
587
+ return -1
588
+
589
+
590
+ # --- term-sum parsing ------------------------------------------------------
591
+
592
+
593
+ def _parse_term_sum(body: str, role: str, sym: _SymTab) -> list[Term]:
594
+ body = body.strip()
595
+ if body == "" or body == "0":
596
+ # An explicit "0" RHS carries no terms.
597
+ if body == "0":
598
+ return []
599
+ return []
600
+ pieces = _split_signed(body)
601
+ terms = []
602
+ for sign, text in pieces:
603
+ t = _parse_term(text, sign, role, sym)
604
+ if t is not None:
605
+ terms.append(t)
606
+ return terms
607
+
608
+
609
+ def _split_signed(body: str) -> list[tuple[int, str]]:
610
+ """Split a term sum at top-level +/-, returning (sign, term_text)."""
611
+ out: list[tuple[int, str]] = []
612
+ depth = 0
613
+ sign = 1
614
+ cur: list[str] = []
615
+ i = 0
616
+ started = False
617
+ while i < len(body):
618
+ ch = body[i]
619
+ if ch == "{":
620
+ depth += 1
621
+ cur.append(ch)
622
+ elif ch == "}":
623
+ depth -= 1
624
+ cur.append(ch)
625
+ elif depth == 0 and ch in "+-" and started and not _is_exponent(body, i):
626
+ out.append((sign, "".join(cur).strip()))
627
+ sign = -1 if ch == "-" else 1
628
+ cur = []
629
+ elif depth == 0 and ch in "+-" and not started:
630
+ sign = -1 if ch == "-" else 1
631
+ else:
632
+ if not ch.isspace():
633
+ started = True
634
+ cur.append(ch)
635
+ i += 1
636
+ tail = "".join(cur).strip()
637
+ if tail:
638
+ out.append((sign, tail))
639
+ return out
640
+
641
+
642
+ def _is_exponent(body: str, i: int) -> bool:
643
+ return i > 0 and body[i - 1] in "eE" and (i >= 2 and body[i - 2].isdigit())
644
+
645
+
646
+ def _parse_term(text: str, sign: int, role: str, sym: _SymTab) -> Term | None:
647
+ text = text.strip()
648
+ if not text:
649
+ return None
650
+ operator = "none"
651
+ operator_over: tuple[str, ...] = ()
652
+
653
+ # Aggregation wrappers.
654
+ if text.startswith(r"\sum_"):
655
+ sub, rest = _take_braced(text[len(r"\sum_") :])
656
+ operator = "sum"
657
+ operator_over = tuple(_setnames(sub))
658
+ text = rest.strip()
659
+ elif text.startswith(r"\left|"):
660
+ operator = "abs"
661
+ text = _between(text, r"\left|", r"\right|")
662
+ elif text.startswith(r"\max\left("):
663
+ operator = "max"
664
+ text = _between(text, r"\max\left(", r"\right)")
665
+ elif text.startswith(r"\min\left("):
666
+ operator = "min"
667
+ text = _between(text, r"\min\left(", r"\right)")
668
+ elif text.startswith(r"\mathbb{1}\left["):
669
+ operator = "indicator"
670
+ text = _between(text, r"\mathbb{1}\left[", r"\right]")
671
+
672
+ # Coefficient / referent.
673
+ coefficient: float | str | None = 1
674
+ if r"\cdot" in text:
675
+ coef_s, ref_s = text.split(r"\cdot", 1)
676
+ coefficient = _parse_coef(coef_s.strip())
677
+ text = ref_s.strip()
678
+
679
+ text = text.strip()
680
+ if re.fullmatch(r"-?\d+(\.\d+)?", text):
681
+ value = float(text)
682
+ if value.is_integer():
683
+ value = int(value)
684
+ return Term(
685
+ ref="_const",
686
+ ref_kind="literal",
687
+ coefficient=value,
688
+ sign=sign,
689
+ role=role,
690
+ operator=operator,
691
+ operator_over=operator_over,
692
+ )
693
+
694
+ name, bindings = _parse_referent(text, sym)
695
+ return Term(
696
+ ref=name,
697
+ ref_kind=sym.kind(name),
698
+ bindings=tuple(bindings),
699
+ coefficient=coefficient,
700
+ sign=sign,
701
+ role=role,
702
+ operator=operator,
703
+ operator_over=operator_over,
704
+ )
705
+
706
+
707
+ def _parse_coef(s: str) -> float | str:
708
+ s = s.strip()
709
+ if re.fullmatch(r"-?\d+(\.\d+)?", s):
710
+ v = float(s)
711
+ return int(v) if v.is_integer() else v
712
+ return _read_sym(s)
713
+
714
+
715
+ def _parse_referent(text: str, sym: _SymTab) -> tuple[str, list[Binding]]:
716
+ base, sub = _split_subscript(text)
717
+ name = _read_sym(base)
718
+ bindings: list[Binding] = []
719
+ if sub:
720
+ exprs = _split_top_commas(sub)
721
+ shape = sym.shape(name)
722
+ for pos, expr in enumerate(exprs):
723
+ fam = shape[pos] if pos < len(shape) else (exprs and expr)
724
+ bindings.append(Binding(index=fam, expr=expr.strip(), offset=_offset(expr)))
725
+ return name, bindings
726
+
727
+
728
+ def _split_subscript(text: str) -> tuple[str, str]:
729
+ m = re.search(r"_\{", text)
730
+ if not m:
731
+ return text.strip(), ""
732
+ base = text[: m.start()]
733
+ sub, _ = _take_braced(text[m.end() - 1 :]) # include the '{'
734
+ return base.strip(), sub
735
+
736
+
737
+ def _read_sym(s: str) -> str:
738
+ s = s.strip()
739
+ m = re.fullmatch(r"\\mathit\{(.*)\}", s)
740
+ if m:
741
+ return m.group(1).replace(r"\_", "_")
742
+ m = re.fullmatch(r"\\mathrm\{(.*)\}", s)
743
+ if m:
744
+ return m.group(1).replace(r"\_", "_")
745
+ return s
746
+
747
+
748
+ def _offset(expr: str) -> int:
749
+ m = re.search(r"[+-]\s*\d+\s*$", expr.replace(" ", ""))
750
+ return int(m.group(0).replace(" ", "")) if m else 0
751
+
752
+
753
+ # --- quantifier parsing ----------------------------------------------------
754
+
755
+
756
+ def _parse_quantifiers(qpart: str) -> list[Quantifier]:
757
+ qpart = qpart.replace("&", " ").strip()
758
+ if not qpart:
759
+ return []
760
+ qpart = qpart.replace(r"\forall", "")
761
+ clauses = [c.strip() for c in _split_clauses(qpart) if c.strip()]
762
+ quants: dict[str, dict[str, Any]] = {}
763
+ order: list[str] = []
764
+ extras: list[str] = []
765
+ for cl in clauses:
766
+ m = re.match(r"^(\w+)\s*\\in\s*\\mathcal\{([\w\\]+)\}$", cl)
767
+ if m:
768
+ idx = m.group(1)
769
+ over = m.group(2).replace(r"\_", "_")
770
+ quants[idx] = {"over": over, "restriction": "none", "other": None, "where": None}
771
+ order.append(idx)
772
+ else:
773
+ extras.append(cl)
774
+ for cl in extras:
775
+ _apply_extra(cl, quants)
776
+ return [
777
+ Quantifier(
778
+ index=i,
779
+ over=quants[i]["over"],
780
+ restriction=quants[i]["restriction"],
781
+ restriction_other=quants[i]["other"],
782
+ where=quants[i]["where"],
783
+ )
784
+ for i in order
785
+ ]
786
+
787
+
788
+ def _apply_extra(cl: str, quants: dict[str, dict[str, Any]]) -> None:
789
+ # where-clause: sym_{idx} = value
790
+ mw = re.match(r"^(.*?)_\{(\w+)\}\s*=\s*(.+)$", cl)
791
+ if mw and mw.group(1).strip() not in ("",):
792
+ idx = mw.group(2)
793
+ if idx in quants:
794
+ param = _read_sym(mw.group(1).strip())
795
+ quants[idx]["where"] = QuantifierWhere(
796
+ parameter=param, equals=_parse_where_val(mw.group(3).strip())
797
+ )
798
+ return
799
+ # restriction: idx OP other
800
+ for tok, restr in _RESTR_IN.items():
801
+ m = re.match(rf"^(\w+)\s*{re.escape(tok)}\s*(\w+)$", cl)
802
+ if m:
803
+ idx = m.group(1)
804
+ if idx in quants:
805
+ quants[idx]["restriction"] = restr
806
+ quants[idx]["other"] = m.group(2)
807
+ return
808
+
809
+
810
+ def _parse_where_val(s: str) -> bool | int | float | str:
811
+ if s == r"\mathrm{true}":
812
+ return True
813
+ if s == r"\mathrm{false}":
814
+ return False
815
+ m = re.fullmatch(r"\\mathrm\{(.*)\}", s)
816
+ if m:
817
+ return m.group(1)
818
+ if re.fullmatch(r"-?\d+", s):
819
+ return int(s)
820
+ if re.fullmatch(r"-?\d+\.\d+", s):
821
+ return float(s)
822
+ return s
823
+
824
+
825
+ # --- low-level string helpers ----------------------------------------------
826
+
827
+
828
+ def _take_braced(s: str) -> tuple[str, str]:
829
+ """Given a string starting with ``{``, return (inner, remainder)."""
830
+ assert s.startswith("{"), s
831
+ depth = 0
832
+ for i, ch in enumerate(s):
833
+ if ch == "{":
834
+ depth += 1
835
+ elif ch == "}":
836
+ depth -= 1
837
+ if depth == 0:
838
+ return s[1:i], s[i + 1 :]
839
+ raise ValueError(f"unbalanced braces: {s!r}")
840
+
841
+
842
+ def _between(text: str, open_t: str, close_t: str) -> str:
843
+ inner = text[len(open_t) :]
844
+ if inner.endswith(close_t):
845
+ inner = inner[: -len(close_t)]
846
+ else:
847
+ idx = inner.rfind(close_t)
848
+ if idx >= 0:
849
+ inner = inner[:idx]
850
+ return inner.strip()
851
+
852
+
853
+ def _setnames(sub: str) -> list[str]:
854
+ return [m.replace(r"\_", "_") for m in re.findall(r"\\mathcal\{([\w\\]+)\}", sub)]
855
+
856
+
857
+ def _split_top_commas(s: str) -> list[str]:
858
+ out: list[str] = []
859
+ depth = 0
860
+ cur: list[str] = []
861
+ for ch in s:
862
+ if ch == "{":
863
+ depth += 1
864
+ cur.append(ch)
865
+ elif ch == "}":
866
+ depth -= 1
867
+ cur.append(ch)
868
+ elif ch == "," and depth == 0:
869
+ out.append("".join(cur))
870
+ cur = []
871
+ else:
872
+ cur.append(ch)
873
+ if cur:
874
+ out.append("".join(cur))
875
+ return [x.strip() for x in out]
876
+
877
+
878
+ def _split_clauses(s: str) -> list[str]:
879
+ """Split quantifier clauses on top-level commas (``\\;`` already in text)."""
880
+ s = s.replace(r"\;", ",")
881
+ return _split_top_commas(s)
882
+
883
+
884
+ __all__ = ["from_canonical_latex", "to_canonical_latex"]