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/codec/latex.py
ADDED
|
@@ -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"]
|