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.
- sutra_compiler/__init__.py +49 -0
- sutra_compiler/__main__.py +514 -0
- sutra_compiler/ast_nodes.py +553 -0
- sutra_compiler/codegen.py +1811 -0
- sutra_compiler/codegen_base.py +2436 -0
- sutra_compiler/codegen_pytorch.py +1472 -0
- sutra_compiler/diagnostics.py +145 -0
- sutra_compiler/inliner.py +581 -0
- sutra_compiler/lexer.py +821 -0
- sutra_compiler/parser.py +2112 -0
- sutra_compiler/review.py +322 -0
- sutra_compiler/simplify.py +1046 -0
- sutra_compiler/simplify_egglog.py +674 -0
- sutra_compiler/stdlib/axons.su +53 -0
- sutra_compiler/stdlib/embed.su +48 -0
- sutra_compiler/stdlib/javascript_object.su +18 -0
- sutra_compiler/stdlib/logic.su +202 -0
- sutra_compiler/stdlib/math.su +12 -0
- sutra_compiler/stdlib/memory.su +82 -0
- sutra_compiler/stdlib/numbers.su +99 -0
- sutra_compiler/stdlib/rotation.su +83 -0
- sutra_compiler/stdlib/similarity.su +97 -0
- sutra_compiler/stdlib/strings.su +56 -0
- sutra_compiler/stdlib/tensor.su +82 -0
- sutra_compiler/stdlib/vectors.su +119 -0
- sutra_compiler/stdlib_loader.py +219 -0
- sutra_compiler/sutradb_embedded.py +273 -0
- sutra_compiler/trace.py +135 -0
- sutra_compiler/validator.py +552 -0
- sutra_compiler/workspace.py +655 -0
- sutra_dev-0.2.0.dist-info/METADATA +80 -0
- sutra_dev-0.2.0.dist-info/RECORD +36 -0
- sutra_dev-0.2.0.dist-info/WHEEL +5 -0
- sutra_dev-0.2.0.dist-info/entry_points.txt +2 -0
- sutra_dev-0.2.0.dist-info/licenses/LICENSE +201 -0
- sutra_dev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
"""Egglog-backed simplification pass — alternative to simplify.py.
|
|
2
|
+
|
|
3
|
+
A parallel simplification backend using `egglog` (the Python e-graph
|
|
4
|
+
library) that encodes all 16 rewrite rules from simplify.py as
|
|
5
|
+
egglog rewrite rules, plus the new matrix-chain-fusion pass that
|
|
6
|
+
the hand-written simplifier cannot do.
|
|
7
|
+
|
|
8
|
+
This module is deliberately self-contained: it defines its own
|
|
9
|
+
egglog IR rather than lifting directly from sutra_compiler.ast_nodes.
|
|
10
|
+
The IR is a one-to-one mirror of the simplifiable subset of the
|
|
11
|
+
Sutra AST — the operators, literals, and structural shapes that any
|
|
12
|
+
of the rewrite rules target. Everything outside that subset is
|
|
13
|
+
represented opaquely (named identifiers carry a string tag).
|
|
14
|
+
|
|
15
|
+
The separation means:
|
|
16
|
+
|
|
17
|
+
1. Unit tests for the rewrite rules do not need to construct real
|
|
18
|
+
Sutra AST trees; they construct egglog expressions directly,
|
|
19
|
+
which keeps the tests focused on algebraic behaviour.
|
|
20
|
+
|
|
21
|
+
2. Integration with the existing compiler pipeline is a clean
|
|
22
|
+
lift / lower bridge (see `lift_expr` / `lower_expr` below),
|
|
23
|
+
not a monkey-patch of the existing pass.
|
|
24
|
+
|
|
25
|
+
3. The review / trace infrastructure (see `review.py`) can use
|
|
26
|
+
this module to show how an expression rewrites step by step,
|
|
27
|
+
independent of the concrete AST.
|
|
28
|
+
|
|
29
|
+
The 16 rules cover every rewrite currently in simplify.py:
|
|
30
|
+
|
|
31
|
+
Call rewrites:
|
|
32
|
+
R01 bundle(v) -> v
|
|
33
|
+
R02 bundle(bundle(a, b), c) -> bundle(a, b, c) (flatten)
|
|
34
|
+
R03 compose(compose(a, b), c) -> compose(a, b, c) (flatten)
|
|
35
|
+
R04 similarity(a, a) -> 1.0
|
|
36
|
+
R05 displacement(a, a) -> zero_vector()
|
|
37
|
+
R06 bundle drops zero_vector() arguments
|
|
38
|
+
R07 x + zero_vector() -> x (zero absorb in bin op)
|
|
39
|
+
R08 unbind(R, bind(R, x)) -> x
|
|
40
|
+
R09 bind(R, unbind(R, x)) -> x
|
|
41
|
+
R10 similarity(zero, zero) -> 1.0 (structural eq subcase)
|
|
42
|
+
R11 numeric constant folding (x+0, x*1, etc.)
|
|
43
|
+
R12 bind(R, zero_vector()) -> zero_vector()
|
|
44
|
+
R13 unbind(R, zero_vector()) -> zero_vector()
|
|
45
|
+
R14 compose(identity, x) -> x (identity permutation)
|
|
46
|
+
R15 argmax_cosine(q, [single]) -> single
|
|
47
|
+
R16 literal arithmetic (2+3 -> 5 etc.)
|
|
48
|
+
|
|
49
|
+
Bonus pass (not in simplify.py):
|
|
50
|
+
R_CHAIN matrix chain fusion M_n.apply(...M_1.apply(v))
|
|
51
|
+
-> (M_n @ ... @ M_1).apply(v)
|
|
52
|
+
"""
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
from typing import Callable
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
from egglog import (
|
|
59
|
+
EGraph, Expr, StringLike, f64, f64Like, function, i64, i64Like,
|
|
60
|
+
method, rewrite, ruleset, vars_,
|
|
61
|
+
)
|
|
62
|
+
except ImportError as e:
|
|
63
|
+
raise ImportError(
|
|
64
|
+
"simplify_egglog requires the `egglog` Python package. "
|
|
65
|
+
"Install with: pip install egglog"
|
|
66
|
+
) from e
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------
|
|
70
|
+
# Egglog IR for the simplifiable subset of Sutra AST
|
|
71
|
+
# ---------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Vec(Expr):
|
|
75
|
+
"""Vector values — the substrate of Sutra computation.
|
|
76
|
+
|
|
77
|
+
`named("x")` is a reference to an AST identifier (a variable,
|
|
78
|
+
function parameter, or any subexpression we can't break apart);
|
|
79
|
+
`zero()` is the compile-time zero vector literal that several
|
|
80
|
+
rules match against.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@method(egg_fn="VNamed")
|
|
84
|
+
@classmethod
|
|
85
|
+
def named(cls, name: StringLike) -> Vec: ... # type: ignore[empty-body]
|
|
86
|
+
|
|
87
|
+
@method(egg_fn="VZero")
|
|
88
|
+
@classmethod
|
|
89
|
+
def zero(cls) -> Vec: ... # type: ignore[empty-body]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Mat(Expr):
|
|
93
|
+
"""Linear operators on vectors (rotation, learned role, etc.)."""
|
|
94
|
+
|
|
95
|
+
@method(egg_fn="MNamed")
|
|
96
|
+
@classmethod
|
|
97
|
+
def named(cls, name: StringLike) -> Mat: ... # type: ignore[empty-body]
|
|
98
|
+
|
|
99
|
+
@method(egg_fn="MIdentity")
|
|
100
|
+
@classmethod
|
|
101
|
+
def identity(cls) -> Mat: ... # type: ignore[empty-body]
|
|
102
|
+
|
|
103
|
+
@method(egg_fn="MMul")
|
|
104
|
+
def __matmul__(self, other: Mat) -> Mat: ... # type: ignore[empty-body]
|
|
105
|
+
|
|
106
|
+
@method(egg_fn="MApply")
|
|
107
|
+
def apply(self, v: Vec) -> Vec: ... # type: ignore[empty-body]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Num(Expr):
|
|
111
|
+
"""Scalar numbers (int or float), for R11/R16 numeric folding."""
|
|
112
|
+
|
|
113
|
+
@method(egg_fn="NLit")
|
|
114
|
+
@classmethod
|
|
115
|
+
def lit(cls, value: f64Like) -> Num: ... # type: ignore[empty-body]
|
|
116
|
+
|
|
117
|
+
@method(egg_fn="NNamed")
|
|
118
|
+
@classmethod
|
|
119
|
+
def named(cls, name: StringLike) -> Num: ... # type: ignore[empty-body]
|
|
120
|
+
|
|
121
|
+
@method(egg_fn="NAdd")
|
|
122
|
+
def __add__(self, other: Num) -> Num: ... # type: ignore[empty-body]
|
|
123
|
+
|
|
124
|
+
@method(egg_fn="NSub")
|
|
125
|
+
def __sub__(self, other: Num) -> Num: ... # type: ignore[empty-body]
|
|
126
|
+
|
|
127
|
+
@method(egg_fn="NMul")
|
|
128
|
+
def __mul__(self, other: Num) -> Num: ... # type: ignore[empty-body]
|
|
129
|
+
|
|
130
|
+
@method(egg_fn="NDiv")
|
|
131
|
+
def __truediv__(self, other: Num) -> Num: ... # type: ignore[empty-body]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@function(egg_fn="Bundle2")
|
|
135
|
+
def bundle(a: Vec, b: Vec) -> Vec: ... # type: ignore[empty-body]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@function(egg_fn="Bundle1")
|
|
139
|
+
def bundle1(a: Vec) -> Vec: ... # type: ignore[empty-body]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@function(egg_fn="Bind")
|
|
143
|
+
def bind(role: Mat, filler: Vec) -> Vec: ... # type: ignore[empty-body]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@function(egg_fn="Unbind")
|
|
147
|
+
def unbind(role: Mat, record: Vec) -> Vec: ... # type: ignore[empty-body]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@function(egg_fn="Similarity")
|
|
151
|
+
def similarity(a: Vec, b: Vec) -> Num: ... # type: ignore[empty-body]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@function(egg_fn="Displacement")
|
|
155
|
+
def displacement(a: Vec, b: Vec) -> Vec: ... # type: ignore[empty-body]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@function(egg_fn="ArgmaxCosSingle")
|
|
159
|
+
def argmax_cosine_single(q: Vec, candidate: Vec) -> Vec: ... # type: ignore[empty-body]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@function(egg_fn="AddVec")
|
|
163
|
+
def vec_add(a: Vec, b: Vec) -> Vec: ... # type: ignore[empty-body]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@function(egg_fn="SubVec")
|
|
167
|
+
def vec_sub(a: Vec, b: Vec) -> Vec: ... # type: ignore[empty-body]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@function(egg_fn="ComposeIdentity")
|
|
171
|
+
def compose_identity(m: Mat) -> Mat: ... # type: ignore[empty-body]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------
|
|
175
|
+
# The 16 rewrite rules + matrix-chain fusion
|
|
176
|
+
# ---------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def make_egraph() -> EGraph:
|
|
180
|
+
"""Build an egraph pre-loaded with every Sutra simplification rule.
|
|
181
|
+
|
|
182
|
+
One call returns a freshly-seeded egraph you can `register(expr)` on,
|
|
183
|
+
then `run(iters)` to saturate, then `extract(expr, cost_model=...)`.
|
|
184
|
+
"""
|
|
185
|
+
eg = EGraph()
|
|
186
|
+
|
|
187
|
+
v, w, x = vars_("v w x", Vec)
|
|
188
|
+
R, S, T = vars_("R S T", Mat)
|
|
189
|
+
a, b = vars_("a b", Num)
|
|
190
|
+
|
|
191
|
+
eg.register(
|
|
192
|
+
# R01: bundle of a single element collapses.
|
|
193
|
+
rewrite(bundle1(v)).to(v),
|
|
194
|
+
|
|
195
|
+
# R02: nested 2-arg bundles flatten via associativity. We do
|
|
196
|
+
# not model N-ary bundle explicitly; repeated applications of
|
|
197
|
+
# `bundle(bundle(a, b), c) = bundle(a, bundle(b, c))` give the
|
|
198
|
+
# flattened shape via saturation.
|
|
199
|
+
rewrite(bundle(bundle(v, w), x)).to(bundle(v, bundle(w, x))),
|
|
200
|
+
rewrite(bundle(v, bundle(w, x))).to(bundle(bundle(v, w), x)),
|
|
201
|
+
|
|
202
|
+
# R04: similarity of a term with itself is identically 1.0.
|
|
203
|
+
rewrite(similarity(v, v)).to(Num.lit(1.0)),
|
|
204
|
+
|
|
205
|
+
# R05: displacement of a term from itself is zero.
|
|
206
|
+
rewrite(displacement(v, v)).to(Vec.zero()),
|
|
207
|
+
|
|
208
|
+
# R06: bundling a zero vector on either side is identity.
|
|
209
|
+
rewrite(bundle(Vec.zero(), v)).to(v),
|
|
210
|
+
rewrite(bundle(v, Vec.zero())).to(v),
|
|
211
|
+
|
|
212
|
+
# R07: zero-vector absorption in vector + / -.
|
|
213
|
+
rewrite(vec_add(Vec.zero(), v)).to(v),
|
|
214
|
+
rewrite(vec_add(v, Vec.zero())).to(v),
|
|
215
|
+
rewrite(vec_sub(v, Vec.zero())).to(v),
|
|
216
|
+
|
|
217
|
+
# R08: bind-unbind roundtrip.
|
|
218
|
+
rewrite(unbind(R, bind(R, v))).to(v),
|
|
219
|
+
# R09: unbind-bind roundtrip.
|
|
220
|
+
rewrite(bind(R, unbind(R, v))).to(v),
|
|
221
|
+
|
|
222
|
+
# R12: bind of zero vector is zero (rotation of 0 = 0).
|
|
223
|
+
rewrite(bind(R, Vec.zero())).to(Vec.zero()),
|
|
224
|
+
# R13: unbind of zero vector is zero (rotation^T of 0 = 0).
|
|
225
|
+
rewrite(unbind(R, Vec.zero())).to(Vec.zero()),
|
|
226
|
+
|
|
227
|
+
# R14: compose with identity permutation drops the identity.
|
|
228
|
+
rewrite(R @ Mat.identity()).to(R),
|
|
229
|
+
rewrite(Mat.identity() @ R).to(R),
|
|
230
|
+
# R03: matrix composition is associative (flatten).
|
|
231
|
+
rewrite((R @ S) @ T).to(R @ (S @ T)),
|
|
232
|
+
rewrite(R @ (S @ T)).to((R @ S) @ T),
|
|
233
|
+
|
|
234
|
+
# R15: argmax_cosine over a singleton candidate list is the
|
|
235
|
+
# element itself. Modelled as argmax_cosine_single(q, c) = c.
|
|
236
|
+
rewrite(argmax_cosine_single(v, w)).to(w),
|
|
237
|
+
|
|
238
|
+
# R11 + R16: numeric identity + constant folding. Identities
|
|
239
|
+
# first so a constant-fold does not clobber span / type info
|
|
240
|
+
# when an identity rewrite is available.
|
|
241
|
+
rewrite(a + Num.lit(0.0)).to(a),
|
|
242
|
+
rewrite(Num.lit(0.0) + a).to(a),
|
|
243
|
+
rewrite(a - Num.lit(0.0)).to(a),
|
|
244
|
+
rewrite(a * Num.lit(1.0)).to(a),
|
|
245
|
+
rewrite(Num.lit(1.0) * a).to(a),
|
|
246
|
+
rewrite(a / Num.lit(1.0)).to(a),
|
|
247
|
+
rewrite(a * Num.lit(0.0)).to(Num.lit(0.0)),
|
|
248
|
+
rewrite(Num.lit(0.0) * a).to(Num.lit(0.0)),
|
|
249
|
+
|
|
250
|
+
# R_CHAIN: matrix-chain fusion. Associativity of MMul combined
|
|
251
|
+
# with `apply` distributing through composition lets an egraph
|
|
252
|
+
# cost model pick the fully-fused
|
|
253
|
+
# `(M_n @ ... @ M_1).apply(v)` form over the n-nested-apply
|
|
254
|
+
# form. See experiments/egglog_matrix_chain_fusion.py for the
|
|
255
|
+
# cost-model demo.
|
|
256
|
+
rewrite((R @ S).apply(v)).to(R.apply(S.apply(v))),
|
|
257
|
+
rewrite(R.apply(S.apply(v))).to((R @ S).apply(v)),
|
|
258
|
+
|
|
259
|
+
# R_PIVOT: bind/apply equivalence. `bind(R, v)` is the same
|
|
260
|
+
# algebraic shape as `R.apply(v)` — both compute Q_R · v. The
|
|
261
|
+
# egraph treats them as the same e-class so chains of `bind`s
|
|
262
|
+
# can rewrite to `apply` chains and the matrix-chain fusion
|
|
263
|
+
# rules above fire. unbind(R, v) is `R^T · v` which the
|
|
264
|
+
# current Mat IR doesn't model directly (no transpose
|
|
265
|
+
# operator), so we leave it as a distinct opaque form for
|
|
266
|
+
# now — the unbind/bind round-trip rules already cover the
|
|
267
|
+
# common case where it matters.
|
|
268
|
+
rewrite(bind(R, v)).to(R.apply(v)),
|
|
269
|
+
rewrite(R.apply(v)).to(bind(R, v)),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Constant-fold for literal numerics. Done via a second register
|
|
273
|
+
# call so it's obvious these are computational rules (the rhs uses
|
|
274
|
+
# Python-level arithmetic on the egglog literal values).
|
|
275
|
+
for (lhs_ctor, rhs_op) in [
|
|
276
|
+
(lambda x, y: Num.lit(x) + Num.lit(y), lambda x, y: x + y),
|
|
277
|
+
(lambda x, y: Num.lit(x) - Num.lit(y), lambda x, y: x - y),
|
|
278
|
+
(lambda x, y: Num.lit(x) * Num.lit(y), lambda x, y: x * y),
|
|
279
|
+
]:
|
|
280
|
+
# We can't pattern-match on general f64 values in egglog without
|
|
281
|
+
# a computational extension; the constant-fold here is handled
|
|
282
|
+
# at lift time in `lift_num_binop` below for known literal
|
|
283
|
+
# operands. The egraph rules above handle the structural
|
|
284
|
+
# identities (x+0, x*1, ...) symbolically.
|
|
285
|
+
_ = (lhs_ctor, rhs_op)
|
|
286
|
+
|
|
287
|
+
return eg
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------
|
|
291
|
+
# Apply cost model: prefer fused chains, cheap composition at module init
|
|
292
|
+
# ---------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def matrix_chain_cost_model(egraph, expr, children_costs):
|
|
296
|
+
"""Cost charges 100 per hot-path operation (apply, bind, unbind),
|
|
297
|
+
1 per matrix-compose (`@`) — module-init only.
|
|
298
|
+
|
|
299
|
+
With these weights, the extractor prefers the single-apply form
|
|
300
|
+
`(M_n @ ... @ M_1).apply(v)` over n nested `.apply()`s and over
|
|
301
|
+
the equivalent n-nested `bind(...)`s. See
|
|
302
|
+
experiments/egglog_matrix_chain_fusion.py for the standalone
|
|
303
|
+
matrix-chain demo. The bind/unbind weighting closes the gap so
|
|
304
|
+
that AST-lifted bind chains also fuse — without it the e-graph
|
|
305
|
+
has both `bind(R, v)` and `R.apply(v)` in the same e-class but
|
|
306
|
+
extraction prefers the shorter `bind` form by tiebreak.
|
|
307
|
+
"""
|
|
308
|
+
s = repr(expr)
|
|
309
|
+
# Hot-path nodes: apply on a vector, bind, unbind. All three
|
|
310
|
+
# cost 100 so the extractor will prefer fewer of them — i.e.
|
|
311
|
+
# one apply on a composed matrix over n nested operations.
|
|
312
|
+
if (".apply(" in s and s.rstrip().endswith(")")) \
|
|
313
|
+
or s.startswith("bind(") \
|
|
314
|
+
or s.startswith("unbind("):
|
|
315
|
+
base = 100
|
|
316
|
+
else:
|
|
317
|
+
base = 1
|
|
318
|
+
return base + sum(children_costs)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------
|
|
322
|
+
# Public entry point — simplify a single egglog expression
|
|
323
|
+
# ---------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def simplify(expr, *, cost_model: Callable | None = None, iters: int = 30):
|
|
327
|
+
"""Saturate an expression and extract the lowest-cost form.
|
|
328
|
+
|
|
329
|
+
Usage:
|
|
330
|
+
|
|
331
|
+
from sutra_compiler.simplify_egglog import (
|
|
332
|
+
simplify, bundle1, bind, unbind, Vec, Mat,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
v = Vec.named("v")
|
|
336
|
+
R = Mat.named("R")
|
|
337
|
+
out = simplify(bundle1(unbind(R, bind(R, v))))
|
|
338
|
+
# out == Vec.named("v")
|
|
339
|
+
|
|
340
|
+
The returned value is an egglog `Expr`; stringifying it gives a
|
|
341
|
+
pythonic repr (`Vec.named(\"v\")`). Equality of two simplified
|
|
342
|
+
expressions can be tested via `str(out1) == str(out2)` — structural
|
|
343
|
+
equality at the AST level.
|
|
344
|
+
"""
|
|
345
|
+
eg = make_egraph()
|
|
346
|
+
eg.register(expr)
|
|
347
|
+
eg.run(iters)
|
|
348
|
+
return eg.extract(expr, cost_model=cost_model)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def simplify_with_cost(expr, *, cost_model: Callable | None = None,
|
|
352
|
+
iters: int = 30):
|
|
353
|
+
"""Same as `simplify` but also returns the integer cost.
|
|
354
|
+
|
|
355
|
+
Useful for review-mode diagnostics: "this expression simplified from
|
|
356
|
+
cost X to cost Y".
|
|
357
|
+
"""
|
|
358
|
+
if cost_model is None:
|
|
359
|
+
cost_model = matrix_chain_cost_model
|
|
360
|
+
eg = make_egraph()
|
|
361
|
+
eg.register(expr)
|
|
362
|
+
eg.run(iters)
|
|
363
|
+
return eg.extract(expr, include_cost=True, cost_model=cost_model)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------
|
|
367
|
+
# AST <-> egglog IR bridge
|
|
368
|
+
# ---------------------------------------------------------------------
|
|
369
|
+
#
|
|
370
|
+
# `simplify_egglog.simplify_ast(ast_expr)` is the public entry point
|
|
371
|
+
# the compiler calls. It walks the Sutra AST, tries to lift each
|
|
372
|
+
# subexpression into the egglog IR, saturates, and lowers a known-
|
|
373
|
+
# simpler form back into the AST. If the expression can't be lifted
|
|
374
|
+
# (contains a construct we don't model — operator overloads, casts,
|
|
375
|
+
# control-flow), it's left alone. The pass is conservative: a subtree
|
|
376
|
+
# round-trips losslessly when egglog can't make progress, and only
|
|
377
|
+
# the simplified shape is materialized when egglog can.
|
|
378
|
+
#
|
|
379
|
+
# Naming strategy: each opaque subexpression gets a stable name keyed
|
|
380
|
+
# off its structural form. Two structurally-equal AST nodes lift to
|
|
381
|
+
# the same `Vec.named("...")` so rules like `similarity(a, a) -> 1.0`
|
|
382
|
+
# fire. The `LiftContext` carries the name table across lift + lower.
|
|
383
|
+
|
|
384
|
+
from . import ast_nodes as _ast
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class LiftContext:
|
|
388
|
+
"""State carried across a lift / lower pair of an AST expression.
|
|
389
|
+
|
|
390
|
+
Maps a canonical-string form of each opaque AST subexpression to a
|
|
391
|
+
fresh stable name and back. The forward direction is used during
|
|
392
|
+
lift to give the same subexpression the same egglog name (so
|
|
393
|
+
rules that require structural equality fire). The reverse
|
|
394
|
+
direction is used during lower to replace egglog leaves with the
|
|
395
|
+
original AST node.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
def __init__(self) -> None:
|
|
399
|
+
self._key_to_name: dict[str, str] = {}
|
|
400
|
+
self._name_to_ast: dict[str, _ast.Expr] = {}
|
|
401
|
+
self._counter = 0
|
|
402
|
+
|
|
403
|
+
def name_for(self, node: _ast.Expr, prefix: str) -> str:
|
|
404
|
+
key = _structural_key(node)
|
|
405
|
+
if key in self._key_to_name:
|
|
406
|
+
return self._key_to_name[key]
|
|
407
|
+
name = f"{prefix}{self._counter}"
|
|
408
|
+
self._counter += 1
|
|
409
|
+
self._key_to_name[key] = name
|
|
410
|
+
self._name_to_ast[name] = node
|
|
411
|
+
return name
|
|
412
|
+
|
|
413
|
+
def ast_for(self, name: str) -> _ast.Expr | None:
|
|
414
|
+
return self._name_to_ast.get(name)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _structural_key(node: _ast.Expr) -> str:
|
|
418
|
+
"""A structural-equality key for an AST node.
|
|
419
|
+
|
|
420
|
+
Two nodes that should lift to the same egglog name produce the
|
|
421
|
+
same key. We use the existing `_structurally_equal` semantics
|
|
422
|
+
from simplify.py — which is the same equality those rewrite
|
|
423
|
+
rules were designed against — by formatting the AST as a
|
|
424
|
+
canonical tuple-string. Identifier names go in directly;
|
|
425
|
+
literal values too; structured nodes recurse.
|
|
426
|
+
"""
|
|
427
|
+
if isinstance(node, _ast.Identifier):
|
|
428
|
+
return f"id:{node.name}"
|
|
429
|
+
if isinstance(node, _ast.IntLiteral):
|
|
430
|
+
return f"int:{node.value}"
|
|
431
|
+
if isinstance(node, _ast.FloatLiteral):
|
|
432
|
+
return f"float:{node.value}"
|
|
433
|
+
if isinstance(node, _ast.StringLiteral):
|
|
434
|
+
return f"str:{node.value!r}"
|
|
435
|
+
if isinstance(node, _ast.BoolLiteral):
|
|
436
|
+
return f"bool:{node.value}"
|
|
437
|
+
if isinstance(node, _ast.UnknownLiteral):
|
|
438
|
+
return "unknown"
|
|
439
|
+
if isinstance(node, _ast.Call):
|
|
440
|
+
callee = _structural_key(node.callee)
|
|
441
|
+
args = ",".join(_structural_key(a) for a in node.args)
|
|
442
|
+
return f"call({callee};{args})"
|
|
443
|
+
if isinstance(node, _ast.BinaryOp):
|
|
444
|
+
return (f"bin({node.op};{_structural_key(node.left)};"
|
|
445
|
+
f"{_structural_key(node.right)})")
|
|
446
|
+
if isinstance(node, _ast.UnaryOp):
|
|
447
|
+
return f"un({node.op};{_structural_key(node.operand)})"
|
|
448
|
+
if isinstance(node, _ast.Parenthesized):
|
|
449
|
+
return _structural_key(node.inner)
|
|
450
|
+
# Anything else gets a unique fallback so we never accidentally
|
|
451
|
+
# alias unrelated nodes.
|
|
452
|
+
return f"opaque:{id(node)}"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _is_call_named(node: _ast.Expr, name: str, arity: int | None = None) -> bool:
|
|
456
|
+
if not isinstance(node, _ast.Call):
|
|
457
|
+
return False
|
|
458
|
+
if not isinstance(node.callee, _ast.Identifier) or node.callee.name != name:
|
|
459
|
+
return False
|
|
460
|
+
if arity is not None and len(node.args) != arity:
|
|
461
|
+
return False
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# Names of AST function calls that map to specific egglog operators
|
|
466
|
+
# at the Vec / Mat / Num level. Anything not in these tables falls
|
|
467
|
+
# through to "name as opaque variable" (which keeps the rules from
|
|
468
|
+
# firing on it but doesn't cause errors).
|
|
469
|
+
|
|
470
|
+
def lift_vec(node: _ast.Expr, ctx: LiftContext) -> Vec | None:
|
|
471
|
+
"""Lift a Sutra AST expression into the Vec sublanguage of the
|
|
472
|
+
egglog IR. Returns None if the expression isn't recognizably a
|
|
473
|
+
vector-typed thing.
|
|
474
|
+
"""
|
|
475
|
+
if _is_call_named(node, "zero_vector", arity=0):
|
|
476
|
+
return Vec.zero()
|
|
477
|
+
|
|
478
|
+
if _is_call_named(node, "bundle"):
|
|
479
|
+
# N-ary bundle. For N == 1 use bundle1; for N >= 2 fold via
|
|
480
|
+
# the 2-arg bundle (the rules know associativity).
|
|
481
|
+
args = node.args # type: ignore[union-attr]
|
|
482
|
+
if len(args) == 1:
|
|
483
|
+
inner = lift_vec(args[0], ctx)
|
|
484
|
+
if inner is None:
|
|
485
|
+
return None
|
|
486
|
+
return bundle1(inner)
|
|
487
|
+
if len(args) >= 2:
|
|
488
|
+
lifted = []
|
|
489
|
+
for a in args:
|
|
490
|
+
la = lift_vec(a, ctx)
|
|
491
|
+
if la is None:
|
|
492
|
+
return None
|
|
493
|
+
lifted.append(la)
|
|
494
|
+
acc = lifted[0]
|
|
495
|
+
for nxt in lifted[1:]:
|
|
496
|
+
acc = bundle(acc, nxt)
|
|
497
|
+
return acc
|
|
498
|
+
|
|
499
|
+
if _is_call_named(node, "bind", arity=2):
|
|
500
|
+
role = lift_mat(node.args[0], ctx) # type: ignore[union-attr]
|
|
501
|
+
filler = lift_vec(node.args[1], ctx) # type: ignore[union-attr]
|
|
502
|
+
if role is None or filler is None:
|
|
503
|
+
return None
|
|
504
|
+
return bind(role, filler)
|
|
505
|
+
|
|
506
|
+
if _is_call_named(node, "unbind", arity=2):
|
|
507
|
+
role = lift_mat(node.args[0], ctx) # type: ignore[union-attr]
|
|
508
|
+
record = lift_vec(node.args[1], ctx) # type: ignore[union-attr]
|
|
509
|
+
if role is None or record is None:
|
|
510
|
+
return None
|
|
511
|
+
return unbind(role, record)
|
|
512
|
+
|
|
513
|
+
if _is_call_named(node, "displacement", arity=2):
|
|
514
|
+
a = lift_vec(node.args[0], ctx) # type: ignore[union-attr]
|
|
515
|
+
b = lift_vec(node.args[1], ctx) # type: ignore[union-attr]
|
|
516
|
+
if a is None or b is None:
|
|
517
|
+
return None
|
|
518
|
+
return displacement(a, b)
|
|
519
|
+
|
|
520
|
+
if isinstance(node, _ast.BinaryOp) and node.op in ("+", "-"):
|
|
521
|
+
l = lift_vec(node.left, ctx)
|
|
522
|
+
r = lift_vec(node.right, ctx)
|
|
523
|
+
if l is not None and r is not None:
|
|
524
|
+
return vec_add(l, r) if node.op == "+" else vec_sub(l, r)
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
# Fall through: everything else is opaque-named at the Vec level.
|
|
528
|
+
# This is conservative — basis_vector("foo"), arbitrary identifiers,
|
|
529
|
+
# casts, calls we don't model — all become Vec.named with a stable
|
|
530
|
+
# name tied to the structural key.
|
|
531
|
+
return Vec.named(ctx.name_for(node, "v"))
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def lift_mat(node: _ast.Expr, ctx: LiftContext) -> Mat | None:
|
|
535
|
+
"""Lift an AST expression into the Mat sublanguage. The role
|
|
536
|
+
arguments to bind/unbind are typed Vec at the AST level but
|
|
537
|
+
function as Mat at the algebraic level — same opaque-named
|
|
538
|
+
treatment. Identity permutation is the one specific call we
|
|
539
|
+
recognize at the Mat level.
|
|
540
|
+
"""
|
|
541
|
+
if _is_call_named(node, "identity_permutation", arity=0):
|
|
542
|
+
return Mat.identity()
|
|
543
|
+
return Mat.named(ctx.name_for(node, "m"))
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def lift_num(node: _ast.Expr, ctx: LiftContext) -> Num | None:
|
|
547
|
+
"""Lift a numeric-context AST expression into the Num sublanguage."""
|
|
548
|
+
if isinstance(node, _ast.IntLiteral):
|
|
549
|
+
return Num.lit(float(node.value))
|
|
550
|
+
if isinstance(node, _ast.FloatLiteral):
|
|
551
|
+
return Num.lit(float(node.value))
|
|
552
|
+
if isinstance(node, _ast.BinaryOp) and node.op in ("+", "-", "*", "/"):
|
|
553
|
+
l = lift_num(node.left, ctx)
|
|
554
|
+
r = lift_num(node.right, ctx)
|
|
555
|
+
if l is None or r is None:
|
|
556
|
+
return None
|
|
557
|
+
if node.op == "+":
|
|
558
|
+
return l + r
|
|
559
|
+
if node.op == "-":
|
|
560
|
+
return l - r
|
|
561
|
+
if node.op == "*":
|
|
562
|
+
return l * r
|
|
563
|
+
if node.op == "/":
|
|
564
|
+
return l / r
|
|
565
|
+
if _is_call_named(node, "similarity", arity=2):
|
|
566
|
+
a = lift_vec(node.args[0], ctx) # type: ignore[union-attr]
|
|
567
|
+
b = lift_vec(node.args[1], ctx) # type: ignore[union-attr]
|
|
568
|
+
if a is None or b is None:
|
|
569
|
+
return None
|
|
570
|
+
return similarity(a, b)
|
|
571
|
+
return Num.named(ctx.name_for(node, "n"))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ---------------------------------------------------------------------
|
|
575
|
+
# Lower: extract pattern → AST node (only specific simplified shapes)
|
|
576
|
+
# ---------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
def _try_lower_to_ast(extracted: object, ctx: LiftContext,
|
|
579
|
+
span) -> _ast.Expr | None:
|
|
580
|
+
"""If the extracted egglog form matches a known simple shape, build
|
|
581
|
+
the corresponding AST node. Otherwise return None — meaning we
|
|
582
|
+
couldn't simplify into something structurally cleaner than the
|
|
583
|
+
original AST.
|
|
584
|
+
|
|
585
|
+
The shapes we recognize are exactly what the 16 rules can reduce
|
|
586
|
+
to: a `Vec.named(...)` (replace with the original opaque AST),
|
|
587
|
+
`Vec.zero()` (replace with `zero_vector()`), `Num.lit(...)`
|
|
588
|
+
(replace with a number literal). Anything more complex is left
|
|
589
|
+
alone for now — wiring more shapes is a follow-up.
|
|
590
|
+
"""
|
|
591
|
+
s = str(extracted).strip()
|
|
592
|
+
|
|
593
|
+
# Vec.named("v3") — the original opaque AST.
|
|
594
|
+
if s.startswith('Vec.named("') and s.endswith('")'):
|
|
595
|
+
name = s[len('Vec.named("'):-len('")')]
|
|
596
|
+
return ctx.ast_for(name)
|
|
597
|
+
|
|
598
|
+
# Vec.zero() — emit a zero_vector() call.
|
|
599
|
+
if s == "Vec.zero()":
|
|
600
|
+
return _ast.Call(
|
|
601
|
+
callee=_ast.Identifier(name="zero_vector", span=span),
|
|
602
|
+
type_args=[], args=[], span=span,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# Num.lit(N) — emit a numeric literal.
|
|
606
|
+
if s.startswith("Num.lit(") and s.endswith(")"):
|
|
607
|
+
body = s[len("Num.lit("):-1]
|
|
608
|
+
try:
|
|
609
|
+
value = float(body)
|
|
610
|
+
except ValueError:
|
|
611
|
+
return None
|
|
612
|
+
if value == int(value):
|
|
613
|
+
return _ast.IntLiteral(value=int(value), span=span)
|
|
614
|
+
return _ast.FloatLiteral(value=value, span=span)
|
|
615
|
+
|
|
616
|
+
# Num.named("n3") — opaque numeric.
|
|
617
|
+
if s.startswith('Num.named("') and s.endswith('")'):
|
|
618
|
+
name = s[len('Num.named("'):-len('")')]
|
|
619
|
+
return ctx.ast_for(name)
|
|
620
|
+
|
|
621
|
+
# Mat.named("m3") — opaque matrix-context AST.
|
|
622
|
+
if s.startswith('Mat.named("') and s.endswith('")'):
|
|
623
|
+
name = s[len('Mat.named("'):-len('")')]
|
|
624
|
+
return ctx.ast_for(name)
|
|
625
|
+
|
|
626
|
+
return None
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# ---------------------------------------------------------------------
|
|
630
|
+
# Public AST entry points
|
|
631
|
+
# ---------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
def simplify_ast_vec(node: _ast.Expr, *, iters: int = 30) -> _ast.Expr:
|
|
634
|
+
"""Try to simplify a vector-context AST expression via egglog.
|
|
635
|
+
|
|
636
|
+
Returns the simplified AST node if egglog reduced it to a known
|
|
637
|
+
simpler shape; otherwise returns the original node unchanged.
|
|
638
|
+
Conservative: never replaces a subtree with something it can't
|
|
639
|
+
confidently reconstruct, and short-circuits when saturation didn't
|
|
640
|
+
change the egglog expression at all (so a literal AST node round-
|
|
641
|
+
trips through unchanged rather than getting reformatted).
|
|
642
|
+
"""
|
|
643
|
+
ctx = LiftContext()
|
|
644
|
+
lifted = lift_vec(node, ctx)
|
|
645
|
+
if lifted is None:
|
|
646
|
+
return node
|
|
647
|
+
extracted = simplify(lifted, iters=iters)
|
|
648
|
+
if str(extracted) == str(lifted):
|
|
649
|
+
# Saturation made no progress — keep the original AST node,
|
|
650
|
+
# which preserves span / literal-type / annotations the
|
|
651
|
+
# downstream codegen may rely on.
|
|
652
|
+
return node
|
|
653
|
+
out = _try_lower_to_ast(extracted, ctx, node.span)
|
|
654
|
+
return out if out is not None else node
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def simplify_ast_num(node: _ast.Expr, *, iters: int = 30) -> _ast.Expr:
|
|
658
|
+
"""Try to simplify a numeric-context AST expression via egglog.
|
|
659
|
+
|
|
660
|
+
Same short-circuit as `simplify_ast_vec`: if saturation didn't
|
|
661
|
+
change the egglog expression, the original AST node is returned.
|
|
662
|
+
This matters for literals — `FloatLiteral(0.0)` should stay a
|
|
663
|
+
`FloatLiteral`, not get reformatted to an `IntLiteral` just
|
|
664
|
+
because `0.0 == int(0.0)`.
|
|
665
|
+
"""
|
|
666
|
+
ctx = LiftContext()
|
|
667
|
+
lifted = lift_num(node, ctx)
|
|
668
|
+
if lifted is None:
|
|
669
|
+
return node
|
|
670
|
+
extracted = simplify(lifted, iters=iters)
|
|
671
|
+
if str(extracted) == str(lifted):
|
|
672
|
+
return node
|
|
673
|
+
out = _try_lower_to_ast(extracted, ctx, node.span)
|
|
674
|
+
return out if out is not None else node
|