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/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
+ """
@@ -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
+ ]