algo2code 0.2.1__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.
- algo2code/__init__.py +72 -0
- algo2code/algo_parser.py +458 -0
- algo2code/ast_nodes.py +171 -0
- algo2code/backends/__init__.py +1 -0
- algo2code/backends/c_petsc_codegen.py +1 -0
- algo2code/backends/numpy_codegen.py +1 -0
- algo2code/backends/taichi_codegen.py +859 -0
- algo2code/errors.py +27 -0
- algo2code/expr_parser.py +598 -0
- algo2code/library/.gitkeep +0 -0
- algo2code/library/__init__.py +19 -0
- algo2code/library/pcg.py +127 -0
- algo2code/library/radial_return_j2.py +90 -0
- algo2code/library/radial_return_j2_kinematic.py +89 -0
- algo2code/library/radial_return_j2_mixed.py +91 -0
- algo2code/type_inference.py +219 -0
- algo2code-0.2.1.dist-info/METADATA +30 -0
- algo2code-0.2.1.dist-info/RECORD +20 -0
- algo2code-0.2.1.dist-info/WHEEL +4 -0
- algo2code-0.2.1.dist-info/licenses/LICENSE +21 -0
algo2code/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exception types for algo2code.
|
|
2
|
+
|
|
3
|
+
The transpiler must *fail loud*: any LaTeX construct it cannot faithfully lower
|
|
4
|
+
should raise, never silently emit wrong (or empty) code. Finding F6 in issue #307
|
|
5
|
+
documented the opposite — statements vanished and invalid ``ti.field`` arithmetic
|
|
6
|
+
shipped without warning. These exception types are the contract that replaces that
|
|
7
|
+
silent behaviour.
|
|
8
|
+
|
|
9
|
+
Convention (mirrors the mechdsl-core IR discipline): an ``UnsupportedConstructError``
|
|
10
|
+
message names the offending construct *and* the workaround or plan phase that would
|
|
11
|
+
add support, so the failure is actionable.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Algo2CodeError(Exception):
|
|
18
|
+
"""Base class for all algo2code transpilation errors."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnsupportedConstructError(Algo2CodeError):
|
|
22
|
+
"""A LaTeX construct is recognised but not supported by the transpiler.
|
|
23
|
+
|
|
24
|
+
Raised instead of silently skipping a token, dropping a statement, or
|
|
25
|
+
emitting un-runnable code. The message should tell the user what to do
|
|
26
|
+
instead (e.g. use a subscript, use mechdsl-core einsum, declare a callable).
|
|
27
|
+
"""
|
algo2code/expr_parser.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Expression parser for LaTeX math fragments inside \\State commands.
|
|
3
|
+
|
|
4
|
+
Handles the linear-algebra subset of LaTeX math relevant to iterative solvers:
|
|
5
|
+
- Scalar arithmetic: \\alpha, \\frac{a}{b}, a + b, a - b
|
|
6
|
+
- Vector/matrix ops: \\mathbf{A} \\mathbf{p}, \\mathbf{r}^\\top \\mathbf{z}
|
|
7
|
+
- Norms: \\|\\mathbf{r}\\|, \\lVert r \\rVert
|
|
8
|
+
- Function calls: \\mathbf{M}^{-1}(\\mathbf{r})
|
|
9
|
+
- Subscripts: \\rho_{\\text{new}}, x_{k+1}
|
|
10
|
+
|
|
11
|
+
This is intentionally NOT a full LaTeX math parser. It handles the ~20 patterns
|
|
12
|
+
that actually appear in algorithm boxes for CG, GMRES, Newton, return-mapping, etc.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from .ast_nodes import BinOp, Expr, FuncCall, Number, UnaryOp, Var
|
|
20
|
+
from .errors import UnsupportedConstructError
|
|
21
|
+
|
|
22
|
+
# ── Tokenizer ────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
# Order matters: longer patterns first
|
|
25
|
+
TOKEN_PATTERNS = [
|
|
26
|
+
# Commands and groups
|
|
27
|
+
(r"\\(?:mathbf|boldsymbol|bm|mathit|mathrm|text|textbf)\{([^}]*)\}", "STYLED"),
|
|
28
|
+
(r"\\operatorname\{([^}]*)\}", "OPNAME"),
|
|
29
|
+
(r"\\(?:lVert|left\\\|)", "LNORM"),
|
|
30
|
+
(r"\\(?:rVert|right\\\|)", "RNORM"),
|
|
31
|
+
(r"\\\|", "NORMPIPE"),
|
|
32
|
+
(r"\|", "PIPE"),
|
|
33
|
+
(r"\\frac\s*", "FRAC"),
|
|
34
|
+
(r"\\sqrt\s*", "SQRT"),
|
|
35
|
+
(r"\\cdot", "CDOT"),
|
|
36
|
+
(r"\\,", "THINSPACE"),
|
|
37
|
+
(r"\\;", "THINSPACE"),
|
|
38
|
+
(r"\\quad", "THINSPACE"),
|
|
39
|
+
(r"\\top", "TOP"),
|
|
40
|
+
(r"\\[Tt]ranspose", "TOP"),
|
|
41
|
+
(r"\\ldots|\\dots|\\cdots", "DOTS"),
|
|
42
|
+
# Greek letters
|
|
43
|
+
(
|
|
44
|
+
r"\\(alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|"
|
|
45
|
+
r"iota|kappa|lambda|mu|nu|xi|pi|rho|sigma|tau|upsilon|phi|"
|
|
46
|
+
r"varphi|chi|psi|omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|"
|
|
47
|
+
r"Upsilon|Phi|Psi|Omega)",
|
|
48
|
+
"GREEK",
|
|
49
|
+
),
|
|
50
|
+
# Delimiters and operators
|
|
51
|
+
(r"\{", "LBRACE"),
|
|
52
|
+
(r"\}", "RBRACE"),
|
|
53
|
+
(r"\(", "LPAREN"),
|
|
54
|
+
(r"\)", "RPAREN"),
|
|
55
|
+
(r"\^", "CARET"),
|
|
56
|
+
(r"_", "UNDERSCORE"),
|
|
57
|
+
(r"\+", "PLUS"),
|
|
58
|
+
(r"-", "MINUS"),
|
|
59
|
+
(r"/", "SLASH"),
|
|
60
|
+
(r"=", "EQUALS"),
|
|
61
|
+
(r"<", "LT"),
|
|
62
|
+
(r">", "GT"),
|
|
63
|
+
(r",", "COMMA"),
|
|
64
|
+
# Numbers
|
|
65
|
+
(r"\d+\.?\d*", "NUMBER"),
|
|
66
|
+
# Plain letters / identifiers — multi-character names supported so
|
|
67
|
+
# algorithm scratch identifiers like ``pq``, ``sn``, ``rho_new`` can
|
|
68
|
+
# tokenise as a single Var instead of an implicit product. Single
|
|
69
|
+
# letters still tokenise to LETTER for back-compat (``x``, ``a``).
|
|
70
|
+
(r"[a-zA-Z][a-zA-Z0-9]*", "LETTER"),
|
|
71
|
+
# Whitespace
|
|
72
|
+
(r"\s+", "WS"),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
_TOKEN_RE = re.compile("|".join(f"(?P<T{i}>{pat})" for i, (pat, _) in enumerate(TOKEN_PATTERNS)))
|
|
76
|
+
_TOKEN_NAMES = [name for _, name in TOKEN_PATTERNS]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Token:
|
|
80
|
+
__slots__ = ("kind", "pos", "value")
|
|
81
|
+
|
|
82
|
+
def __init__(self, kind: str, value: str, pos: int):
|
|
83
|
+
self.kind = kind
|
|
84
|
+
self.value = value
|
|
85
|
+
self.pos = pos
|
|
86
|
+
|
|
87
|
+
def __repr__(self):
|
|
88
|
+
return f"Token({self.kind}, {self.value!r})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def tokenize(latex: str) -> list[Token]:
|
|
92
|
+
"""Tokenize a LaTeX math expression.
|
|
93
|
+
|
|
94
|
+
Fail-loud (F6): characters that match no token pattern are *not* silently
|
|
95
|
+
skipped. Previously a stray ``:`` (tensor contraction) or ``\\tilde`` accent
|
|
96
|
+
left an unmatched gap that the regex dropped, so the surrounding statement
|
|
97
|
+
parsed as something else entirely with no warning. Any non-whitespace gap
|
|
98
|
+
now raises :class:`UnsupportedConstructError`.
|
|
99
|
+
"""
|
|
100
|
+
tokens = []
|
|
101
|
+
cursor = 0
|
|
102
|
+
for m in _TOKEN_RE.finditer(latex):
|
|
103
|
+
gap = latex[cursor : m.start()]
|
|
104
|
+
if gap.strip():
|
|
105
|
+
_raise_unrecognized(gap.strip(), cursor, latex)
|
|
106
|
+
cursor = m.end()
|
|
107
|
+
for i, (_, name) in enumerate(TOKEN_PATTERNS):
|
|
108
|
+
g = m.group(f"T{i}")
|
|
109
|
+
if g is not None:
|
|
110
|
+
if name in ("WS", "THINSPACE"):
|
|
111
|
+
break # skip whitespace
|
|
112
|
+
val = g
|
|
113
|
+
# Extract inner text for styled commands
|
|
114
|
+
if name in ("STYLED", "OPNAME"):
|
|
115
|
+
inner = re.match(TOKEN_PATTERNS[0 if name == "STYLED" else 1][0], g)
|
|
116
|
+
if inner:
|
|
117
|
+
val = inner.group(1)
|
|
118
|
+
if name == "GREEK":
|
|
119
|
+
idx = next(j for j, (_, n) in enumerate(TOKEN_PATTERNS) if n == "GREEK")
|
|
120
|
+
inner = re.match(TOKEN_PATTERNS[idx][0], g)
|
|
121
|
+
if inner:
|
|
122
|
+
val = inner.group(1)
|
|
123
|
+
tokens.append(Token(name, val, m.start()))
|
|
124
|
+
break
|
|
125
|
+
tail = latex[cursor:]
|
|
126
|
+
if tail.strip():
|
|
127
|
+
_raise_unrecognized(tail.strip(), cursor, latex)
|
|
128
|
+
return tokens
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_ACCENT_RE = re.compile(r"\\(tilde|hat|bar|vec|dot|ddot|acute|grave|check|breve)\b")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _raise_unrecognized(fragment: str, pos: int, latex: str) -> None:
|
|
135
|
+
"""Raise a focused, actionable error for an unrecognised token fragment."""
|
|
136
|
+
# The accent name (e.g. ``tilde``) tokenises as a LETTER, so the unmatched
|
|
137
|
+
# gap is just the leading backslash. Look at the remaining source from ``pos``
|
|
138
|
+
# to recognise the full ``\tilde{...}`` form and give an actionable message.
|
|
139
|
+
accent = _ACCENT_RE.match(latex[pos:]) or _ACCENT_RE.match(fragment)
|
|
140
|
+
if accent:
|
|
141
|
+
name = accent.group(1)
|
|
142
|
+
raise UnsupportedConstructError(
|
|
143
|
+
f"accent macro '\\{name}{{...}}' is not supported on variables "
|
|
144
|
+
f"(at position {pos} in {latex!r}). Diacritics are ambiguous as "
|
|
145
|
+
f"identifiers — use a subscript instead, e.g. 'r_tilde' or "
|
|
146
|
+
f"'\\tau_{{hat}}'. See dev/design_docs/11-ALGO2CODE.md §2.4."
|
|
147
|
+
)
|
|
148
|
+
if fragment.startswith(":"):
|
|
149
|
+
raise UnsupportedConstructError(
|
|
150
|
+
f"tensor double-contraction ':' is not supported by algo2code "
|
|
151
|
+
f"(at position {pos} in {latex!r}). Contraction belongs in the "
|
|
152
|
+
f"mechdsl-core einsum pipeline; algo2code handles solver scaffolding "
|
|
153
|
+
f"only. See dev/design_docs/11-ALGO2CODE.md §2.4."
|
|
154
|
+
)
|
|
155
|
+
raise UnsupportedConstructError(
|
|
156
|
+
f"unrecognised character(s) {fragment!r} at position {pos} in {latex!r}. "
|
|
157
|
+
f"This fragment matches no algo2code token. If it is a valid construct "
|
|
158
|
+
f"the parser should support, it is not yet implemented."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Recursive-descent parser ─────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ExprParser:
|
|
166
|
+
"""
|
|
167
|
+
Recursive-descent parser for LaTeX math expressions.
|
|
168
|
+
|
|
169
|
+
Grammar (simplified):
|
|
170
|
+
expr := term (('+' | '-') term)*
|
|
171
|
+
term := factor (('\\cdot' | implicit) factor)*
|
|
172
|
+
factor := base ('^' superscript)?
|
|
173
|
+
base := '(' expr ')'
|
|
174
|
+
| '\\frac' '{' expr '}' '{' expr '}'
|
|
175
|
+
| '\\sqrt' '{' expr '}'
|
|
176
|
+
| norm_expr
|
|
177
|
+
| func_call
|
|
178
|
+
| atom
|
|
179
|
+
norm_expr := '\\|' expr '\\|' | '\\lVert' expr '\\rVert'
|
|
180
|
+
atom := NUMBER | LETTER | GREEK | STYLED
|
|
181
|
+
superscript := '{' expr '}' | '\\top' | '-1' | atom
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, tokens: list[Token]):
|
|
185
|
+
self.tokens = tokens
|
|
186
|
+
self.pos = 0
|
|
187
|
+
|
|
188
|
+
def peek(self) -> Token | None:
|
|
189
|
+
if self.pos < len(self.tokens):
|
|
190
|
+
return self.tokens[self.pos]
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
def advance(self) -> Token:
|
|
194
|
+
tok = self.tokens[self.pos]
|
|
195
|
+
self.pos += 1
|
|
196
|
+
return tok
|
|
197
|
+
|
|
198
|
+
def expect(self, kind: str) -> Token:
|
|
199
|
+
tok = self.peek()
|
|
200
|
+
if tok is None or tok.kind != kind:
|
|
201
|
+
got = tok.kind if tok else "EOF"
|
|
202
|
+
raise SyntaxError(f"Expected {kind} but got {got} at position {self.pos}")
|
|
203
|
+
return self.advance()
|
|
204
|
+
|
|
205
|
+
def at(self, *kinds: str) -> bool:
|
|
206
|
+
tok = self.peek()
|
|
207
|
+
return tok is not None and tok.kind in kinds
|
|
208
|
+
|
|
209
|
+
def parse(self) -> Expr:
|
|
210
|
+
"""Parse the full expression."""
|
|
211
|
+
return self.parse_expr()
|
|
212
|
+
|
|
213
|
+
def parse_expr(self) -> Expr:
|
|
214
|
+
"""expr := term (('+' | '-') term)*"""
|
|
215
|
+
left = self.parse_term()
|
|
216
|
+
while self.at("PLUS", "MINUS"):
|
|
217
|
+
op_tok = self.advance()
|
|
218
|
+
right = self.parse_term()
|
|
219
|
+
op = "+" if op_tok.kind == "PLUS" else "-"
|
|
220
|
+
left = BinOp(op=op, left=left, right=right)
|
|
221
|
+
return left
|
|
222
|
+
|
|
223
|
+
def parse_term(self) -> Expr:
|
|
224
|
+
"""term := signed_factor (('·' | '/' | implicit_mul) signed_factor)*
|
|
225
|
+
|
|
226
|
+
Division at the term level shares precedence with multiplication
|
|
227
|
+
(left-associative) so ``a + b / c`` parses as ``a + (b / c)``
|
|
228
|
+
rather than dropping the divisor. post_recovery_plan Phase 5
|
|
229
|
+
parser fix.
|
|
230
|
+
"""
|
|
231
|
+
left = self.parse_signed_factor()
|
|
232
|
+
while True:
|
|
233
|
+
# Explicit multiply
|
|
234
|
+
if self.at("CDOT"):
|
|
235
|
+
self.advance()
|
|
236
|
+
right = self.parse_signed_factor()
|
|
237
|
+
left = BinOp(op="*", left=left, right=right)
|
|
238
|
+
# Division (bare ``/``); ``\frac{}{}`` is handled separately by
|
|
239
|
+
# parse_frac so this path only fires for inline divisions.
|
|
240
|
+
elif self.at("SLASH"):
|
|
241
|
+
self.advance()
|
|
242
|
+
right = self.parse_signed_factor()
|
|
243
|
+
left = BinOp(op="/", left=left, right=right)
|
|
244
|
+
# Implicit multiplication: two adjacent factors with no operator
|
|
245
|
+
elif self._can_start_factor():
|
|
246
|
+
right = self.parse_signed_factor()
|
|
247
|
+
left = BinOp(op="*", left=left, right=right)
|
|
248
|
+
else:
|
|
249
|
+
break
|
|
250
|
+
return left
|
|
251
|
+
|
|
252
|
+
def parse_signed_factor(self) -> Expr:
|
|
253
|
+
"""Handle unary minus before a factor."""
|
|
254
|
+
if self.at("MINUS"):
|
|
255
|
+
self.advance()
|
|
256
|
+
operand = self.parse_factor()
|
|
257
|
+
return UnaryOp(op="neg", operand=operand)
|
|
258
|
+
return self.parse_factor()
|
|
259
|
+
|
|
260
|
+
def _can_start_factor(self) -> bool:
|
|
261
|
+
"""Check if the next token can start a new factor (implicit multiply).
|
|
262
|
+
|
|
263
|
+
NOTE: NORMPIPE is intentionally excluded. It is ambiguous — it could
|
|
264
|
+
be the *closing* pipe of a norm we're currently inside. Norms as
|
|
265
|
+
implicit-mul operands must use \\lVert/\\rVert or explicit \\cdot.
|
|
266
|
+
"""
|
|
267
|
+
tok = self.peek()
|
|
268
|
+
if tok is None:
|
|
269
|
+
return False
|
|
270
|
+
return tok.kind in (
|
|
271
|
+
"LETTER",
|
|
272
|
+
"GREEK",
|
|
273
|
+
"STYLED",
|
|
274
|
+
"NUMBER",
|
|
275
|
+
"LPAREN",
|
|
276
|
+
"FRAC",
|
|
277
|
+
"SQRT",
|
|
278
|
+
"LNORM",
|
|
279
|
+
"OPNAME",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def parse_factor(self) -> Expr:
|
|
283
|
+
"""factor := base ('^' superscript)? ('(' args ')')?
|
|
284
|
+
|
|
285
|
+
The trailing parens handle M^{-1}(r) as a function call.
|
|
286
|
+
"""
|
|
287
|
+
base = self.parse_base()
|
|
288
|
+
|
|
289
|
+
if self.at("CARET"):
|
|
290
|
+
self.advance()
|
|
291
|
+
base = self.parse_superscript(base)
|
|
292
|
+
|
|
293
|
+
# Check for function call after superscript: M^{-1}(r)
|
|
294
|
+
if self.at("LPAREN"):
|
|
295
|
+
self.advance()
|
|
296
|
+
args = []
|
|
297
|
+
if not self.at("RPAREN"):
|
|
298
|
+
args.append(self.parse_expr())
|
|
299
|
+
while self.at("COMMA"):
|
|
300
|
+
self.advance()
|
|
301
|
+
args.append(self.parse_expr())
|
|
302
|
+
self.expect("RPAREN")
|
|
303
|
+
return FuncCall(func=base, args=args)
|
|
304
|
+
|
|
305
|
+
return base
|
|
306
|
+
|
|
307
|
+
def parse_superscript(self, base: Expr) -> Expr:
|
|
308
|
+
"""
|
|
309
|
+
superscript after ^:
|
|
310
|
+
^{\\top} → transpose
|
|
311
|
+
^{-1} → inverse (for callable context)
|
|
312
|
+
^{T} → transpose
|
|
313
|
+
^{expr} → power
|
|
314
|
+
^\\top → transpose (no braces)
|
|
315
|
+
"""
|
|
316
|
+
if self.at("TOP"):
|
|
317
|
+
self.advance()
|
|
318
|
+
return UnaryOp(op="transpose", operand=base)
|
|
319
|
+
|
|
320
|
+
if self.at("LBRACE"):
|
|
321
|
+
self.advance()
|
|
322
|
+
|
|
323
|
+
# Check for ^\top inside braces
|
|
324
|
+
if self.at("TOP"):
|
|
325
|
+
self.advance()
|
|
326
|
+
self.expect("RBRACE")
|
|
327
|
+
return UnaryOp(op="transpose", operand=base)
|
|
328
|
+
|
|
329
|
+
# Check for ^{T}
|
|
330
|
+
tok = self.peek()
|
|
331
|
+
if tok is not None and tok.kind == "LETTER" and tok.value == "T":
|
|
332
|
+
saved_pos = self.pos
|
|
333
|
+
self.advance()
|
|
334
|
+
if self.at("RBRACE"):
|
|
335
|
+
self.advance()
|
|
336
|
+
return UnaryOp(op="transpose", operand=base)
|
|
337
|
+
else:
|
|
338
|
+
self.pos = saved_pos
|
|
339
|
+
|
|
340
|
+
# Check for ^{-1} (inverse)
|
|
341
|
+
if self.at("MINUS"):
|
|
342
|
+
saved_pos = self.pos
|
|
343
|
+
self.advance()
|
|
344
|
+
num_tok = self.peek()
|
|
345
|
+
if num_tok is not None and num_tok.kind == "NUMBER" and num_tok.value == "1":
|
|
346
|
+
self.advance()
|
|
347
|
+
self.expect("RBRACE")
|
|
348
|
+
return UnaryOp(op="inverse", operand=base)
|
|
349
|
+
self.pos = saved_pos
|
|
350
|
+
|
|
351
|
+
# General exponent
|
|
352
|
+
exp = self.parse_expr()
|
|
353
|
+
self.expect("RBRACE")
|
|
354
|
+
return BinOp(op="pow", left=base, right=exp)
|
|
355
|
+
|
|
356
|
+
# Bare superscript: single token
|
|
357
|
+
if self.at("TOP"):
|
|
358
|
+
self.advance()
|
|
359
|
+
return UnaryOp(op="transpose", operand=base)
|
|
360
|
+
|
|
361
|
+
# Bare ^T transpose alias: a lone uppercase T after ^ means transpose
|
|
362
|
+
# in linear-algebra notation, same as ^\top and ^{T}.
|
|
363
|
+
tok = self.peek()
|
|
364
|
+
if tok is not None and tok.kind == "LETTER" and tok.value == "T":
|
|
365
|
+
self.advance()
|
|
366
|
+
return UnaryOp(op="transpose", operand=base)
|
|
367
|
+
|
|
368
|
+
exp = self.parse_atom()
|
|
369
|
+
return BinOp(op="pow", left=base, right=exp)
|
|
370
|
+
|
|
371
|
+
def parse_base(self) -> Expr:
|
|
372
|
+
"""
|
|
373
|
+
base := '(' expr ')'
|
|
374
|
+
| '\\frac{num}{den}'
|
|
375
|
+
| '\\sqrt{expr}'
|
|
376
|
+
| norm_expr
|
|
377
|
+
| func_call (detected by atom followed by '(')
|
|
378
|
+
| atom
|
|
379
|
+
"""
|
|
380
|
+
if self.at("LPAREN"):
|
|
381
|
+
self.advance()
|
|
382
|
+
expr = self.parse_expr()
|
|
383
|
+
self.expect("RPAREN")
|
|
384
|
+
return expr
|
|
385
|
+
|
|
386
|
+
if self.at("FRAC"):
|
|
387
|
+
return self.parse_frac()
|
|
388
|
+
|
|
389
|
+
if self.at("SQRT"):
|
|
390
|
+
return self.parse_sqrt()
|
|
391
|
+
|
|
392
|
+
if self.at("NORMPIPE", "LNORM", "PIPE"):
|
|
393
|
+
norm = self.parse_norm()
|
|
394
|
+
# A norm may carry an order subscript: ||r||_2, ||r||_1, ||r||_\infty.
|
|
395
|
+
# The generated _norm kernel computes the Euclidean (2-)norm, so the
|
|
396
|
+
# default and ``_2`` are fine; any other order must fail loud rather
|
|
397
|
+
# than silently compute the wrong norm.
|
|
398
|
+
if self.at("UNDERSCORE"):
|
|
399
|
+
self.advance()
|
|
400
|
+
order = self._parse_subscript_text()
|
|
401
|
+
if order not in ("", "2"):
|
|
402
|
+
raise UnsupportedConstructError(
|
|
403
|
+
f"only the Euclidean 2-norm is supported by algo2code; "
|
|
404
|
+
f"got a norm of order {order!r}. Other norm orders would "
|
|
405
|
+
f"need a dedicated kernel."
|
|
406
|
+
)
|
|
407
|
+
return norm
|
|
408
|
+
|
|
409
|
+
atom = self.parse_atom()
|
|
410
|
+
|
|
411
|
+
if self.at("LPAREN"):
|
|
412
|
+
self.advance()
|
|
413
|
+
args = []
|
|
414
|
+
if not self.at("RPAREN"):
|
|
415
|
+
args.append(self.parse_expr())
|
|
416
|
+
while self.at("COMMA"):
|
|
417
|
+
self.advance()
|
|
418
|
+
args.append(self.parse_expr())
|
|
419
|
+
self.expect("RPAREN")
|
|
420
|
+
return FuncCall(func=atom, args=args)
|
|
421
|
+
|
|
422
|
+
return atom
|
|
423
|
+
|
|
424
|
+
def parse_frac(self) -> Expr:
|
|
425
|
+
"""\\frac{numerator}{denominator}"""
|
|
426
|
+
self.expect("FRAC")
|
|
427
|
+
self.expect("LBRACE")
|
|
428
|
+
num = self.parse_expr()
|
|
429
|
+
self.expect("RBRACE")
|
|
430
|
+
self.expect("LBRACE")
|
|
431
|
+
den = self.parse_expr()
|
|
432
|
+
self.expect("RBRACE")
|
|
433
|
+
return BinOp(op="/", left=num, right=den)
|
|
434
|
+
|
|
435
|
+
def parse_sqrt(self) -> Expr:
|
|
436
|
+
"""\\sqrt{expr}"""
|
|
437
|
+
self.expect("SQRT")
|
|
438
|
+
self.expect("LBRACE")
|
|
439
|
+
inner = self.parse_expr()
|
|
440
|
+
self.expect("RBRACE")
|
|
441
|
+
return FuncCall(func=Var(name="sqrt"), args=[inner])
|
|
442
|
+
|
|
443
|
+
def parse_norm(self) -> Expr:
|
|
444
|
+
"""\\| expr \\| or \\lVert expr \\rVert or | expr |
|
|
445
|
+
|
|
446
|
+
Bare ``|expr|`` is absolute value / norm; the closing delimiter matches
|
|
447
|
+
the opener. Codegen lowers a scalar operand to ``abs(...)`` and a vector
|
|
448
|
+
operand to the ``_norm`` kernel.
|
|
449
|
+
"""
|
|
450
|
+
start = self.advance() # consume NORMPIPE, LNORM, or PIPE
|
|
451
|
+
inner = self.parse_expr()
|
|
452
|
+
if start.kind == "LNORM":
|
|
453
|
+
self.expect("RNORM")
|
|
454
|
+
elif start.kind == "PIPE":
|
|
455
|
+
self.expect("PIPE")
|
|
456
|
+
else:
|
|
457
|
+
self.expect("NORMPIPE")
|
|
458
|
+
return UnaryOp(op="norm", operand=inner)
|
|
459
|
+
|
|
460
|
+
def parse_atom(self) -> Expr:
|
|
461
|
+
"""atom := NUMBER | LETTER | GREEK | STYLED"""
|
|
462
|
+
tok = self.peek()
|
|
463
|
+
if tok is None:
|
|
464
|
+
raise SyntaxError("Unexpected end of expression")
|
|
465
|
+
|
|
466
|
+
if tok.kind == "NUMBER":
|
|
467
|
+
self.advance()
|
|
468
|
+
return Number(value=float(tok.value))
|
|
469
|
+
|
|
470
|
+
if tok.kind in ("LETTER", "GREEK", "STYLED", "OPNAME"):
|
|
471
|
+
self.advance()
|
|
472
|
+
name = tok.value
|
|
473
|
+
subscript = None
|
|
474
|
+
if self.at("UNDERSCORE"):
|
|
475
|
+
self.advance()
|
|
476
|
+
subscript = self._parse_subscript_text()
|
|
477
|
+
return Var(name=name, subscript=subscript)
|
|
478
|
+
|
|
479
|
+
if tok.kind == "DOTS":
|
|
480
|
+
self.advance()
|
|
481
|
+
return Var(name="...")
|
|
482
|
+
|
|
483
|
+
raise SyntaxError(f"Unexpected token {tok} at position {tok.pos}")
|
|
484
|
+
|
|
485
|
+
def _parse_subscript_text(self) -> str:
|
|
486
|
+
"""Parse the text after _ : either {content} or single token."""
|
|
487
|
+
if self.at("LBRACE"):
|
|
488
|
+
self.advance()
|
|
489
|
+
parts: list[str] = []
|
|
490
|
+
depth = 1
|
|
491
|
+
while depth > 0:
|
|
492
|
+
tok = self.advance()
|
|
493
|
+
if tok.kind == "LBRACE":
|
|
494
|
+
depth += 1
|
|
495
|
+
elif tok.kind == "RBRACE":
|
|
496
|
+
depth -= 1
|
|
497
|
+
if depth == 0:
|
|
498
|
+
break
|
|
499
|
+
if tok.kind == "STYLED" or tok.kind in ("LETTER", "GREEK", "NUMBER"):
|
|
500
|
+
parts.append(tok.value)
|
|
501
|
+
elif tok.kind == "PLUS":
|
|
502
|
+
parts.append("+")
|
|
503
|
+
elif tok.kind == "MINUS":
|
|
504
|
+
parts.append("-")
|
|
505
|
+
return "".join(parts)
|
|
506
|
+
else:
|
|
507
|
+
tok = self.advance()
|
|
508
|
+
return tok.value
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ── Public API ───────────────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def parse_latex_expr(latex: str) -> Expr:
|
|
515
|
+
"""Parse a LaTeX math expression string into an AST."""
|
|
516
|
+
tokens = tokenize(latex)
|
|
517
|
+
if not tokens:
|
|
518
|
+
raise SyntaxError(f"Empty expression: {latex!r}")
|
|
519
|
+
parser = ExprParser(tokens)
|
|
520
|
+
result = parser.parse()
|
|
521
|
+
# Fail-loud: the parser must consume the whole expression. A leftover
|
|
522
|
+
# token means a prefix parsed and the rest was silently dropped (e.g. an
|
|
523
|
+
# unhandled operator). Surface it instead of emitting a truncated AST.
|
|
524
|
+
if parser.pos != len(tokens):
|
|
525
|
+
leftover = tokens[parser.pos]
|
|
526
|
+
raise UnsupportedConstructError(
|
|
527
|
+
f"could not fully parse {latex!r}: unexpected {leftover.kind} "
|
|
528
|
+
f"token {leftover.value!r} at position {leftover.pos}. "
|
|
529
|
+
f"{parser.pos} of {len(tokens)} tokens consumed."
|
|
530
|
+
)
|
|
531
|
+
return result
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def parse_assignment(latex: str) -> tuple[Var, Expr] | None:
|
|
535
|
+
"""
|
|
536
|
+
Parse 'lhs = rhs' from a LaTeX string.
|
|
537
|
+
Returns (target_var, rhs_expr) or None if not an assignment.
|
|
538
|
+
"""
|
|
539
|
+
depth = 0
|
|
540
|
+
eq_pos = -1
|
|
541
|
+
for i, ch in enumerate(latex):
|
|
542
|
+
if ch == "{":
|
|
543
|
+
depth += 1
|
|
544
|
+
elif ch == "}":
|
|
545
|
+
depth -= 1
|
|
546
|
+
elif ch == "=" and depth == 0:
|
|
547
|
+
eq_pos = i
|
|
548
|
+
break
|
|
549
|
+
|
|
550
|
+
if eq_pos < 0:
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
lhs_str = latex[:eq_pos].strip()
|
|
554
|
+
rhs_str = latex[eq_pos + 1 :].strip()
|
|
555
|
+
|
|
556
|
+
lhs = parse_latex_expr(lhs_str)
|
|
557
|
+
rhs = parse_latex_expr(rhs_str)
|
|
558
|
+
|
|
559
|
+
if not isinstance(lhs, Var):
|
|
560
|
+
raise SyntaxError(f"LHS of assignment must be a variable, got {type(lhs)}: {lhs_str}")
|
|
561
|
+
|
|
562
|
+
return (lhs, rhs)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def parse_condition(latex: str) -> Expr:
|
|
566
|
+
"""
|
|
567
|
+
Parse a condition expression: \\|r\\| < \\varepsilon, etc.
|
|
568
|
+
Returns a BinOp with op='<', '>', '<=', '>=', '=='.
|
|
569
|
+
"""
|
|
570
|
+
depth = 0
|
|
571
|
+
for i, ch in enumerate(latex):
|
|
572
|
+
if ch == "{":
|
|
573
|
+
depth += 1
|
|
574
|
+
elif ch == "}":
|
|
575
|
+
depth -= 1
|
|
576
|
+
elif depth == 0 and ch in "<>":
|
|
577
|
+
lhs_str = latex[:i].strip()
|
|
578
|
+
rhs_str = latex[i + 1 :].strip()
|
|
579
|
+
op = ch
|
|
580
|
+
if rhs_str.startswith("="):
|
|
581
|
+
op += "="
|
|
582
|
+
rhs_str = rhs_str[1:].strip()
|
|
583
|
+
|
|
584
|
+
lhs = parse_latex_expr(lhs_str)
|
|
585
|
+
rhs = parse_latex_expr(rhs_str)
|
|
586
|
+
return BinOp(op=op, left=lhs, right=rhs)
|
|
587
|
+
elif depth == 0 and ch == "=":
|
|
588
|
+
# Equality test in a condition, e.g. \If{$r_0 = 0$}. In LaTeX a
|
|
589
|
+
# bare ``=`` inside a condition is equality, not assignment, so it
|
|
590
|
+
# lowers to Python ``==``. (``<=``/``>=`` are caught above because
|
|
591
|
+
# the ``<``/``>`` is reached first.)
|
|
592
|
+
lhs_str = latex[:i].strip()
|
|
593
|
+
rhs_str = latex[i + 1 :].strip()
|
|
594
|
+
lhs = parse_latex_expr(lhs_str)
|
|
595
|
+
rhs = parse_latex_expr(rhs_str)
|
|
596
|
+
return BinOp(op="==", left=lhs, right=rhs)
|
|
597
|
+
|
|
598
|
+
return parse_latex_expr(latex)
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Curated library of canonical algorithm sources shipped with ``algo2code``.
|
|
2
|
+
|
|
3
|
+
Each module in this subpackage exposes a single LaTeX algorithm text plus a
|
|
4
|
+
small helper that returns it (and, where the parser supports it, the parsed
|
|
5
|
+
:class:`~algo2code.ast_nodes.Algorithm`). Downstream packages import from
|
|
6
|
+
here instead of re-typing or re-inventing the algorithm text — this is the
|
|
7
|
+
canonical hand-off seam between ``algo2code`` and any consumer that wants a
|
|
8
|
+
reference implementation of a textbook iterative solver.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from algo2code.library.pcg import (
|
|
12
|
+
PCG_ALGORITHM_LATEX,
|
|
13
|
+
get_pcg_algorithm_latex,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"PCG_ALGORITHM_LATEX",
|
|
18
|
+
"get_pcg_algorithm_latex",
|
|
19
|
+
]
|