sutra-dev 0.2.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.
@@ -0,0 +1,1046 @@
1
+ """AST simplification pass + basis_vector-argument collection.
2
+
3
+ Runs after parsing, before codegen. Takes an `ast.Module` and
4
+ returns a simplified `ast.Module` with algebraic rewrites applied.
5
+
6
+ The rewrite set below is deliberately aggressive: Sutra's language
7
+ contract is that `.su` source describes *what* to compute, and the
8
+ compiler is responsible for reducing that to the minimum substrate
9
+ work. Every rewrite here is either an exact algebraic identity or
10
+ a soundness-preserving structural match. No approximate rewrites.
11
+
12
+ ### Rewrites applied
13
+
14
+ 1. **bundle-of-one elision.** `bundle(v)` → `v`.
15
+ Exact identity for unit-norm inputs; harmless re-normalization of
16
+ non-unit inputs is also algebraically `v / |v|`, still the identity
17
+ after any downstream cosine-based consumption.
18
+
19
+ 2. **bundle flattening.** `bundle(bundle(a,b), c, bundle(d,e))` →
20
+ `bundle(a,b,c,d,e)`. Nested bundles are sums-of-sums; flattening
21
+ surfaces all terms so the parallel-scheduling pass sees them as
22
+ independent leaves.
23
+
24
+ 3. **compose flattening.** `compose(compose(a,b), c)` → `compose(a,b,c)`.
25
+ Same motivation as bundle flattening: `compose` is associative,
26
+ and nested forms hide parallelism from the scheduling pass.
27
+
28
+ 4. **similarity of self.** `similarity(a, a)` → `1.0`. Cosine of a
29
+ vector with itself is 1 for any non-zero vector. The rare
30
+ zero-vector case also agrees with runtime (runtime returns 0 for
31
+ zero norms, but `similarity(zero, zero)` in actual programs means
32
+ "I made a bug"; the rewrite surfaces that earlier).
33
+
34
+ 5. **displacement of self.** `displacement(a, a)` → `zero_vector()`.
35
+ `a - a = 0` exactly. Downstream rewrites (6, 7) then absorb the
36
+ zero into surrounding expressions.
37
+
38
+ 6. **zero absorption in bundle.** `bundle(..., zero_vector(), ...)` →
39
+ `bundle(...)` (drop the zero arg). If that leaves bundle empty,
40
+ the rewrite emits `zero_vector()` directly.
41
+
42
+ 7. **zero absorption in addition.** `x + zero_vector()` → `x`,
43
+ `zero_vector() + x` → `x`. For BinaryOp with `+` or `-`.
44
+
45
+ 8. **unbind/bind inverse.** `unbind(R, bind(R, x))` → `x` when the
46
+ two R arguments are structurally-identical Identifier references.
47
+ This is exact: Q.T @ (Q @ x) = x for orthogonal Q. The role
48
+ matrix is recomputed at runtime from the role vector, so
49
+ bit-identical role vectors produce bit-identical Q matrices.
50
+
51
+ 9. **bind/unbind inverse.** `bind(R, unbind(R, x))` → `x`. Same
52
+ identity in the other direction.
53
+
54
+ 10. **displacement-addition bundle rewrite.** A `bundle(...)` whose
55
+ args include a `displacement(a, b)` and a `b` in adjacent positions
56
+ *could* collapse to `bundle(..., a)`, but this requires reasoning
57
+ about bundle's normalization, which is lossy for repeated terms.
58
+ Not implemented — left as a comment for future work. The
59
+ cartography-style `a - b + c` (= `bundle(displacement(a,b), c)`)
60
+ stays as written.
61
+
62
+ 11. **Arithmetic constant folding.** `x + 0` → `x`, `x - 0` → `x`,
63
+ `x * 1` → `x`, `1 * x` → `x`, `x * 0` → `0`, `0 * x` → `0`,
64
+ `x / 1` → `x` for scalar literal operands. Applied to IntLiteral
65
+ and FloatLiteral.
66
+
67
+ 12. **bind of zero_vector() absorbs.** `bind(role, zero_vector())`
68
+ → `zero_vector()`. Q @ 0 = 0 for any orthogonal Q. Independent
69
+ of role. Enables cascading: rule 5's `displacement(a, a)` →
70
+ zero can propagate through bind, and rule 6 then drops it from
71
+ an enclosing bundle.
72
+
73
+ 13. **unbind of zero_vector() absorbs.** `unbind(role,
74
+ zero_vector())` → `zero_vector()`. Q^T @ 0 = 0 by the same
75
+ argument.
76
+
77
+ 14. **compose with identity_permutation drops.**
78
+ `compose(identity_permutation(), x)` → `x` and
79
+ `compose(x, identity_permutation())` → `x`. `identity_permutation()`
80
+ is the all-ones vector; pointwise multiply by all-ones is the
81
+ identity. If all args are identities, the result is
82
+ `identity_permutation()` itself.
83
+
84
+ 15. **argmax_cosine of single-candidate list.** `argmax_cosine(v,
85
+ [x])` → `x`. Only fires when the candidates are a compile-time
86
+ `ArrayLiteral` with exactly one element.
87
+
88
+ 16. **Subscript of ArrayLiteral with literal int index.** `[a, b,
89
+ c][1]` → `b`. Compile-time array indexing. Negative indices
90
+ are handled Python-style; out-of-range indices are left
91
+ unsimplified so the runtime IndexError surfaces as a real
92
+ diagnostic.
93
+
94
+ ### Rewrites NOT applied (documented non-rewrites)
95
+
96
+ - `bundle(x, x)` → `x` (NOT applied). `bundle` normalizes to unit
97
+ norm, so `bundle(x, x) = (x+x)/|x+x| = x/|x| = x` for unit x.
98
+ True algebraically, but the rewrite requires reasoning about
99
+ norms we don't track statically. Skipped.
100
+ - `bind(R1, bind(R2, x))` → `bind(compose(R1,R2), x)`. The product
101
+ of two Haar rotations is not itself a single cached role, so the
102
+ rewrite would require materializing a composite rotation at
103
+ runtime rather than at compile time. Skipped.
104
+
105
+ ### Design invariants
106
+
107
+ - Post-order traversal: children are simplified before the parent
108
+ looks at them. This lets nested rewrites cascade in one pass.
109
+ - No rewrite produces a new node type the codegen doesn't already
110
+ handle. `zero_vector()` is a new builtin emitted via the normal
111
+ `Call(Identifier("zero_vector"), ...)` path.
112
+ - Rewrites are applied unconditionally — there is no user-facing
113
+ flag to disable them. If a rewrite produces wrong output for some
114
+ program, the rewrite is wrong and has to be removed, not hidden
115
+ behind a flag.
116
+ """
117
+ from __future__ import annotations
118
+
119
+ from typing import Callable, List, Optional
120
+
121
+ from . import ast_nodes as ast
122
+ from .diagnostics import SourceSpan
123
+
124
+
125
+ # Optional tracing hook. When set to a callable, every rewrite that
126
+ # fires invokes `_trace_callback(rule_name, before_node, after_node)`.
127
+ # Used by `sutra_compiler.review` to show step-by-step simplification.
128
+ # None in production — zero overhead when not used.
129
+ _trace_callback: Optional[Callable[[str, object, object], None]] = None
130
+
131
+
132
+ def set_trace_callback(cb: Optional[Callable[[str, object, object], None]]) -> None:
133
+ """Set (or clear with None) the per-rewrite tracing callback.
134
+
135
+ The callback receives (rule_name, before_node, after_node) every
136
+ time a rewrite fires. Intended for the --review compiler mode;
137
+ production code leaves this at None.
138
+ """
139
+ global _trace_callback
140
+ _trace_callback = cb
141
+
142
+
143
+ def _trace(rule: str, before, after):
144
+ """Invoke the trace callback if one is registered. Inline helper so
145
+ rewrite sites read as `return _trace("R01", call, result)`."""
146
+ if _trace_callback is not None:
147
+ _trace_callback(rule, before, after)
148
+ return after
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Public entry points
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ def simplify_module(module: ast.Module) -> ast.Module:
157
+ """Apply all simplification passes to a module and return the result.
158
+
159
+ Two passes:
160
+ 1. The hand-rolled rule set in this file (R01..R16, structural).
161
+ 2. An egglog-driven post-pass that tries to reduce expressions
162
+ the hand-rolled pass leaves intact — primarily cases where
163
+ cascading rewrites need equality saturation rather than the
164
+ fixed-order pattern matching the hand-rolled simplifier does.
165
+
166
+ The egglog pass is conservative: it only replaces a subtree when
167
+ the result lowers to a recognized simpler shape (a literal, an
168
+ identifier, a `zero_vector()` call). Anything else round-trips
169
+ losslessly. If the `egglog` package isn't installed, the post-
170
+ pass is a no-op.
171
+
172
+ The module is mutated in place; the return value is the same
173
+ object, returned for call-chain convenience.
174
+ """
175
+ for decl in module.items:
176
+ _simplify_top_level(decl)
177
+ _egglog_post_pass(module)
178
+ return module
179
+
180
+
181
+ def _egglog_post_pass(module: ast.Module) -> None:
182
+ """Walk every expression in the module and try the egglog
183
+ simplifier on it. Replace the expression in its parent if egglog
184
+ found a simpler shape.
185
+
186
+ Conservative — only replaces when the result is structurally
187
+ simpler (a literal, identifier, or zero_vector call). The lift /
188
+ lower bridge in `simplify_egglog` returns the original expression
189
+ unchanged when it can't make progress, so the identity-comparison
190
+ `out is expr` is the signal for "no change."
191
+ """
192
+ try:
193
+ from . import simplify_egglog as _eg
194
+ except ImportError:
195
+ # egglog not installed — post-pass is a no-op.
196
+ return
197
+
198
+ def _try(expr: ast.Expr) -> ast.Expr:
199
+ # Try vec context first; if no progress, try num. Bridge is
200
+ # conservative on both sides — never returns a wrong result.
201
+ out = _eg.simplify_ast_vec(expr)
202
+ if out is not expr:
203
+ return out
204
+ out = _eg.simplify_ast_num(expr)
205
+ return out
206
+
207
+ def _walk_expr(expr: ast.Expr) -> ast.Expr:
208
+ """Bottom-up walk: simplify children first, then the node."""
209
+ if isinstance(expr, ast.Call):
210
+ expr.args = [_walk_expr(a) for a in expr.args]
211
+ elif isinstance(expr, ast.BinaryOp):
212
+ expr.left = _walk_expr(expr.left)
213
+ expr.right = _walk_expr(expr.right)
214
+ elif isinstance(expr, ast.UnaryOp):
215
+ expr.operand = _walk_expr(expr.operand)
216
+ elif isinstance(expr, ast.Parenthesized):
217
+ expr.inner = _walk_expr(expr.inner)
218
+ elif isinstance(expr, ast.ArrayLiteral):
219
+ expr.elements = [_walk_expr(e) for e in expr.elements]
220
+ elif isinstance(expr, ast.CastExpr):
221
+ expr.expr = _walk_expr(expr.expr)
222
+ elif isinstance(expr, ast.UnsafeCastExpr):
223
+ expr.expr = _walk_expr(expr.expr)
224
+ return _try(expr)
225
+
226
+ def _walk_stmt(stmt: ast.Stmt) -> None:
227
+ # Skip if there's no obvious place an expr could be.
228
+ if isinstance(stmt, ast.VarDecl):
229
+ if stmt.initializer is not None:
230
+ stmt.initializer = _walk_expr(stmt.initializer)
231
+ elif isinstance(stmt, ast.ExprStmt):
232
+ stmt.expr = _walk_expr(stmt.expr)
233
+ elif isinstance(stmt, ast.ReturnStmt):
234
+ if stmt.value is not None:
235
+ stmt.value = _walk_expr(stmt.value)
236
+ elif isinstance(stmt, ast.IfStmt):
237
+ stmt.condition = _walk_expr(stmt.condition)
238
+ for s in stmt.then_branch.statements:
239
+ _walk_stmt(s)
240
+ if stmt.else_branch is not None:
241
+ for s in stmt.else_branch.statements:
242
+ _walk_stmt(s)
243
+ elif isinstance(stmt, ast.Block):
244
+ for s in stmt.statements:
245
+ _walk_stmt(s)
246
+ elif isinstance(stmt, ast.WhileStmt):
247
+ stmt.condition = _walk_expr(stmt.condition)
248
+ for s in stmt.body.statements:
249
+ _walk_stmt(s)
250
+ elif isinstance(stmt, ast.LoopStmt):
251
+ for s in stmt.body.statements:
252
+ _walk_stmt(s)
253
+ # Other statement types: walked-through children are out of
254
+ # scope for this post-pass; the hand-written pass already
255
+ # handled them.
256
+
257
+ for decl in module.items:
258
+ if isinstance(decl, ast.VarDecl):
259
+ if decl.initializer is not None:
260
+ decl.initializer = _walk_expr(decl.initializer)
261
+ elif isinstance(decl, ast.FunctionDecl):
262
+ for s in decl.body.statements:
263
+ _walk_stmt(s)
264
+ elif isinstance(decl, ast.MethodDecl):
265
+ for s in decl.body.statements:
266
+ _walk_stmt(s)
267
+ elif isinstance(decl, ast.ClassDecl):
268
+ # Empty bodies for now; nothing to walk inside.
269
+ pass
270
+ elif isinstance(decl, ast.Stmt):
271
+ _walk_stmt(decl)
272
+
273
+
274
+ def _auto_embed_var_decl_init(decl: ast.VarDecl) -> None:
275
+ """Type-directed rewrite of StringLiteral initializers.
276
+
277
+ String literals are interpreted by the type they are being
278
+ assigned into:
279
+
280
+ - `vector v = "foo"` — the string is implicitly embedded. We wrap
281
+ it in `EmbedExpr` so the codegen emits `_VSA.embed("foo")`.
282
+ - `char c = "c"` — the string must be exactly one character; we
283
+ rewrite it to a `CharLiteral`. A longer string is a type error
284
+ the validator will catch separately; for now we leave longer
285
+ strings in place so the program still parses (codegen will
286
+ diagnose).
287
+ - `var x = "foo"` (untyped) — defaults to embed. User direction
288
+ was clear: "var x = 'x' will default embed". The programmer
289
+ writes `var x : string = "foo"` or `string x = "foo"` to keep
290
+ the string as a string.
291
+ - `string x = "foo"` — untouched. Explicit string type keeps the
292
+ string.
293
+
294
+ Only the literal case — `StringLiteral` — is rewritten. Non-literal
295
+ RHS expressions (e.g. `vector v = lookup_name();`) are left to
296
+ whatever the normal expression-typing story produces.
297
+ """
298
+ if decl.initializer is None:
299
+ return
300
+ if not isinstance(decl.initializer, ast.StringLiteral):
301
+ return
302
+ s_lit: ast.StringLiteral = decl.initializer
303
+ type_name = decl.type_ref.name if decl.type_ref is not None else None
304
+
305
+ # `string x = "foo"` — explicit string, no rewrite.
306
+ if type_name == "string":
307
+ return
308
+
309
+ # `char c = "c"` — fold to CharLiteral at compile time.
310
+ if type_name == "char":
311
+ if len(s_lit.value) == 1:
312
+ decl.initializer = ast.CharLiteral(
313
+ value=ord(s_lit.value), span=s_lit.span,
314
+ )
315
+ # Length != 1 is left alone; the validator / codegen will
316
+ # complain when a string hits a `char` context.
317
+ return
318
+
319
+ # `vector v = "foo"` OR `var v = "foo"` (untyped) — implicit embed.
320
+ if type_name == "vector" or decl.is_var_inferred:
321
+ decl.initializer = ast.EmbedExpr(expr=s_lit, span=s_lit.span)
322
+ return
323
+
324
+ # Other typed contexts (int, float, fuzzy, etc.) — don't rewrite.
325
+ # Fuzzy-with-StringLiteral is a type error the validator catches;
326
+ # int/float with a string is a cast the validator catches too.
327
+
328
+
329
+ def collect_basis_vector_strings(module: ast.Module) -> list[str]:
330
+ """Return every string literal that will be embedded at runtime.
331
+
332
+ Covers two sources:
333
+ - `basis_vector("name")` — explicit source-level basis_vector call
334
+ with a string literal argument.
335
+ - `embed(<StringLiteral>)` — the `EmbedExpr` AST node, which the
336
+ auto-embed pass inserts in type-directed contexts (`vector v =
337
+ "foo"`, untyped `var x = "foo"`, etc.) and which may also be
338
+ written explicitly.
339
+
340
+ Used by the codegen to emit a batched Ollama pre-fetch at module
341
+ init: N sequential HTTP round-trips collapse into a single batched
342
+ embed call. Strings are returned in source order, deduplicated
343
+ (first-occurrence order preserved).
344
+ """
345
+ seen: set[str] = set()
346
+ collected: list[str] = []
347
+
348
+ def record(s: str) -> None:
349
+ if s not in seen:
350
+ seen.add(s)
351
+ collected.append(s)
352
+
353
+ def visit(node) -> None:
354
+ if node is None:
355
+ return
356
+ if isinstance(node, ast.Call):
357
+ if _is_basis_vector_literal_call(node):
358
+ record(node.args[0].value) # type: ignore[attr-defined]
359
+ visit(node.callee)
360
+ for a in node.args:
361
+ visit(a)
362
+ return
363
+ if isinstance(node, ast.EmbedExpr) and isinstance(node.expr, ast.StringLiteral):
364
+ record(node.expr.value)
365
+ return
366
+ for child in _children(node):
367
+ visit(child)
368
+
369
+ for item in module.items:
370
+ visit(item)
371
+ return collected
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Internal: AST traversal helpers
376
+ # ---------------------------------------------------------------------------
377
+
378
+
379
+ def _children(node):
380
+ """Yield direct child AST nodes of `node`. Covers every expression
381
+ and statement type the simplifier may encounter.
382
+ """
383
+ if node is None:
384
+ return
385
+ if isinstance(node, ast.BinaryOp):
386
+ yield node.left; yield node.right; return
387
+ if isinstance(node, ast.UnaryOp):
388
+ yield node.operand; return
389
+ if isinstance(node, ast.PostfixOp):
390
+ yield node.operand; return
391
+ if isinstance(node, ast.ArrayLiteral):
392
+ for e in node.elements: yield e
393
+ return
394
+ if isinstance(node, ast.MapLiteral):
395
+ for k in node.keys: yield k
396
+ for v in node.values: yield v
397
+ return
398
+ if isinstance(node, ast.Subscript):
399
+ yield node.target; yield node.index; return
400
+ if isinstance(node, ast.MemberAccess):
401
+ yield node.obj; return
402
+ if isinstance(node, ast.Assignment):
403
+ yield node.target; yield node.value; return
404
+ if isinstance(node, ast.Parenthesized):
405
+ yield node.inner; return
406
+ if isinstance(node, ast.CastExpr):
407
+ yield node.expr; return
408
+ if isinstance(node, ast.UnsafeCastExpr):
409
+ yield node.expr; return
410
+ if isinstance(node, ast.UnsafeOverrideExpr):
411
+ yield node.expr; return
412
+ if isinstance(node, ast.DefuzzyExpr):
413
+ yield node.expr; return
414
+ if isinstance(node, ast.EmbedExpr):
415
+ yield node.expr; return
416
+ if isinstance(node, ast.InterpolatedString):
417
+ for part in node.parts:
418
+ if not isinstance(part, str):
419
+ yield part
420
+ return
421
+ if isinstance(node, ast.VarDecl):
422
+ yield node.initializer; return
423
+ if isinstance(node, ast.FunctionDecl):
424
+ for s in node.body.statements: yield s
425
+ return
426
+ if isinstance(node, ast.ReturnStmt):
427
+ yield node.value; return
428
+ if isinstance(node, ast.ExprStmt):
429
+ yield node.expr; return
430
+ if isinstance(node, ast.IfStmt):
431
+ yield node.condition
432
+ for s in node.then_branch.statements: yield s
433
+ if node.else_branch is not None:
434
+ if isinstance(node.else_branch, ast.IfStmt):
435
+ yield node.else_branch
436
+ else:
437
+ for s in node.else_branch.statements: yield s
438
+ return
439
+ if isinstance(node, ast.WhileStmt):
440
+ yield node.condition
441
+ for s in node.body.statements: yield s
442
+ return
443
+ if isinstance(node, ast.ForStmt):
444
+ yield node.init; yield node.condition; yield node.step
445
+ for s in node.body.statements: yield s
446
+ return
447
+ if isinstance(node, ast.DoWhileStmt):
448
+ yield node.condition
449
+ for s in node.body.statements: yield s
450
+ return
451
+ if isinstance(node, ast.ForeachStmt):
452
+ yield node.iterable
453
+ for s in node.body.statements: yield s
454
+ return
455
+ if isinstance(node, ast.LoopStmt):
456
+ yield node.count; yield node.condition
457
+ for s in node.body.statements: yield s
458
+ return
459
+ if isinstance(node, ast.Block):
460
+ for s in node.statements: yield s
461
+ return
462
+ if isinstance(node, ast.TryStmt):
463
+ for s in node.try_body.statements: yield s
464
+ for s in node.catch_body.statements: yield s
465
+ return
466
+ # Leaf nodes (Identifier, literals, etc.) have no children.
467
+
468
+
469
+ # ---------------------------------------------------------------------------
470
+ # Internal: structural equality on expressions
471
+ # ---------------------------------------------------------------------------
472
+
473
+
474
+ def _structurally_equal(a, b) -> bool:
475
+ """Conservative structural equality for expressions.
476
+
477
+ Returns True only when we can prove the two subtrees evaluate to
478
+ the same value — which is exact for literal constants and for
479
+ identifier references to the same name. Pessimistic elsewhere:
480
+ a `MemberAccess`, a `Call`, or anything with side effects compares
481
+ unequal even if textually identical, because a cautious rewriter
482
+ needs to assume they might differ.
483
+
484
+ This is used by the unbind/bind inverse rewrites to decide whether
485
+ the two role arguments are "the same". In practice, roles in .su
486
+ programs are top-level `vector r_foo = basis_vector("...")`
487
+ declarations referenced by identifier — exactly the case this
488
+ function handles exactly.
489
+ """
490
+ if type(a) is not type(b):
491
+ return False
492
+ if isinstance(a, ast.Identifier):
493
+ return a.name == b.name
494
+ if isinstance(a, ast.IntLiteral):
495
+ return a.value == b.value
496
+ if isinstance(a, ast.FloatLiteral):
497
+ return a.value == b.value
498
+ if isinstance(a, ast.StringLiteral):
499
+ return a.value == b.value
500
+ if isinstance(a, ast.BoolLiteral):
501
+ return a.value == b.value
502
+ # Anything else: be conservative.
503
+ return False
504
+
505
+
506
+ # ---------------------------------------------------------------------------
507
+ # Internal: call-pattern matchers
508
+ # ---------------------------------------------------------------------------
509
+
510
+
511
+ def _is_call_named(expr, name: str, arity: Optional[int] = None) -> bool:
512
+ if not isinstance(expr, ast.Call):
513
+ return False
514
+ if not isinstance(expr.callee, ast.Identifier):
515
+ return False
516
+ if expr.callee.name != name:
517
+ return False
518
+ if arity is not None and len(expr.args) != arity:
519
+ return False
520
+ return True
521
+
522
+
523
+ def _is_zero_vector_call(expr) -> bool:
524
+ """Match `zero_vector()` — the emitted zero primitive."""
525
+ return _is_call_named(expr, "zero_vector", arity=0)
526
+
527
+
528
+ def _is_basis_vector_literal_call(expr) -> bool:
529
+ return (_is_call_named(expr, "basis_vector", arity=1)
530
+ and isinstance(expr.args[0], ast.StringLiteral))
531
+
532
+
533
+ def _mk_zero_vector(span: SourceSpan) -> ast.Call:
534
+ """Construct a fresh `zero_vector()` call node."""
535
+ return ast.Call(
536
+ span=span,
537
+ callee=ast.Identifier(span=span, name="zero_vector"),
538
+ type_args=[],
539
+ args=[],
540
+ )
541
+
542
+
543
+ def _mk_float_literal(value: float, span: SourceSpan) -> ast.FloatLiteral:
544
+ return ast.FloatLiteral(span=span, value=value)
545
+
546
+
547
+ # ---------------------------------------------------------------------------
548
+ # Internal: statement dispatch
549
+ # ---------------------------------------------------------------------------
550
+
551
+
552
+ def _simplify_top_level(decl) -> None:
553
+ if isinstance(decl, ast.FunctionDecl):
554
+ _simplify_block(decl.body)
555
+ elif isinstance(decl, ast.VarDecl):
556
+ _auto_embed_var_decl_init(decl)
557
+ if decl.initializer is not None:
558
+ decl.initializer = _simplify_expr(decl.initializer)
559
+
560
+
561
+ def _simplify_block(block: ast.Block) -> None:
562
+ for stmt in block.statements:
563
+ _simplify_stmt(stmt)
564
+
565
+
566
+ def _simplify_stmt(stmt) -> None:
567
+ if isinstance(stmt, ast.VarDecl):
568
+ _auto_embed_var_decl_init(stmt)
569
+ if stmt.initializer is not None:
570
+ stmt.initializer = _simplify_expr(stmt.initializer)
571
+ return
572
+ if isinstance(stmt, ast.ReturnStmt):
573
+ if stmt.value is not None:
574
+ stmt.value = _simplify_expr(stmt.value)
575
+ return
576
+ if isinstance(stmt, ast.ExprStmt):
577
+ stmt.expr = _simplify_expr(stmt.expr)
578
+ return
579
+ if isinstance(stmt, ast.IfStmt):
580
+ stmt.condition = _simplify_expr(stmt.condition)
581
+ _simplify_block(stmt.then_branch)
582
+ if stmt.else_branch is not None:
583
+ if isinstance(stmt.else_branch, ast.IfStmt):
584
+ _simplify_stmt(stmt.else_branch)
585
+ else:
586
+ _simplify_block(stmt.else_branch)
587
+ return
588
+ if isinstance(stmt, ast.WhileStmt):
589
+ stmt.condition = _simplify_expr(stmt.condition)
590
+ _simplify_block(stmt.body)
591
+ return
592
+ if isinstance(stmt, ast.ForStmt):
593
+ if stmt.init is not None:
594
+ _simplify_stmt(stmt.init)
595
+ if stmt.condition is not None:
596
+ stmt.condition = _simplify_expr(stmt.condition)
597
+ if stmt.step is not None:
598
+ _simplify_stmt(stmt.step)
599
+ _simplify_block(stmt.body)
600
+ return
601
+ if isinstance(stmt, ast.DoWhileStmt):
602
+ _simplify_block(stmt.body)
603
+ stmt.condition = _simplify_expr(stmt.condition)
604
+ return
605
+ if isinstance(stmt, ast.ForeachStmt):
606
+ stmt.iterable = _simplify_expr(stmt.iterable)
607
+ _simplify_block(stmt.body)
608
+ return
609
+ if isinstance(stmt, ast.LoopStmt):
610
+ if stmt.count is not None:
611
+ stmt.count = _simplify_expr(stmt.count)
612
+ if stmt.condition is not None:
613
+ stmt.condition = _simplify_expr(stmt.condition)
614
+ _simplify_block(stmt.body)
615
+ return
616
+ if isinstance(stmt, ast.Block):
617
+ _simplify_block(stmt)
618
+ return
619
+ if isinstance(stmt, ast.TryStmt):
620
+ _simplify_block(stmt.try_body)
621
+ _simplify_block(stmt.catch_body)
622
+ return
623
+ # Unknown statement types pass through untouched.
624
+
625
+
626
+ # ---------------------------------------------------------------------------
627
+ # Internal: expression simplification (post-order)
628
+ # ---------------------------------------------------------------------------
629
+
630
+
631
+ def _simplify_expr(expr):
632
+ """Recursively simplify an expression. Post-order traversal: simplify
633
+ children first, then look at the resulting node for rewrite matches.
634
+ Rewrites may compound in a single pass because children are finalized
635
+ before the parent inspects them.
636
+ """
637
+ if expr is None:
638
+ return None
639
+
640
+ if isinstance(expr, ast.Call):
641
+ if not isinstance(expr.callee, ast.Identifier):
642
+ expr.callee = _simplify_expr(expr.callee)
643
+ expr.args = [_simplify_expr(a) for a in expr.args]
644
+ return _rewrite_call(expr)
645
+
646
+ if isinstance(expr, ast.BinaryOp):
647
+ expr.left = _simplify_expr(expr.left)
648
+ expr.right = _simplify_expr(expr.right)
649
+ return _rewrite_binary(expr)
650
+
651
+ if isinstance(expr, ast.UnaryOp):
652
+ expr.operand = _simplify_expr(expr.operand)
653
+ # Unary minus on an imaginary literal folds at compile time
654
+ # (`-5i` → ImaginaryLiteral(-5)). Unary plus is a no-op.
655
+ if expr.op == "-" and isinstance(expr.operand, ast.ImaginaryLiteral):
656
+ return ast.ImaginaryLiteral(
657
+ value=-expr.operand.value, span=expr.span,
658
+ )
659
+ if expr.op == "+" and isinstance(expr.operand, ast.ImaginaryLiteral):
660
+ return expr.operand
661
+ # Unary minus on a numeric literal: `-5` → IntLiteral(-5),
662
+ # `-3.14` → FloatLiteral(-3.14). Matters for inlined
663
+ # polynomials where a `0 - x` form from logical_not on a
664
+ # literal input should collapse.
665
+ if expr.op == "-":
666
+ if isinstance(expr.operand, ast.IntLiteral):
667
+ return ast.IntLiteral(
668
+ value=-expr.operand.value, span=expr.span,
669
+ )
670
+ if isinstance(expr.operand, ast.FloatLiteral):
671
+ return ast.FloatLiteral(
672
+ value=-expr.operand.value, span=expr.span,
673
+ )
674
+ if expr.op == "+":
675
+ if isinstance(expr.operand, (ast.IntLiteral, ast.FloatLiteral)):
676
+ return expr.operand
677
+ return expr
678
+
679
+ if isinstance(expr, ast.ArrayLiteral):
680
+ expr.elements = [_simplify_expr(e) for e in expr.elements]
681
+ return expr
682
+
683
+ if isinstance(expr, ast.MapLiteral):
684
+ expr.keys = [_simplify_expr(k) for k in expr.keys]
685
+ expr.values = [_simplify_expr(v) for v in expr.values]
686
+ return expr
687
+
688
+ if isinstance(expr, ast.Subscript):
689
+ expr.target = _simplify_expr(expr.target)
690
+ expr.index = _simplify_expr(expr.index)
691
+ # Rule 16: Subscript of an ArrayLiteral with a literal int index
692
+ # → the indexed element. Compile-time array indexing. Out-of-
693
+ # range indices are left unsimplified (the runtime IndexError
694
+ # is honest; silent truncation would hide a program bug).
695
+ # Negative indices are handled in Python style (-1 → last).
696
+ if (isinstance(expr.target, ast.ArrayLiteral)
697
+ and isinstance(expr.index, ast.IntLiteral)):
698
+ elements = expr.target.elements
699
+ idx = expr.index.value
700
+ if -len(elements) <= idx < len(elements):
701
+ return elements[idx]
702
+ return expr
703
+
704
+ if isinstance(expr, ast.MemberAccess):
705
+ expr.obj = _simplify_expr(expr.obj)
706
+ return expr
707
+
708
+ if isinstance(expr, ast.Assignment):
709
+ expr.target = _simplify_expr(expr.target)
710
+ expr.value = _simplify_expr(expr.value)
711
+ return expr
712
+
713
+ if isinstance(expr, ast.Parenthesized):
714
+ expr.inner = _simplify_expr(expr.inner)
715
+ return expr
716
+
717
+ if isinstance(expr, ast.CastExpr):
718
+ expr.expr = _simplify_expr(expr.expr)
719
+ return expr
720
+
721
+ if isinstance(expr, ast.UnsafeCastExpr):
722
+ expr.expr = _simplify_expr(expr.expr)
723
+ return expr
724
+
725
+ if isinstance(expr, ast.UnsafeOverrideExpr):
726
+ expr.expr = _simplify_expr(expr.expr)
727
+ return expr
728
+
729
+ if isinstance(expr, ast.DefuzzyExpr):
730
+ expr.expr = _simplify_expr(expr.expr)
731
+ return expr
732
+
733
+ if isinstance(expr, ast.EmbedExpr):
734
+ expr.expr = _simplify_expr(expr.expr)
735
+ return expr
736
+
737
+ if isinstance(expr, ast.InterpolatedString):
738
+ new_parts = []
739
+ for p in expr.parts:
740
+ if isinstance(p, str):
741
+ new_parts.append(p)
742
+ else:
743
+ new_parts.append(_simplify_expr(p))
744
+ expr.parts = new_parts
745
+ return expr
746
+
747
+ # Identifier, IntLiteral, FloatLiteral, CharLiteral, StringLiteral,
748
+ # BoolLiteral, UnknownLiteral, ImaginaryLiteral, ComplexLiteral —
749
+ # ThisExpr — no simplification.
750
+ return expr
751
+
752
+
753
+ # ---------------------------------------------------------------------------
754
+ # Internal: call-level rewrites (post children-simplified)
755
+ # ---------------------------------------------------------------------------
756
+
757
+
758
+ def _rewrite_call(call: ast.Call):
759
+ if not isinstance(call.callee, ast.Identifier):
760
+ return call
761
+ name = call.callee.name
762
+
763
+ # Rule 1: bundle(v) → v (single-arg bundle is identity).
764
+ if name == "bundle" and len(call.args) == 1:
765
+ return _trace("R01 bundle(v) -> v", call, call.args[0])
766
+
767
+ # Rule 2: flatten nested bundles.
768
+ if name == "bundle":
769
+ flattened: List = []
770
+ changed = False
771
+ for a in call.args:
772
+ if _is_call_named(a, "bundle"):
773
+ flattened.extend(a.args)
774
+ changed = True
775
+ else:
776
+ flattened.append(a)
777
+ if changed:
778
+ call.args = flattened
779
+ # Re-check rule 1 after flattening.
780
+ if len(call.args) == 1:
781
+ return call.args[0]
782
+
783
+ # Rule 6: drop zero_vector() arguments from bundle.
784
+ if name == "bundle":
785
+ non_zero = [a for a in call.args if not _is_zero_vector_call(a)]
786
+ if len(non_zero) != len(call.args):
787
+ if not non_zero:
788
+ # bundle(zero, zero, ...) → zero_vector()
789
+ return _mk_zero_vector(call.span)
790
+ call.args = non_zero
791
+ if len(call.args) == 1:
792
+ return call.args[0]
793
+
794
+ # Rule 3: compose(compose(a,b), c) → compose(a,b,c).
795
+ if name == "compose":
796
+ flattened = []
797
+ changed = False
798
+ for a in call.args:
799
+ if _is_call_named(a, "compose"):
800
+ flattened.extend(a.args)
801
+ changed = True
802
+ else:
803
+ flattened.append(a)
804
+ if changed:
805
+ call.args = flattened
806
+
807
+ # Rule 4: similarity(a, a) → 1.0 (structurally equal args only).
808
+ if (name == "similarity"
809
+ and len(call.args) == 2
810
+ and _structurally_equal(call.args[0], call.args[1])):
811
+ return _trace("R04 similarity(a, a) -> 1.0", call,
812
+ _mk_float_literal(1.0, call.span))
813
+
814
+ # Rule 5: displacement(a, a) → zero_vector() (structurally equal args).
815
+ if (name == "displacement"
816
+ and len(call.args) == 2
817
+ and _structurally_equal(call.args[0], call.args[1])):
818
+ return _trace("R05 displacement(a, a) -> zero", call,
819
+ _mk_zero_vector(call.span))
820
+
821
+ # Rule 8: unbind(R, bind(R, x)) → x.
822
+ if name == "unbind" and len(call.args) == 2:
823
+ inner = call.args[1]
824
+ if (_is_call_named(inner, "bind", arity=2)
825
+ and _structurally_equal(call.args[0], inner.args[0])):
826
+ return _trace("R08 unbind(R, bind(R, x)) -> x", call,
827
+ inner.args[1])
828
+
829
+ # Rule 9: bind(R, unbind(R, x)) → x.
830
+ if name == "bind" and len(call.args) == 2:
831
+ inner = call.args[1]
832
+ if (_is_call_named(inner, "unbind", arity=2)
833
+ and _structurally_equal(call.args[0], inner.args[0])):
834
+ return _trace("R09 bind(R, unbind(R, x)) -> x", call,
835
+ inner.args[1])
836
+
837
+ # Rule 12: bind(role, zero_vector()) → zero_vector().
838
+ # Q @ 0 = 0 for any orthogonal Q. The rotation of the zero vector is
839
+ # the zero vector. Independent of role.
840
+ if (name == "bind" and len(call.args) == 2
841
+ and _is_zero_vector_call(call.args[1])):
842
+ return _trace("R12 bind(R, zero) -> zero", call,
843
+ _mk_zero_vector(call.span))
844
+
845
+ # Rule 13: unbind(role, zero_vector()) → zero_vector().
846
+ # Q^T @ 0 = 0 by the same argument. Independent of role.
847
+ if (name == "unbind" and len(call.args) == 2
848
+ and _is_zero_vector_call(call.args[1])):
849
+ return _trace("R13 unbind(R, zero) -> zero", call,
850
+ _mk_zero_vector(call.span))
851
+
852
+ # Rule 14: compose with identity_permutation() on either side →
853
+ # drop the identity. `identity_permutation()` is the all-ones
854
+ # vector and `compose` is elementwise multiply, so multiplying
855
+ # by all-ones is the identity. Works after rule 3 has flattened
856
+ # nested composes.
857
+ if name == "compose" and len(call.args) >= 2:
858
+ non_identity = [
859
+ a for a in call.args if not _is_call_named(a, "identity_permutation", arity=0)
860
+ ]
861
+ if len(non_identity) != len(call.args):
862
+ if not non_identity:
863
+ # compose(identity, identity, ...) is identity itself.
864
+ return _mk_identity_permutation(call.span)
865
+ if len(non_identity) == 1:
866
+ # compose(x, identity) → x; compose(identity, x) → x.
867
+ return non_identity[0]
868
+ call.args = non_identity
869
+
870
+ # Rule 15: argmax_cosine(query, [single]) → single.
871
+ # Single-candidate argmax has no choice; the literal element is the
872
+ # result regardless of the query. Only fires when the candidates
873
+ # are an ArrayLiteral with exactly one element (structural match
874
+ # against the compile-time shape).
875
+ if name == "argmax_cosine" and len(call.args) == 2:
876
+ candidates = call.args[1]
877
+ if (isinstance(candidates, ast.ArrayLiteral)
878
+ and len(candidates.elements) == 1):
879
+ return _trace("R15 argmax_cosine(q, [x]) -> x", call,
880
+ candidates.elements[0])
881
+
882
+ return call
883
+
884
+
885
+ def _mk_identity_permutation(span):
886
+ """Synthesize an `identity_permutation()` call for rule 14's empty
887
+ compose case."""
888
+ return ast.Call(
889
+ callee=ast.Identifier(name="identity_permutation", span=span),
890
+ type_args=[],
891
+ args=[],
892
+ span=span,
893
+ )
894
+
895
+
896
+ # ---------------------------------------------------------------------------
897
+ # Internal: binary-op rewrites
898
+ # ---------------------------------------------------------------------------
899
+
900
+
901
+ def _rewrite_binary(expr: ast.BinaryOp):
902
+ """Arithmetic constant folding + zero-vector absorption."""
903
+ op = expr.op
904
+ left = expr.left
905
+ right = expr.right
906
+
907
+ # Rule 7: zero-vector absorption in +/-.
908
+ if op == "+":
909
+ if _is_zero_vector_call(left):
910
+ return _trace("R07 zero + x -> x", expr, right)
911
+ if _is_zero_vector_call(right):
912
+ return _trace("R07 x + zero -> x", expr, left)
913
+ if op == "-":
914
+ if _is_zero_vector_call(right):
915
+ return _trace("R07 x - zero -> x", expr, left)
916
+ # zero - x is not x, so no rewrite on the left side of subtract.
917
+
918
+ # Complex-literal folding: `re ± im·i` at compile time. Turns the
919
+ # two-literal expression into a single ComplexLiteral so the
920
+ # codegen emits one _VSA.make_complex(re, im) allocation instead
921
+ # of a vector-add over two partial literals. Handles int+imag,
922
+ # float+imag, imag+int, imag+float, and the four subtraction
923
+ # variants. Imag+imag collapses to a single ImaginaryLiteral.
924
+ if op in ("+", "-"):
925
+ folded = _fold_complex_literal(left, right, op, expr.span)
926
+ if folded is not None:
927
+ return folded
928
+
929
+ # Rule 11: numeric constant folding for scalar literals.
930
+ l_num = _numeric_value(left)
931
+ r_num = _numeric_value(right)
932
+
933
+ # Identity-element rules first — preserve the original operand
934
+ # node so its type/span stays. (Pre-dates the step-6 full fold
935
+ # and has tests asserting specific node types survive.)
936
+ if op == "+":
937
+ if l_num == 0:
938
+ return right
939
+ if r_num == 0:
940
+ return left
941
+ elif op == "-":
942
+ if r_num == 0:
943
+ return left
944
+ elif op == "*":
945
+ if l_num == 0 or r_num == 0:
946
+ return _mk_float_literal(0.0, expr.span)
947
+ if l_num == 1:
948
+ return right
949
+ if r_num == 1:
950
+ return left
951
+ elif op == "/":
952
+ if r_num == 1:
953
+ return left
954
+
955
+ # Step 6 — full literal-on-literal fold: `2 + 3` → `5`,
956
+ # `0.7 * 0.3` → `0.21`, `(poly on literal args) * 0.5` → single
957
+ # constant. Applies when both operands have a numeric-literal
958
+ # value AND neither is an identity element (caught above). This
959
+ # is the starter for the fusion pass: inlined polynomial bodies
960
+ # where all operands are literals collapse to a single literal
961
+ # at compile time. `logical_and(0.7, 0.3)` inlines to the
962
+ # polynomial on `0.7` / `0.3`; this fold collapses that chain to
963
+ # `FloatLiteral(0.33705)`, so the runtime sees a constant
964
+ # (wrapped in `make_truth` by the fuzzy-literal coercion) rather
965
+ # than a tree of arithmetic ops.
966
+ if l_num is not None and r_num is not None:
967
+ try:
968
+ if op == "+":
969
+ return _trace("R16 fold (lit + lit)", expr,
970
+ _lit_from_num(l_num + r_num, expr.span))
971
+ if op == "-":
972
+ return _trace("R16 fold (lit - lit)", expr,
973
+ _lit_from_num(l_num - r_num, expr.span))
974
+ if op == "*":
975
+ return _trace("R16 fold (lit * lit)", expr,
976
+ _lit_from_num(l_num * r_num, expr.span))
977
+ if op == "/" and r_num != 0:
978
+ return _trace("R16 fold (lit / lit)", expr,
979
+ _lit_from_num(l_num / r_num, expr.span))
980
+ except (ZeroDivisionError, OverflowError):
981
+ pass # fall through; leave expr unchanged
982
+
983
+ return expr
984
+
985
+
986
+ def _lit_from_num(value, span: SourceSpan):
987
+ """Pick IntLiteral vs FloatLiteral based on the value's type.
988
+ Integer-valued floats that came from a pure int computation become
989
+ IntLiteral for pleasanter emission; anything with a fractional
990
+ component stays FloatLiteral."""
991
+ if isinstance(value, int):
992
+ return ast.IntLiteral(span=span, value=value)
993
+ if isinstance(value, float) and value.is_integer():
994
+ # Keep as float — mixing int and float folds should produce
995
+ # float per language semantics, even if the math happens to
996
+ # land on an integer value.
997
+ return _mk_float_literal(value, span)
998
+ return _mk_float_literal(float(value), span)
999
+
1000
+
1001
+ def _numeric_value(expr):
1002
+ """Extract the numeric value of a literal node, or None if not a literal.
1003
+
1004
+ Returns an int or float depending on the literal type; callers can
1005
+ compare against 0 or 1 directly. Unwraps `Parenthesized(...)`
1006
+ wrappers so a literal inside parens still folds.
1007
+ """
1008
+ while isinstance(expr, ast.Parenthesized):
1009
+ expr = expr.inner
1010
+ if isinstance(expr, ast.IntLiteral):
1011
+ return expr.value
1012
+ if isinstance(expr, ast.FloatLiteral):
1013
+ return expr.value
1014
+ return None
1015
+
1016
+
1017
+ def _fold_complex_literal(left, right, op: str, span):
1018
+ """Compile-time fold of `re ± im·i` into a single ComplexLiteral.
1019
+
1020
+ Recognises the four pairings (number ± imag, imag ± number) and
1021
+ the imag ± imag case. Returns the folded node or None if the
1022
+ operands don't form a complex-literal pattern.
1023
+ """
1024
+ assert op in ("+", "-")
1025
+ sign = 1.0 if op == "+" else -1.0
1026
+ l_num = _numeric_value(left)
1027
+ r_num = _numeric_value(right)
1028
+ l_is_imag = isinstance(left, ast.ImaginaryLiteral)
1029
+ r_is_imag = isinstance(right, ast.ImaginaryLiteral)
1030
+
1031
+ # number ± imag → Complex(number, ±imag)
1032
+ if l_num is not None and r_is_imag:
1033
+ return ast.ComplexLiteral(
1034
+ re=float(l_num), im=sign * float(right.value), span=span,
1035
+ )
1036
+ # imag ± number → Complex(±number, imag) (because `5i - 3 = -3 + 5i`)
1037
+ if l_is_imag and r_num is not None:
1038
+ return ast.ComplexLiteral(
1039
+ re=sign * float(r_num), im=float(left.value), span=span,
1040
+ )
1041
+ # imag ± imag → ImaginaryLiteral(sum/diff).
1042
+ if l_is_imag and r_is_imag:
1043
+ return ast.ImaginaryLiteral(
1044
+ value=float(left.value) + sign * float(right.value), span=span,
1045
+ )
1046
+ return None