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.
@@ -0,0 +1,859 @@
1
+ """
2
+ Taichi code generator.
3
+
4
+ Takes a typed Algorithm AST and emits:
5
+ 1. Taichi kernel functions for vector/matrix operations (matvec, dot, axpy, norm)
6
+ 2. A Python driver function that calls the kernels in the right order
7
+
8
+ Design decisions:
9
+ - Vector/matrix variables are allocated as Taichi fields inside the driver
10
+ - The loop driver stays in Python scope (Taichi doesn't support
11
+ dynamic convergence checks inside kernels well)
12
+ - Each heavy linear-algebra op gets its own @ti.kernel
13
+ - Scalar arithmetic stays in the driver
14
+ - Vector assignment (p = z) emits _copy(z, p), not Python reference aliasing
15
+
16
+ Import modes (``runtime`` parameter of :func:`generate_taichi`):
17
+ - ``"inline"`` (default): emit private ``@ti.kernel`` definitions inline in
18
+ each generated file — backward-compatible, no runtime dependency.
19
+ - ``"ti_runtime"``: emit ``from ti_runtime import vector_ops as _v`` and call
20
+ the shared primitives for ``dot``/``norm``/``copy``/``vec_add``; only
21
+ ``_matvec`` remains inlined (the matrix-free operator seam is P2-2's
22
+ concern). algo2code itself never imports ``ti_runtime`` — it only *emits*
23
+ the import line.
24
+
25
+ Compatibility note (``"ti_runtime"`` mode):
26
+ ``ti_runtime.vector_ops.dot`` uses ``x[I].dot(y[I])`` which requires
27
+ ``ti.Vector.field`` arguments. algo2code emits ``ti.field(ti.f64, shape=n)``
28
+ scalar fields, so ``_v.dot``/``_v.norm2`` will raise at Taichi JIT time if
29
+ the consumer passes scalar fields. Callers that use vector fields (e.g.
30
+ mechdsl FEM drivers) are unaffected. This is a known field-model mismatch;
31
+ resolving it (either by changing the emitted field model to ``ti.Vector.field``
32
+ or by adding scalar variants to ti_runtime) is out of scope for P2-1.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import contextlib
38
+
39
+ from ..ast_nodes import (
40
+ Algorithm,
41
+ Assign,
42
+ BinOp,
43
+ Branch,
44
+ Break,
45
+ Expr,
46
+ ForLoop,
47
+ FuncCall,
48
+ Number,
49
+ Return,
50
+ Stmt,
51
+ UnaryOp,
52
+ Var,
53
+ VarType,
54
+ WhileLoop,
55
+ )
56
+ from ..errors import UnsupportedConstructError
57
+
58
+ # Literal sentinel values for the ``runtime`` parameter.
59
+ RUNTIME_INLINE = "inline"
60
+ RUNTIME_TI_RUNTIME = "ti_runtime"
61
+
62
+ # ── Variable name sanitization ───────────────────────────────────────────────
63
+
64
+ _GREEK_MAP = {
65
+ "alpha": "alpha",
66
+ "beta": "beta",
67
+ "gamma": "gamma",
68
+ "delta": "delta",
69
+ "epsilon": "eps",
70
+ "varepsilon": "eps",
71
+ "zeta": "zeta",
72
+ "eta": "eta",
73
+ "theta": "theta",
74
+ "iota": "iota",
75
+ "kappa": "kappa",
76
+ "lambda": "lam",
77
+ "mu": "mu",
78
+ "nu": "nu",
79
+ "xi": "xi",
80
+ "pi": "pi_val",
81
+ "rho": "rho",
82
+ "sigma": "sigma",
83
+ "tau": "tau",
84
+ "phi": "phi",
85
+ "varphi": "phi",
86
+ "chi": "chi",
87
+ "psi": "psi",
88
+ "omega": "omega",
89
+ }
90
+
91
+
92
+ def _sanitize(name: str) -> str:
93
+ """Convert a LaTeX variable name to a valid Python identifier."""
94
+ if name in _GREEK_MAP:
95
+ return _GREEK_MAP[name]
96
+ name = name.replace("{", "").replace("}", "").replace("\\", "")
97
+ name = name.replace(" ", "_").replace("-", "_")
98
+ if name in ("lambda", "in", "is", "not", "and", "or", "from", "import"):
99
+ name = name + "_"
100
+ return name
101
+
102
+
103
+ def _var_name(v: Var) -> str:
104
+ """Get the Python name for a Var node."""
105
+ base = _sanitize(v.name)
106
+ if v.subscript:
107
+ sub = _sanitize(v.subscript)
108
+ return f"{base}_{sub}"
109
+ return base
110
+
111
+
112
+ # ── Kernel collector ─────────────────────────────────────────────────────────
113
+
114
+
115
+ class KernelCollector:
116
+ """Walk the AST and identify which standard kernels are needed."""
117
+
118
+ def __init__(self):
119
+ self.needed_kernels: set[str] = set()
120
+
121
+ def scan(self, stmts: list[Stmt]):
122
+ for stmt in stmts:
123
+ self._scan_stmt(stmt)
124
+
125
+ def _scan_stmt(self, stmt: Stmt):
126
+ if isinstance(stmt, Assign):
127
+ self._scan_expr(stmt.value)
128
+ if (
129
+ stmt.target.inferred_type == VarType.VECTOR
130
+ and isinstance(stmt.value, Var)
131
+ and stmt.value.inferred_type == VarType.VECTOR
132
+ ):
133
+ self.needed_kernels.add("copy")
134
+ elif isinstance(stmt, ForLoop):
135
+ self.scan(stmt.body)
136
+ elif isinstance(stmt, WhileLoop):
137
+ self._scan_expr(stmt.condition)
138
+ self.scan(stmt.body)
139
+ elif isinstance(stmt, Branch):
140
+ self._scan_expr(stmt.condition)
141
+ self.scan(stmt.if_body)
142
+ for cond, body in stmt.elif_branches:
143
+ self._scan_expr(cond)
144
+ self.scan(body)
145
+ self.scan(stmt.else_body)
146
+ elif isinstance(stmt, Return):
147
+ for v in stmt.values:
148
+ self._scan_expr(v)
149
+
150
+ def _scan_expr(self, expr: Expr):
151
+ if isinstance(expr, BinOp):
152
+ self._scan_expr(expr.left)
153
+ self._scan_expr(expr.right)
154
+ if expr.op == "dot":
155
+ self.needed_kernels.add("dot")
156
+ elif expr.op == "matvec":
157
+ # A matrix-free callable operator (`% type A callable`, §8.3)
158
+ # lowers `A · p` to an in-place call A(out, p); it needs no
159
+ # dense `_matvec` kernel. Only a stored MATRIX operand does.
160
+ if expr.left.inferred_type != VarType.CALLABLE:
161
+ self.needed_kernels.add("matvec")
162
+ elif expr.op == "scale" or (
163
+ expr.op in ("+", "-") and expr.inferred_type == VarType.VECTOR
164
+ ):
165
+ self.needed_kernels.add("vec_add")
166
+ elif isinstance(expr, UnaryOp):
167
+ self._scan_expr(expr.operand)
168
+ if expr.op == "norm":
169
+ self.needed_kernels.add("norm")
170
+ elif isinstance(expr, FuncCall):
171
+ for arg in expr.args:
172
+ self._scan_expr(arg)
173
+
174
+ def emit_kernels(self, runtime: str = RUNTIME_INLINE) -> str:
175
+ """Emit kernel definitions (inline mode) or nothing (ti_runtime mode).
176
+
177
+ In ``"ti_runtime"`` mode the shared primitives (dot/norm/copy/vec_add)
178
+ are called via the ``_v`` alias imported from ``ti_runtime.vector_ops``.
179
+ Only ``_matvec`` has no ti_runtime equivalent and remains inlined
180
+ (the matrix-free operator seam is P2-2's concern).
181
+ """
182
+ lines = []
183
+ if runtime == RUNTIME_TI_RUNTIME:
184
+ # dot/norm/copy/vec_add are routed to ti_runtime; only emit _matvec.
185
+ if "matvec" in self.needed_kernels:
186
+ lines.append(_K_MATVEC)
187
+ else:
188
+ if "dot" in self.needed_kernels:
189
+ lines.append(_K_DOT)
190
+ if "norm" in self.needed_kernels:
191
+ lines.append(_K_NORM)
192
+ if "matvec" in self.needed_kernels:
193
+ lines.append(_K_MATVEC)
194
+ if "vec_add" in self.needed_kernels:
195
+ lines.append(_K_VEC_ADD)
196
+ if "copy" in self.needed_kernels:
197
+ lines.append(_K_COPY)
198
+ return "\n\n".join(lines)
199
+
200
+
201
+ # ── Kernel templates ─────────────────────────────────────────────────────────
202
+
203
+ _K_DOT = """@ti.kernel
204
+ def _dot(a: ti.template(), b: ti.template()) -> ti.f64:
205
+ result = 0.0
206
+ for i in a:
207
+ result += a[i] * b[i]
208
+ return result"""
209
+
210
+ _K_NORM = """@ti.kernel
211
+ def _norm(a: ti.template()) -> ti.f64:
212
+ result = 0.0
213
+ for i in a:
214
+ result += a[i] * a[i]
215
+ return ti.sqrt(result)"""
216
+
217
+ _K_MATVEC = """@ti.kernel
218
+ def _matvec(A: ti.template(), x: ti.template(), out: ti.template()):
219
+ for i in out:
220
+ s = 0.0
221
+ for j in range(x.shape[0]):
222
+ s += A[i, j] * x[j]
223
+ out[i] = s"""
224
+
225
+ _K_VEC_ADD = '''@ti.kernel
226
+ def _vec_add(alpha: ti.f64, x: ti.template(), beta: ti.f64,
227
+ y: ti.template(), out: ti.template()):
228
+ """out[i] = alpha*x[i] + beta*y[i]"""
229
+ for i in out:
230
+ out[i] = alpha * x[i] + beta * y[i]'''
231
+
232
+ _K_COPY = """@ti.kernel
233
+ def _copy(src: ti.template(), dst: ti.template()):
234
+ for i in dst:
235
+ dst[i] = src[i]"""
236
+
237
+
238
+ # ── Variable scanner ─────────────────────────────────────────────────────────
239
+
240
+
241
+ def _collect_vector_vars(algo: Algorithm) -> set[str]:
242
+ """Find all vector-typed variable names that need field allocation."""
243
+ vecs = set()
244
+ for name, vtype in algo.type_annotations.items():
245
+ if vtype == VarType.VECTOR:
246
+ vecs.add(_sanitize(name))
247
+ return vecs
248
+
249
+
250
+ def _collect_arg_names(algo: Algorithm) -> set[str]:
251
+ return {name for name, _ in algo.args}
252
+
253
+
254
+ # ── Code emitter ─────────────────────────────────────────────────────────────
255
+
256
+
257
+ class TaichiEmitter:
258
+ """Emit complete Taichi code from a typed Algorithm AST.
259
+
260
+ Parameters
261
+ ----------
262
+ algo:
263
+ Typed Algorithm AST to emit.
264
+ runtime:
265
+ Import mode — ``"inline"`` (default) or ``"ti_runtime"``. See module
266
+ docstring for the full description of each mode.
267
+ """
268
+
269
+ def __init__(self, algo: Algorithm, runtime: str = RUNTIME_INLINE):
270
+ self.algo = algo
271
+ self.runtime = runtime
272
+ self.lines: list[str] = []
273
+ self._indent = 0
274
+ self._temp_counter = 0
275
+ self._needed_temp_count = 0
276
+ self._peak_temps = 0
277
+ self._arg_names: set[str] = set()
278
+ self._vector_vars: set[str] = set()
279
+
280
+ def emit(self) -> str:
281
+ """Generate the complete Taichi source."""
282
+ self._arg_names = _collect_arg_names(self.algo)
283
+ self._vector_vars = _collect_vector_vars(self.algo)
284
+
285
+ collector = KernelCollector()
286
+ collector.scan(self.algo.body)
287
+
288
+ self._pre_scan_temps(self.algo.body)
289
+
290
+ if self.runtime == RUNTIME_TI_RUNTIME:
291
+ self._check_runtime_mode_supported(collector)
292
+
293
+ parts = []
294
+ parts.append("import taichi as ti\n")
295
+ parts.append("ti.init(arch=ti.gpu, default_fp=ti.f64)\n")
296
+
297
+ if self.runtime == RUNTIME_TI_RUNTIME:
298
+ parts.append("from ti_runtime import vector_ops as _v\n")
299
+
300
+ kernels = collector.emit_kernels(runtime=self.runtime)
301
+ if kernels:
302
+ parts.append("# ── Taichi kernels " + "─" * 56)
303
+ parts.append(kernels)
304
+ parts.append("")
305
+
306
+ parts.append("# ── Solver driver " + "─" * 57)
307
+ parts.append(self._emit_driver())
308
+
309
+ return "\n\n".join(parts) + "\n"
310
+
311
+ def _pre_scan_temps(self, stmts: list[Stmt]):
312
+ """Discover how many temporary vector fields the driver needs.
313
+
314
+ Rather than re-deriving the count from a parallel traversal (which drifts
315
+ from the real lowering — the F1 root cause was exactly such a mismatch),
316
+ we *dry-run* the actual body emission into a throwaway buffer. Every
317
+ ``_get_temp`` call updates ``_peak_temps``; the temp counter resets per
318
+ statement (temps are scratch within one statement and reused across
319
+ statements), so the peak is the number of fields to allocate once.
320
+ ``_emit_driver`` overwrites ``lines``/``indent``/``temp_counter`` for the
321
+ real pass, so no save/restore is needed here.
322
+ """
323
+ self.lines = []
324
+ self._indent = 0
325
+ self._temp_counter = 0
326
+ self._peak_temps = 0
327
+ # A genuinely un-lowerable construct will raise again — and be reported —
328
+ # during the real emission pass, so don't let the dry run fail here.
329
+ with contextlib.suppress(UnsupportedConstructError):
330
+ self._emit_block(stmts)
331
+ self._needed_temp_count = self._peak_temps
332
+
333
+ def _emit_driver(self) -> str:
334
+ self.lines = []
335
+ self._indent = 0
336
+ self._temp_counter = 0
337
+
338
+ args = self._build_arg_list()
339
+ self._write(f"def {self.algo.name}({args}):")
340
+ self._indent += 1
341
+
342
+ self._write('"""')
343
+ self._write("Auto-generated from LaTeX algorithmic environment.")
344
+ self._write("Backend: Taichi (GPU)")
345
+ self._write('"""')
346
+
347
+ vec_arg = self._first_vector_arg()
348
+ local_vecs = self._vector_vars - self._arg_names
349
+ requires_vector_length = bool(local_vecs) or self._needed_temp_count > 0
350
+ needs_n = requires_vector_length or vec_arg is not None
351
+ if needs_n:
352
+ if vec_arg:
353
+ self._write(f"n = {vec_arg}.shape[0]")
354
+ elif requires_vector_length:
355
+ self._write(
356
+ 'raise ValueError("Cannot infer vector length n for this generated Taichi driver: '
357
+ "the algorithm allocates local vectors or temporary vector fields, but no vector "
358
+ 'argument is available to determine n.")'
359
+ )
360
+ else:
361
+ # Emitting ``n = b.shape[0]`` unconditionally would break scalar/matrix-only
362
+ # algorithms with no vector argument; keep a harmless marker only when no
363
+ # vector-sized allocation depends on n.
364
+ self._write("n = 0 # no vector arg present; scalar/matrix-only algorithm")
365
+
366
+ if local_vecs or self._needed_temp_count > 0:
367
+ self._write("")
368
+ self._write("# Allocate working vectors")
369
+ alloc = self._vector_field_alloc(vec_arg)
370
+ for name in sorted(local_vecs):
371
+ self._write(f"{name} = {alloc}")
372
+ for i in range(self._needed_temp_count):
373
+ self._write(f"_tmp{i} = {alloc}")
374
+
375
+ self._write("")
376
+
377
+ self._emit_block(self.algo.body)
378
+
379
+ self._indent -= 1
380
+ return "\n".join(self.lines)
381
+
382
+ def _first_vector_arg(self) -> str | None:
383
+ for name, vtype in self.algo.args:
384
+ if vtype == VarType.VECTOR:
385
+ return name
386
+ return None
387
+
388
+ def _vector_field_alloc(self, vec_arg: str | None) -> str:
389
+ """Return the field-allocation expression for a local/temp working vector.
390
+
391
+ Inline mode keeps the historical scalar layout ``ti.field(ti.f64,
392
+ shape=n)`` — byte-stable, so existing consumers and goldens are
393
+ unaffected. ``ti_runtime`` mode instead allocates ``ti.Vector.field``
394
+ locals matching the *vector argument's* component layout (``<arg>.n``):
395
+ the ``ti_runtime`` ``dot``/``norm2`` primitives use ``x[I].dot(y[I])``,
396
+ which requires vector fields. This is what lets the matrix-free seam
397
+ (P2-2) run end-to-end against ``ti_runtime`` (resolving the field-model
398
+ mismatch P2-1 documented). With no vector argument the component count
399
+ is unknown, so fall back to the scalar layout.
400
+ """
401
+ if self.runtime == RUNTIME_TI_RUNTIME and vec_arg is not None:
402
+ return f"ti.Vector.field({vec_arg}.n, ti.f64, shape=n)"
403
+ return "ti.field(ti.f64, shape=n)"
404
+
405
+ def _check_runtime_mode_supported(self, collector: KernelCollector) -> None:
406
+ """Fail loud on ``ti_runtime``-mode inputs that would emit non-runnable code.
407
+
408
+ Runtime mode targets the matrix-free seam over ``ti.Vector.field`` DOF
409
+ vectors, so two preconditions must hold (otherwise the emitter would
410
+ silently produce code that crashes at Taichi JIT):
411
+
412
+ * **callable operator** — a stored ``MATRIX`` operand lowers to a dense,
413
+ scalar-indexed ``_matvec``, incompatible with the ``ti.Vector.field``
414
+ locals and the ``ti_runtime`` ``dot``/``norm2`` reductions
415
+ (``x[I].dot(y[I])``). Declare the operator ``callable``
416
+ (e.g. ``% type A callable``) to use the in-place ``A(out, x)`` seam.
417
+ * **a vector argument** — needed to size the ``ti.Vector.field`` locals
418
+ (``<arg>.n``). Without one the locals fall back to the scalar layout,
419
+ on which ``_v.dot``/``_v.norm2`` cannot run.
420
+
421
+ Both are rejected here, before emission, with the specific reason.
422
+ """
423
+ if "matvec" in collector.needed_kernels:
424
+ raise UnsupportedConstructError(
425
+ "runtime='ti_runtime' requires a matrix-free callable operator: "
426
+ "declare the system operator callable (e.g. `% type A callable`). "
427
+ "A matrix-typed operator lowers to a dense scalar-indexed matvec, "
428
+ "incompatible with the ti.Vector.field layout used in runtime mode."
429
+ )
430
+ uses_vectors = (
431
+ bool(self._vector_vars - self._arg_names)
432
+ or self._needed_temp_count > 0
433
+ or bool({"dot", "copy", "vec_add"} & collector.needed_kernels)
434
+ )
435
+ if uses_vectors and self._first_vector_arg() is None:
436
+ raise UnsupportedConstructError(
437
+ "runtime='ti_runtime' needs a vector argument to size its "
438
+ "ti.Vector.field working vectors (`<arg>.n`); none was declared."
439
+ )
440
+
441
+ def _build_arg_list(self) -> str:
442
+ if self.algo.args:
443
+ return ", ".join(name for name, _ in self.algo.args)
444
+ return "A, b, x, M_inv=None, tol=1e-10, maxiter=1000"
445
+
446
+ def _write(self, text: str):
447
+ self.lines.append(" " * self._indent + text)
448
+
449
+ def _emit_block(self, stmts: list[Stmt]):
450
+ for stmt in stmts:
451
+ self._emit_stmt(stmt)
452
+
453
+ def _emit_stmt(self, stmt: Stmt):
454
+ # Temps are scratch within a single statement; reset so they are reused
455
+ # across statements (only the per-statement peak is allocated).
456
+ self._temp_counter = 0
457
+ if isinstance(stmt, Assign):
458
+ self._emit_assign(stmt)
459
+ elif isinstance(stmt, ForLoop):
460
+ self._emit_for(stmt)
461
+ elif isinstance(stmt, WhileLoop):
462
+ self._emit_while(stmt)
463
+ elif isinstance(stmt, Branch):
464
+ self._emit_branch(stmt)
465
+ elif isinstance(stmt, Return):
466
+ self._emit_return(stmt)
467
+ elif isinstance(stmt, Break):
468
+ self._write("break")
469
+
470
+ # ── Assignment ───────────────────────────────────────────────────────
471
+
472
+ def _emit_assign(self, stmt: Assign):
473
+ target = _var_name(stmt.target)
474
+ target_type = self.algo.type_annotations.get(stmt.target.display, stmt.target.inferred_type)
475
+
476
+ # Vector ← simple vector: copy
477
+ if (
478
+ target_type == VarType.VECTOR
479
+ and isinstance(stmt.value, Var)
480
+ and stmt.value.inferred_type == VarType.VECTOR
481
+ ):
482
+ src = _var_name(stmt.value)
483
+ if self.runtime == RUNTIME_TI_RUNTIME:
484
+ # ti_runtime.vector_ops.copy(dst, src) — reversed arg order
485
+ self._write(f"_v.copy({target}, {src})")
486
+ else:
487
+ self._write(f"_copy({src}, {target})")
488
+ return
489
+
490
+ # Vector ← callable(args): in-place e.g. M_inv(r, z) not z = M_inv(r)
491
+ if target_type == VarType.VECTOR and isinstance(stmt.value, FuncCall):
492
+ func_code = self._emit_func_call_name(stmt.value)
493
+ args = ", ".join(self._emit_expr_str(a) for a in stmt.value.args)
494
+ self._write(f"{func_code}({args}, {target})")
495
+ return
496
+
497
+ value_code = self._emit_expr(stmt.value, target_var=target)
498
+ if value_code is not None:
499
+ self._write(f"{target} = {value_code}")
500
+
501
+ # ── Expression emission ──────────────────────────────────────────────
502
+
503
+ def _emit_expr_str(self, expr: Expr) -> str:
504
+ """Like _emit_expr but asserts a value is returned (no target_var side-effect path)."""
505
+ result = self._emit_expr(expr)
506
+ assert result is not None, f"expression emitted no value: {expr!r}"
507
+ return result
508
+
509
+ def _emit_expr(self, expr: Expr, target_var: str | None = None) -> str | None:
510
+ if isinstance(expr, Number):
511
+ v = expr.value
512
+ return str(int(v)) if v == int(v) else str(v)
513
+ if isinstance(expr, Var):
514
+ return _var_name(expr)
515
+ if isinstance(expr, UnaryOp):
516
+ # A vector-valued negation (w = -v) must lower to a kernel call, not
517
+ # Python field arithmetic. Scalar/transpose/norm stay inline.
518
+ if expr.inferred_type == VarType.VECTOR and expr.op == "neg":
519
+ result = self._lower_vector_expr(expr, dest=target_var)
520
+ return None if target_var is not None else result
521
+ return self._emit_unary(expr)
522
+ if isinstance(expr, BinOp):
523
+ return self._emit_binop(expr, target_var)
524
+ if isinstance(expr, FuncCall):
525
+ return self._emit_func_call(expr)
526
+ raise UnsupportedConstructError(
527
+ f"cannot emit code for expression node {type(expr).__name__}: {expr!r}"
528
+ )
529
+
530
+ def _emit_unary(self, expr: UnaryOp) -> str:
531
+ inner = self._emit_expr_str(expr.operand)
532
+ if expr.op == "neg":
533
+ return f"(-{inner})"
534
+ if expr.op == "norm":
535
+ # |scalar| is absolute value; ||vector|| is the Euclidean norm kernel.
536
+ if expr.operand.inferred_type == VarType.SCALAR:
537
+ return f"abs({inner})"
538
+ if self.runtime == RUNTIME_TI_RUNTIME:
539
+ return f"_v.norm2({inner})"
540
+ return f"_norm({inner})"
541
+ if expr.op in ("transpose", "inverse"):
542
+ return inner # semantic, handled at binop / funccall level
543
+ raise UnsupportedConstructError(
544
+ f"cannot emit code for unary operator {expr.op!r} on {expr.operand!r}"
545
+ )
546
+
547
+ def _emit_binop(self, expr: BinOp, target_var: str | None) -> str | None:
548
+ if expr.op == "dot":
549
+ return self._emit_dot(expr)
550
+ # All vector-valued binary ops (matvec, scale, +, -) lower through the
551
+ # SSA pass: every emitted op is a single kernel call writing a field,
552
+ # so arbitrarily nested RHS like `r + beta*(p - omega*v)` is faithful.
553
+ # When a target field is given the result is written there and None is
554
+ # returned; otherwise a fresh temp field name is.
555
+ if expr.inferred_type == VarType.VECTOR and expr.op in (
556
+ "matvec",
557
+ "scale",
558
+ "*",
559
+ "+",
560
+ "-",
561
+ ):
562
+ result = self._lower_vector_expr(expr, dest=target_var)
563
+ return None if target_var is not None else result
564
+
565
+ # Scalar
566
+ left = self._emit_expr(expr.left)
567
+ right = self._emit_expr(expr.right)
568
+ op_map = {
569
+ "+": "+",
570
+ "-": "-",
571
+ "*": "*",
572
+ "/": "/",
573
+ "pow": "**",
574
+ "<": "<",
575
+ ">": ">",
576
+ "<=": "<=",
577
+ ">=": ">=",
578
+ "==": "==",
579
+ "!=": "!=",
580
+ }
581
+ op = op_map.get(expr.op, expr.op)
582
+ return f"({left} {op} {right})"
583
+
584
+ def _emit_dot(self, expr: BinOp) -> str:
585
+ left = expr.left
586
+ if isinstance(left, UnaryOp) and left.op == "transpose":
587
+ left = left.operand
588
+ if self.runtime == RUNTIME_TI_RUNTIME:
589
+ return f"_v.dot({self._emit_expr(left)}, {self._emit_expr(expr.right)})"
590
+ return f"_dot({self._emit_expr(left)}, {self._emit_expr(expr.right)})"
591
+
592
+ # ── SSA vector lowering ──────────────────────────────────────────────
593
+ #
594
+ # Any vector-valued expression is lowered to a chain of single-kernel-call
595
+ # operations, each writing a fresh temporary field, with the final op
596
+ # writing the destination. One-level decomposition would emit invalid
597
+ # ``ti.field`` Python arithmetic for nested RHS such as
598
+ # ``r + beta*(p - omega*v)``. ``scalar * vector`` factors fuse into the
599
+ # parent axpy coefficient, so the common solver updates stay a single
600
+ # ``_vec_add`` and only genuinely nested sub-expressions cost a temp.
601
+
602
+ def _lower_vector_expr(self, expr: Expr, dest: str | None) -> str:
603
+ """Lower a vector expression to kernel calls; return the result field.
604
+
605
+ ``dest`` is the destination field name, or None to materialise into a
606
+ fresh temporary. Every branch emits at most one kernel call plus the
607
+ calls from its recursively-lowered operands.
608
+ """
609
+ if isinstance(expr, Var):
610
+ name = _var_name(expr)
611
+ if dest is not None and dest != name:
612
+ # ti_runtime.vector_ops.copy(dst, src) — note arg order is
613
+ # reversed from the inlined _copy(src, dst) kernel.
614
+ if self.runtime == RUNTIME_TI_RUNTIME:
615
+ self._write(f"_v.copy({dest}, {name})")
616
+ else:
617
+ self._write(f"_copy({name}, {dest})")
618
+ return dest
619
+ return name
620
+
621
+ if isinstance(expr, FuncCall):
622
+ # In-place callable: M_inv(r, out) — out is the last argument.
623
+ out = dest if dest is not None else self._get_temp()
624
+ func = self._emit_func_call_name(expr)
625
+ args = ", ".join(self._emit_expr_str(a) for a in expr.args)
626
+ self._write(f"{func}({args}, {out})")
627
+ return out
628
+
629
+ if isinstance(expr, UnaryOp) and expr.op == "neg":
630
+ vec = self._lower_to_field(expr.operand)
631
+ out = dest if dest is not None else self._get_temp()
632
+ if self.runtime == RUNTIME_TI_RUNTIME:
633
+ # ti_runtime.vec_add(out, a, x, b, y) — note arg order differs
634
+ # from the inlined _vec_add(alpha, x, beta, y, out)
635
+ self._write(f"_v.vec_add({out}, -1.0, {vec}, 0.0, {vec})")
636
+ else:
637
+ self._write(f"_vec_add(-1.0, {vec}, 0.0, {vec}, {out})")
638
+ return out
639
+
640
+ if isinstance(expr, BinOp):
641
+ if expr.op == "matvec":
642
+ out = dest if dest is not None else self._get_temp()
643
+ vec = self._lower_to_field(expr.right)
644
+ # Matrix-free operator seam (11-ALGO2CODE §8.3): a CALLABLE
645
+ # operand `A` is applied in place — `A(out, vec)`, matching the
646
+ # ti_runtime `apply_A(out, x)` contract — instead of a dense
647
+ # `_matvec` over a stored matrix field. The op name comes from
648
+ # the operator Var the same way an `M_inv(...)` callable does.
649
+ if expr.left.inferred_type == VarType.CALLABLE:
650
+ op_name = self._emit_expr_str(expr.left)
651
+ self._write(f"{op_name}({out}, {vec})")
652
+ return out
653
+ mat = self._emit_expr_str(expr.left)
654
+ self._write(f"_matvec({mat}, {vec}, {out})")
655
+ return out
656
+
657
+ if expr.op in ("scale", "*"):
658
+ scalar, vec_expr = self._split_scale(expr)
659
+ if scalar is not None and vec_expr is not None:
660
+ vec = self._lower_to_field(vec_expr)
661
+ out = dest if dest is not None else self._get_temp()
662
+ if self.runtime == RUNTIME_TI_RUNTIME:
663
+ self._write(f"_v.vec_add({out}, {scalar}, {vec}, 0.0, {vec})")
664
+ else:
665
+ self._write(f"_vec_add({scalar}, {vec}, 0.0, {vec}, {out})")
666
+ return out
667
+
668
+ if expr.op in ("+", "-"):
669
+ left = self._lower_to_field(expr.left)
670
+ coeff, right = self._lower_addend(expr.right)
671
+ beta = self._negate(coeff) if expr.op == "-" else coeff
672
+ out = dest if dest is not None else self._get_temp()
673
+ if self.runtime == RUNTIME_TI_RUNTIME:
674
+ self._write(f"_v.vec_add({out}, 1.0, {left}, {beta}, {right})")
675
+ else:
676
+ self._write(f"_vec_add(1.0, {left}, {beta}, {right}, {out})")
677
+ return out
678
+
679
+ raise UnsupportedConstructError(
680
+ f"cannot lower vector expression to kernel ops: {expr!r}. "
681
+ f"Supported vector forms are copy, callable application, matvec, "
682
+ f"scalar*vector, and vector +/- vector."
683
+ )
684
+
685
+ def _lower_to_field(self, expr: Expr) -> str:
686
+ """Lower a vector expression into some field (existing var or a temp)."""
687
+ return self._lower_vector_expr(expr, dest=None)
688
+
689
+ def _split_scale(self, expr: BinOp) -> tuple[str | None, Expr | None]:
690
+ """Split a scalar*vector product into (scalar_code, vector_expr).
691
+
692
+ The vector operand is whichever side is typed VECTOR; the other side is
693
+ the scalar coefficient. Treating "not VECTOR" (rather than strictly
694
+ SCALAR) as the coefficient keeps lowering robust when a scalar is only
695
+ weakly typed (e.g. an undeclared Greek coefficient inferred UNKNOWN).
696
+ """
697
+ lt, rt = expr.left.inferred_type, expr.right.inferred_type
698
+ if rt == VarType.VECTOR and lt != VarType.VECTOR:
699
+ return self._emit_expr_str(expr.left), expr.right
700
+ if lt == VarType.VECTOR and rt != VarType.VECTOR:
701
+ return self._emit_expr_str(expr.right), expr.left
702
+ return None, None
703
+
704
+ def _lower_addend(self, expr: Expr) -> tuple[str, str]:
705
+ """Express an addend as (coefficient_code, vector_field).
706
+
707
+ A ``scalar*vector`` addend fuses its coefficient into the parent axpy;
708
+ anything else gets coefficient ``1.0`` and is lowered into a field.
709
+ """
710
+ if isinstance(expr, BinOp) and expr.op in ("scale", "*"):
711
+ scalar, vec_expr = self._split_scale(expr)
712
+ if scalar is not None and vec_expr is not None:
713
+ return scalar, self._lower_to_field(vec_expr)
714
+ return "1.0", self._lower_to_field(expr)
715
+
716
+ @staticmethod
717
+ def _has_top_level_addsub(coeff: str) -> bool:
718
+ """True if ``coeff`` has a *binary* ``+``/``-`` at the top paren level.
719
+
720
+ Emitted coefficient code spaces its binary operators (``a + b``,
721
+ ``a - b``) while a leading unary minus (``-alpha``) and float exponents
722
+ (``1e-3``) do not — so a space-surrounded ``+``/``-`` at paren depth 0
723
+ unambiguously marks a top-level sum/difference. ``(a + b)`` (depth 1)
724
+ and ``a * b`` are *not* compound for negation purposes (unary minus
725
+ already binds tighter than ``*`` / ``/`` and the parens already group).
726
+ """
727
+ depth = 0
728
+ for i, ch in enumerate(coeff):
729
+ if ch == "(":
730
+ depth += 1
731
+ elif ch == ")":
732
+ depth -= 1
733
+ elif (
734
+ depth == 0
735
+ and ch in "+-"
736
+ and 0 < i < len(coeff) - 1
737
+ and coeff[i - 1] == " "
738
+ and coeff[i + 1] == " "
739
+ ):
740
+ return True
741
+ return False
742
+
743
+ @staticmethod
744
+ def _negate(coeff: str) -> str:
745
+ """Negate a coefficient string, keeping the output tidy.
746
+
747
+ A compound coefficient (a top-level ``+``/``-``) must be parenthesised
748
+ so the unary minus binds the *whole* expression: ``-(a + b)``, not the
749
+ precedence-wrong ``-a + b`` (gemini MED / WI-4). This also covers a
750
+ compound *leading*-minus (``-a + b``) correctly — wrapping yields
751
+ ``-(-a + b)`` (== ``a - b``), whereas the bare ``coeff[1:]`` strip would
752
+ have produced the wrong ``a + b``. Atoms and products keep the tidy
753
+ strip-or-prepend form.
754
+ """
755
+ if TaichiEmitter._has_top_level_addsub(coeff):
756
+ return f"-({coeff})"
757
+ return coeff[1:] if coeff.startswith("-") else f"-{coeff}"
758
+
759
+ def _emit_func_call(self, expr: FuncCall) -> str:
760
+ func_name = self._emit_func_call_name(expr)
761
+ args = ", ".join(self._emit_expr_str(a) for a in expr.args)
762
+ return f"{func_name}({args})"
763
+
764
+ def _emit_func_call_name(self, expr: FuncCall) -> str:
765
+ """Extract the function name string from a FuncCall node."""
766
+ if isinstance(expr.func, UnaryOp) and expr.func.op == "inverse":
767
+ base = expr.func.operand
768
+ return (
769
+ _var_name(base) if isinstance(base, Var) else self._emit_expr_str(base)
770
+ ) + "_inv"
771
+ elif isinstance(expr.func, Var):
772
+ name = _var_name(expr.func)
773
+ return "ti.sqrt" if name == "sqrt" else name
774
+ else:
775
+ return self._emit_expr_str(expr.func)
776
+
777
+ # ── Control flow ─────────────────────────────────────────────────────
778
+
779
+ def _emit_for(self, stmt: ForLoop):
780
+ # Explicit terminal `\ldots, N` is inclusive (k = 1, 2, ..., N), so the
781
+ # Python range upper bound is N + 1 — without the +1 the loop would run one
782
+ # fewer iteration than written. An open-ended `0, 1, 2, ...` uses `maxiter`
783
+ # as a safety cap (exactly `maxiter` iterations), not an inclusive terminal.
784
+ end = f"{_sanitize(stmt.end_expr)} + 1" if stmt.end_expr else "maxiter"
785
+ self._write(f"for {stmt.var} in range({stmt.start}, {end}):")
786
+ self._indent += 1
787
+ self._emit_block(stmt.body)
788
+ self._indent -= 1
789
+
790
+ def _emit_while(self, stmt: WhileLoop):
791
+ cond = self._emit_expr(stmt.condition)
792
+ self._write(f"while {cond}:")
793
+ self._indent += 1
794
+ self._emit_block(stmt.body)
795
+ self._indent -= 1
796
+
797
+ def _emit_branch(self, stmt: Branch):
798
+ cond = self._emit_expr(stmt.condition)
799
+ self._write(f"if {cond}:")
800
+ self._indent += 1
801
+ self._emit_block(stmt.if_body)
802
+ self._indent -= 1
803
+ for elif_cond, elif_body in stmt.elif_branches:
804
+ self._write(f"elif {self._emit_expr(elif_cond)}:")
805
+ self._indent += 1
806
+ self._emit_block(elif_body)
807
+ self._indent -= 1
808
+ if stmt.else_body:
809
+ self._write("else:")
810
+ self._indent += 1
811
+ self._emit_block(stmt.else_body)
812
+ self._indent -= 1
813
+
814
+ def _emit_return(self, stmt: Return):
815
+ if not stmt.values:
816
+ self._write("return")
817
+ else:
818
+ vals = ", ".join(self._emit_expr_str(v) for v in stmt.values)
819
+ self._write(f"return {vals}")
820
+
821
+ def _get_temp(self) -> str:
822
+ name = f"_tmp{self._temp_counter}"
823
+ self._temp_counter += 1
824
+ if self._temp_counter > self._peak_temps:
825
+ self._peak_temps = self._temp_counter
826
+ return name
827
+
828
+
829
+ # ── Public API ───────────────────────────────────────────────────────────────
830
+
831
+
832
+ def generate_taichi(algo: Algorithm, runtime: str = RUNTIME_INLINE) -> str:
833
+ """Generate Taichi source code from a typed Algorithm AST.
834
+
835
+ Parameters
836
+ ----------
837
+ algo:
838
+ Typed Algorithm AST (output of :func:`algo2code.type_inference.infer_types`).
839
+ runtime:
840
+ Import mode. One of:
841
+
842
+ ``"inline"`` (default)
843
+ Emit private ``@ti.kernel`` definitions inline in the generated
844
+ file. Zero external runtime dependencies. All existing consumers
845
+ use this mode; it is the backward-compatible default.
846
+
847
+ ``"ti_runtime"``
848
+ Emit ``from ti_runtime import vector_ops as _v`` and call the
849
+ shared primitives for dot/norm/copy/vec_add. Only ``_matvec``
850
+ remains inlined (the matrix-free operator seam is P2-2's concern).
851
+ algo2code itself never imports ti_runtime — it only emits the
852
+ import line.
853
+
854
+ Returns
855
+ -------
856
+ str
857
+ Generated Taichi Python source.
858
+ """
859
+ return TaichiEmitter(algo, runtime=runtime).emit()