pythonfaster 1.8.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,3033 @@
1
+ """Conservative AST transforms for the pythonfaster compilation pipeline.
2
+
3
+ The engine performs value/type inference, object-layout and method-call
4
+ lowering, container unboxing, and selected call/loop rewrites. It returns the
5
+ original source unchanged whenever it cannot safely apply a transformation;
6
+ the compiler owns the subsequent plain-Cython and Python fallbacks.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import copy
12
+ import sys
13
+ from dataclasses import dataclass, field
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Public interface
17
+ # ---------------------------------------------------------------------------
18
+
19
+ @dataclass
20
+ class TransformResult:
21
+ source: str
22
+ changed: bool
23
+ applied: list[str] = field(default_factory=list)
24
+ notes: list[str] = field(default_factory=list)
25
+
26
+
27
+ def transform_source(source: str, level: int = 2,
28
+ *, aggressive: bool = False) -> TransformResult:
29
+ try:
30
+ tree = ast.parse(source)
31
+ except SyntaxError:
32
+ return TransformResult(source=source, changed=False)
33
+ try:
34
+ t = _Transformer(tree)
35
+ if t.run():
36
+ ast.fix_missing_locations(tree)
37
+ # Workaround for Python 3.12.0 ast.unparse bug: when unparsing
38
+ # a full module, function defs adjacent to certain statement
39
+ # patterns (bare-tuple returns, multi-line print) get silently
40
+ # dropped. Unparsing each top-level node individually and
41
+ # joining avoids the bug. Fixed in CPython 3.12.1+.
42
+ if sys.version_info[:2] == (3, 12) and sys.version_info[2] == 0:
43
+ parts = []
44
+ for node in tree.body:
45
+ parts.append(ast.unparse(node))
46
+ source = "\n\n".join(parts)
47
+ else:
48
+ source = ast.unparse(tree)
49
+ # Pass 9 marker swap: __pf_cdef_N = 0 → real cdef blocks
50
+ # (cdef is not valid Python syntax, cannot live in the AST).
51
+ from .recpass import PENDING_CDEFS as _pending
52
+ if _pending:
53
+ for _mk, _cdef_src in _pending.items():
54
+ source = source.replace(f"{_mk} = 0", _cdef_src)
55
+ _pending.clear()
56
+ if "__pb_cdef_marker" in source:
57
+ source = source.replace(
58
+ "__pb_cdef_marker = 0",
59
+ "cdef double[:] __pb_pos = __pb_pos_src\n"
60
+ " cdef double[:] __pb_vel = __pb_vel_src\n"
61
+ " cdef double[:] __pb_mass = __pb_mass_src\n"
62
+ " cdef long[:] __pb_pair = __pb_pair_src")
63
+ # Pass 8 textual layer: typing markers → locals decl, then
64
+ # memoryview bindings for unboxed array('q') creations.
65
+ import re as _re
66
+ _marks = set(_re.findall(r"__pb_type_(\w+) = 0", source))
67
+ if _marks:
68
+ source = _re.sub(r"^ *__pb_type_\w+ = 0\n", "", source, flags=_re.M)
69
+ _decl = ",".join(f"{m}=cython.longlong" for m in sorted(_marks))
70
+ source = _re.sub(r"(@cython\.locals\()",
71
+ rf"\1{_decl},", source, count=1)
72
+ if "__pb_array('q', range(" in source:
73
+ source = _re.sub(
74
+ r"(^ (\w+) = __pb_array\('q', range\([^\n]*\)\)\n)",
75
+ r"\1 cdef long[:] \g<2>_v = \g<2>\n",
76
+ source, flags=_re.M)
77
+ for _base in sorted(set(_re.findall(
78
+ r"^ (\w+) = __pb_array\('q'", source, flags=_re.M))):
79
+ source = _re.sub(
80
+ rf"(?<![_\w]){_base}(?![_\w])(?=\[)",
81
+ f"{_base}_v", source)
82
+ source = source.replace(
83
+ "from array import ", "from array import ", 1)
84
+ if "from array import array as __pb_array" not in source:
85
+ source = "from array import array as __pb_array\n" + source
86
+ return TransformResult(
87
+ source=source, changed=True,
88
+ applied=t.applied_strategies)
89
+ except Exception as exc:
90
+ return TransformResult(
91
+ source=source, changed=False,
92
+ notes=[f"transform aborted: {exc!r}"])
93
+ return TransformResult(source=source, changed=False)
94
+
95
+
96
+ def _is_sum_genexp(node: ast.AST) -> bool:
97
+ """True for `sum(<genexp>)` with a single comprehension, no ifs, no
98
+ async — the only sum shape we loopify."""
99
+ return (
100
+ isinstance(node, ast.Call)
101
+ and isinstance(node.func, ast.Name) and node.func.id == "sum"
102
+ and not node.keywords and len(node.args) == 1
103
+ and isinstance(node.args[0], ast.GeneratorExp)
104
+ and len(node.args[0].generators) == 1
105
+ and not node.args[0].generators[0].ifs
106
+ and not node.args[0].generators[0].is_async
107
+ )
108
+
109
+
110
+ class _StmtList:
111
+ """Marker wrapper: a transformer returns multiple statements in place
112
+ of one; the owner flattens them via _flatten_stmts."""
113
+
114
+ def __init__(self, stmts: list):
115
+ self.stmts = stmts
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Constants & helpers
120
+ # ---------------------------------------------------------------------------
121
+
122
+ _MAX_BITS = 62
123
+ DOUBLE = "cython.double"
124
+ LONGLONG = "cython.longlong"
125
+ OBJECT = "object"
126
+ BINT = "cython.bint"
127
+
128
+ _DYNAMIC_CALLS = frozenset({
129
+ "eval", "exec", "locals", "vars", "globals", "compile",
130
+ "getattr", "setattr", "delattr", "__import__",
131
+ })
132
+
133
+
134
+ def _cheap_expr(node) -> bool:
135
+ """True for expressions that are safe and cheap to evaluate more than
136
+ once (pure, no calls, no attribute/subscript lookups): the only shapes we
137
+ allow when substituting inlined bodies and call-site arguments."""
138
+ if isinstance(node, ast.Name):
139
+ return True
140
+ if isinstance(node, ast.Constant):
141
+ return True
142
+ if isinstance(node, ast.BinOp):
143
+ return _cheap_expr(node.left) and _cheap_expr(node.right)
144
+ if isinstance(node, ast.UnaryOp):
145
+ return _cheap_expr(node.operand)
146
+ return False
147
+
148
+
149
+ def _inlineable_shape(func: ast.FunctionDef) -> dict[str, ast.AST] | None:
150
+ """Return {name -> replacement expr} if `func` is a pure expression
151
+ function safe to inline: plain positional args, straight-line simple
152
+ assignments to fresh names, and a final `return <cheap expr>`.
153
+
154
+ Returns None for anything else (loops, branches, calls, generators,
155
+ defaults, *args, shadowed names...). All exprs must be _cheap_expr so
156
+ duplicated evaluation at the call site is free and side-effect-free.
157
+ """
158
+ if func.decorator_list or func.args.defaults or func.args.kw_defaults \
159
+ or func.args.vararg or func.args.kwarg or func.args.kwonlyargs \
160
+ or func.args.posonlyargs:
161
+ return None
162
+ params = [a.arg for a in func.args.args]
163
+ if not params or len(set(params)) != len(params):
164
+ return None
165
+
166
+ subst: dict[str, ast.AST] = {p: ast.Name(id=p, ctx=ast.Load()) for p in params}
167
+ assigned: set[str] = set()
168
+ stmts = func.body
169
+ if not stmts or not isinstance(stmts[-1], ast.Return) \
170
+ or stmts[-1].value is None:
171
+ return None
172
+ for stmt in stmts[:-1]:
173
+ if not (isinstance(stmt, ast.Assign) and len(stmt.targets) == 1
174
+ and isinstance(stmt.targets[0], ast.Name)):
175
+ return None
176
+ name = stmt.targets[0].id
177
+ if name in params or name in assigned:
178
+ return None # shadowing a param / reassignment: not pure SSA
179
+ val = stmt.value
180
+ if not _cheap_expr(val):
181
+ return None
182
+ # names referenced must be params or earlier temps (no globals,
183
+ # no forward refs); substitute params inside now
184
+ val = ast.copy_location(_subst_names(val, subst), stmt)
185
+ # every referenced name must be known after substitution
186
+ for sub in ast.walk(val):
187
+ if isinstance(sub, ast.Name) and sub.id not in params \
188
+ and sub.id not in assigned:
189
+ return None
190
+ assigned.add(name)
191
+ subst[name] = val
192
+ ret = stmts[-1].value
193
+ if not _cheap_expr(ret):
194
+ return None
195
+ for sub in ast.walk(ret):
196
+ if isinstance(sub, ast.Name) and sub.id not in params \
197
+ and sub.id not in assigned:
198
+ return None
199
+ return subst
200
+
201
+
202
+ def _subst_names(expr: ast.AST, subst: dict[str, ast.AST]) -> ast.AST:
203
+ """Deep-copy `expr`, replacing Name loads found in `subst` in ONE pass.
204
+
205
+ Single-pass is essential: replacement exprs are inserted as-is and not
206
+ re-visited, so a non-idempotent binding (e.g. swapping i<->j at a call
207
+ site like eval_A(j, i)) is applied exactly once. Callers must therefore
208
+ pass replacement exprs that are already fully resolved.
209
+ """
210
+ class _R(ast.NodeTransformer):
211
+ def __init__(self, table):
212
+ self.table = table
213
+
214
+ def visit_Name(self, node: ast.Name) -> ast.AST:
215
+ if isinstance(node.ctx, ast.Load) and node.id in self.table:
216
+ return copy.deepcopy(self.table[node.id])
217
+ return node
218
+ return _R(subst).visit(copy.deepcopy(expr))
219
+
220
+
221
+ def _bit_len(v: int) -> int:
222
+ if v == 0:
223
+ return 1
224
+ return v.bit_length() + (1 if v < 0 else 0)
225
+
226
+
227
+ def _literal_int(node):
228
+ if isinstance(node, ast.Constant) and isinstance(node.value, int) \
229
+ and not isinstance(node.value, bool):
230
+ return node.value
231
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub) \
232
+ and isinstance(node.operand, ast.Constant) \
233
+ and isinstance(node.operand.value, int):
234
+ return -node.operand.value
235
+ return None
236
+
237
+
238
+ def _literal_float(node) -> bool:
239
+ if isinstance(node, ast.Constant) and isinstance(node.value, float):
240
+ return True
241
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
242
+ return _literal_float(node.operand)
243
+ return False
244
+
245
+
246
+ def _call_name(node):
247
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
248
+ return node.func.id
249
+ return None
250
+
251
+
252
+ def _is_range_call(node):
253
+ return _call_name(node) == "range"
254
+
255
+
256
+ def _ctype_to_ast(ctype: str) -> ast.AST:
257
+ parts = ctype.split(".")
258
+ result = ast.Name(id=parts[0], ctx=ast.Load())
259
+ for part in parts[1:]:
260
+ result = ast.Attribute(value=result, attr=part, ctx=ast.Load())
261
+ return result
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Scanner: collect scope-sensitive facts about one function
266
+ # ---------------------------------------------------------------------------
267
+
268
+ class _Scanner:
269
+ """Collects assignment, aug-assign, range-for, and usage data.
270
+
271
+ Never descends into nested scopes (functions/lambdas/classes) for
272
+ assignment tracking; comprehensions are separate scopes.
273
+ """
274
+
275
+ _NESTED = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
276
+ _COMPS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
277
+
278
+ def __init__(self, func: ast.FunctionDef) -> None:
279
+ self.func = func
280
+ self.assigns: dict[str, list[ast.expr]] = {}
281
+ self.aug: dict[str, list[tuple[type, ast.expr]]] = {}
282
+ self.range_fors: dict[str, ast.Call] = {}
283
+ self.opaque_assigned: set[str] = set()
284
+ self.complex_use: set[str] = set()
285
+ self.arg_ctx_bad: set[str] = set()
286
+ self.range_arg_names: set[str] = set()
287
+ self.nested_captures: set[str] = set()
288
+ self.has_divmod = False
289
+ self.dynamic = False
290
+ self.generator = False
291
+ self.bad_except = False
292
+ self.global_nonlocal = False
293
+ self.for_tuple_vars: dict[str, ast.expr] = {}
294
+ # unpacked-var usage classification and numeric-function evidence,
295
+ # computed after the visit pass (see _classify_tuple_vars)
296
+ self.scalar_ok: dict[str, bool] = {}
297
+ self.has_float_evidence = False
298
+ self._arg_names = {x.arg for x in (
299
+ *func.args.posonlyargs, *func.args.args)}
300
+ self._parent: dict[int, ast.AST] = {}
301
+ for node in ast.walk(func):
302
+ for child in ast.iter_child_nodes(node):
303
+ self._parent[id(child)] = node
304
+ for child in ast.iter_child_nodes(func):
305
+ self._visit(child)
306
+ self._classify_tuple_vars()
307
+
308
+ def _classify_tuple_vars(self) -> None:
309
+ """For each flat-unpacked for variable, check whether every use is a
310
+ scalar-numeric context (arithmetic operand / comparison / index /
311
+ container element). A var used as a subscript *base*, attribute base,
312
+ call target/arg or truthiness operand disqualifies (it is a container
313
+ or escapes to unknown code). Also records whether the function shows
314
+ any float evidence (float literal or division), which gates the
315
+ aggressive unpack inference in _Solver.solve Phase 3."""
316
+ self.has_float_evidence = self.has_divmod or any(
317
+ isinstance(n, ast.Constant) and isinstance(n.value, float)
318
+ for n in ast.walk(self.func))
319
+ for name in self.for_tuple_vars:
320
+ ok = True
321
+ used = False
322
+ for node in ast.walk(self.func):
323
+ if not (isinstance(node, ast.Name) and node.id == name
324
+ and isinstance(node.ctx, ast.Load)):
325
+ continue
326
+ used = True
327
+ parent = self.parent_of(node)
328
+ bad = True
329
+ if isinstance(parent, (ast.BinOp, ast.UnaryOp, ast.Compare,
330
+ ast.Tuple, ast.List, ast.Set)):
331
+ bad = False
332
+ elif isinstance(parent, ast.Subscript):
333
+ # index position is fine; container base is not
334
+ bad = not any(n is node for n in ast.walk(parent.slice))
335
+ if bad:
336
+ ok = False
337
+ break
338
+ # vars never read after unpacking carry no type evidence; typing
339
+ # them double would TypeError on list/tuple values (nbody r/v1/v2)
340
+ self.scalar_ok[name] = ok and used
341
+
342
+ def parent_of(self, node):
343
+ return self._parent.get(id(node))
344
+
345
+ def _visit(self, node) -> None:
346
+ if isinstance(node, self._NESTED):
347
+ for sub in ast.walk(node):
348
+ if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load):
349
+ self.nested_captures.add(sub.id)
350
+ if sub.id in self._arg_names:
351
+ self.arg_ctx_bad.add(sub.id)
352
+ elif _call_name(sub) in _DYNAMIC_CALLS:
353
+ self.dynamic = True
354
+ elif isinstance(sub, ast.BinOp) and isinstance(
355
+ sub.op, (ast.Div, ast.FloorDiv, ast.Mod)):
356
+ self.has_divmod = True
357
+ return
358
+ if isinstance(node, self._COMPS):
359
+ for sub in ast.walk(node):
360
+ if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load) \
361
+ and sub.id in self._arg_names:
362
+ self.arg_ctx_bad.add(sub.id)
363
+ elif _call_name(sub) in _DYNAMIC_CALLS:
364
+ self.dynamic = True
365
+ return
366
+
367
+ if isinstance(node, (ast.Yield, ast.YieldFrom)):
368
+ self.generator = True
369
+ elif isinstance(node, (ast.Global, ast.Nonlocal)):
370
+ self.global_nonlocal = True
371
+ elif isinstance(node, ast.Call):
372
+ name = _call_name(node)
373
+ if name in _DYNAMIC_CALLS:
374
+ self.dynamic = True
375
+ if name == "range":
376
+ for arg in node.args:
377
+ if isinstance(arg, ast.Name):
378
+ self.range_arg_names.add(arg.id)
379
+ elif isinstance(node, ast.Assign):
380
+ for target in node.targets:
381
+ if isinstance(target, ast.Name):
382
+ self.assigns.setdefault(target.id, []).append(node.value)
383
+ elif isinstance(target, (ast.Tuple, ast.List)) \
384
+ and isinstance(node.value, ast.Tuple) \
385
+ and len(target.elts) == len(node.value.elts) \
386
+ and all(isinstance(e, ast.Name) for e in target.elts):
387
+ for elt, val in zip(target.elts, node.value.elts):
388
+ self.assigns.setdefault(elt.id, []).append(val)
389
+ elif isinstance(target, ast.Subscript) \
390
+ and isinstance(target.value, ast.Name):
391
+ # xs[i] = value: only the base name is opaque,
392
+ # the index variable is NOT being assigned to.
393
+ self.opaque_assigned.add(target.value.id)
394
+ else:
395
+ for sub in ast.walk(target):
396
+ if isinstance(sub, ast.Name):
397
+ self.opaque_assigned.add(sub.id)
398
+ elif isinstance(node, ast.AnnAssign):
399
+ if isinstance(node.target, ast.Name) and node.value is not None:
400
+ self.assigns.setdefault(node.target.id, []).append(node.value)
401
+ elif isinstance(node.target, ast.Subscript) \
402
+ and isinstance(node.target.value, ast.Name):
403
+ self.opaque_assigned.add(node.target.value.id)
404
+ elif not isinstance(node.target, ast.Name):
405
+ for sub in ast.walk(node.target):
406
+ if isinstance(sub, ast.Name):
407
+ self.opaque_assigned.add(sub.id)
408
+ elif isinstance(node, ast.AugAssign):
409
+ if isinstance(node.target, ast.Name):
410
+ self.aug.setdefault(node.target.id, []).append(
411
+ (type(node.op), node.value))
412
+ elif isinstance(node.target, ast.Subscript) \
413
+ and isinstance(node.target.value, ast.Name):
414
+ # xs[i] -= value: only the base name is opaque,
415
+ # the index variable is NOT being assigned to.
416
+ self.opaque_assigned.add(node.target.value.id)
417
+ else:
418
+ for sub in ast.walk(node.target):
419
+ if isinstance(sub, ast.Name):
420
+ self.opaque_assigned.add(sub.id)
421
+ elif isinstance(node, ast.For):
422
+ if _is_range_call(node.iter) and isinstance(node.target, ast.Name):
423
+ self.range_fors[node.target.id] = node.iter
424
+ else:
425
+ # Try flat unpacking
426
+ flat = self._flatten_target(node.target)
427
+ if flat:
428
+ for nm in flat:
429
+ self.for_tuple_vars[nm] = node.iter
430
+ else:
431
+ for sub in ast.walk(node.target):
432
+ if isinstance(sub, ast.Name):
433
+ self.opaque_assigned.add(sub.id)
434
+ for sub in ast.walk(node.iter):
435
+ if isinstance(sub, ast.Name) and sub.id in self._arg_names:
436
+ self.arg_ctx_bad.add(sub.id)
437
+ elif isinstance(node, ast.BinOp) and isinstance(
438
+ node.op, (ast.Div, ast.FloorDiv, ast.Mod)):
439
+ self.has_divmod = True
440
+ elif isinstance(node, ast.Attribute):
441
+ if isinstance(node.value, ast.Name):
442
+ self.complex_use.add(node.value.id)
443
+ if node.value.id in self._arg_names:
444
+ self.arg_ctx_bad.add(node.value.id)
445
+ elif isinstance(node, (ast.With, )):
446
+ for item in node.items:
447
+ if item.optional_vars is not None:
448
+ for sub in ast.walk(item.optional_vars):
449
+ if isinstance(sub, ast.Name):
450
+ self.opaque_assigned.add(sub.id)
451
+ elif isinstance(node, ast.Delete):
452
+ for t in node.targets:
453
+ if isinstance(t, ast.Name):
454
+ self.complex_use.add(t.id)
455
+
456
+ # Arg usage context checks
457
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) \
458
+ and node.id in self._arg_names:
459
+ parent = self.parent_of(node)
460
+ if isinstance(parent, (ast.Starred, ast.Await, ast.Yield,
461
+ ast.YieldFrom, ast.Delete)):
462
+ self.arg_ctx_bad.add(node.id)
463
+ elif isinstance(parent, ast.For) and parent.iter is node:
464
+ self.arg_ctx_bad.add(node.id)
465
+ elif isinstance(parent, ast.Call) and parent.func is node:
466
+ self.arg_ctx_bad.add(node.id)
467
+ elif isinstance(parent, ast.Subscript):
468
+ self.arg_ctx_bad.add(node.id)
469
+
470
+ for child in ast.iter_child_nodes(node):
471
+ self._visit(child)
472
+
473
+ @staticmethod
474
+ def _flatten_target(target):
475
+ if isinstance(target, ast.Name):
476
+ return [target.id]
477
+ if isinstance(target, (ast.Tuple, ast.List)):
478
+ result = []
479
+ for elt in target.elts:
480
+ flat = _Scanner._flatten_target(elt)
481
+ if flat is None:
482
+ return None
483
+ result.extend(flat)
484
+ return result
485
+ return None
486
+
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # Solver: fixed-point type inference for one function
490
+ # ---------------------------------------------------------------------------
491
+
492
+ class _Solver:
493
+ """Runs the least-fixed-point int/float analysis on a _Scanner."""
494
+
495
+ def __init__(self, sc: _Scanner) -> None:
496
+ self.sc = sc
497
+ self.env_int: dict[str, int] = {} # name → bit bound
498
+ self.env_int_any: set[str] = set() # name → int (unbounded)
499
+ self.env_float: set[str] = set() # name → float
500
+
501
+ def is_float_expr(self, node) -> bool:
502
+ if _literal_float(node):
503
+ return True
504
+ if isinstance(node, ast.Name) and node.id in self.env_float:
505
+ # propagate through already-solved float vars (fixed-point safe:
506
+ # solve() re-evaluates until stable)
507
+ return True
508
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
509
+ return True
510
+ if _call_name(node) == "float":
511
+ return True
512
+ if isinstance(node, ast.BinOp):
513
+ return self.is_float_expr(node.left) or self.is_float_expr(node.right)
514
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
515
+ return self.is_float_expr(node.operand)
516
+ if isinstance(node, ast.IfExp):
517
+ return self.is_float_expr(node.body) or self.is_float_expr(node.orelse)
518
+ return False
519
+
520
+ def _is_int_expr(self, node) -> bool:
521
+ if self.int_bits(node) is not None:
522
+ return True
523
+ if isinstance(node, ast.Name):
524
+ return (node.id in self.env_int_any
525
+ or node.id in self.sc.range_fors
526
+ or node.id in self.sc.range_arg_names)
527
+ if _call_name(node) == "int":
528
+ return True
529
+ if isinstance(node, ast.BinOp) and isinstance(
530
+ node.op, (ast.Add, ast.Sub, ast.Mult)):
531
+ return self._is_int_expr(node.left) and self._is_int_expr(node.right)
532
+ if isinstance(node, ast.UnaryOp) and isinstance(
533
+ node.op, (ast.USub, ast.UAdd)):
534
+ return self._is_int_expr(node.operand)
535
+ if isinstance(node, ast.IfExp):
536
+ return self._is_int_expr(node.body) and self._is_int_expr(node.orelse)
537
+ return False
538
+
539
+ def int_bits(self, node):
540
+ """Return bit bound b (|value| < 2**b) if provably bounded int."""
541
+ v = _literal_int(node)
542
+ if v is not None:
543
+ return _bit_len(v)
544
+ if isinstance(node, ast.Name):
545
+ return self.env_int.get(node.id)
546
+ if _call_name(node) == "len":
547
+ return 60
548
+ if _call_name(node) == "abs":
549
+ return self.int_bits(node.args[0]) if len(node.args) == 1 else None
550
+ if isinstance(node, ast.BinOp):
551
+ op = node.op
552
+ if isinstance(op, (ast.Add, ast.Sub)):
553
+ lb, rb = self.int_bits(node.left), self.int_bits(node.right)
554
+ if lb is not None and rb is not None:
555
+ return min(max(lb, rb) + 1, 63)
556
+ return None
557
+ if isinstance(op, ast.Mult):
558
+ lb, rb = self.int_bits(node.left), self.int_bits(node.right)
559
+ if lb is not None and rb is not None:
560
+ return min(lb + rb, 63)
561
+ return None
562
+ if isinstance(op, ast.FloorDiv):
563
+ lb = self.int_bits(node.left)
564
+ rv = _literal_int(node.right)
565
+ if lb is not None and rv is not None and rv != 0:
566
+ return lb
567
+ return None
568
+ if isinstance(op, ast.Mod):
569
+ rv = _literal_int(node.right)
570
+ if rv is not None and rv > 0:
571
+ return _bit_len(rv - 1)
572
+ return None
573
+ return None
574
+ if isinstance(node, ast.UnaryOp) and isinstance(
575
+ node.op, (ast.USub, ast.UAdd)):
576
+ inner = self.int_bits(node.operand)
577
+ return inner + 1 if inner is not None else None
578
+ return None
579
+
580
+ def induction_bits(self, name) -> int | None:
581
+ rng = self.sc.range_fors.get(name)
582
+ if rng is None:
583
+ return None
584
+ bounds = [self.int_bits(a) for a in rng.args]
585
+ if not bounds or any(b is None for b in bounds):
586
+ if all(self._is_int_expr(a) for a in rng.args):
587
+ return 63
588
+ return None
589
+ return max(bounds)
590
+
591
+ def solve(self) -> None:
592
+ sc = self.sc
593
+ # Phase 0: seed range-for variables
594
+ for rname in sc.range_fors:
595
+ if rname in sc.opaque_assigned or rname in sc.complex_use:
596
+ continue
597
+ if rname in self.env_int:
598
+ continue
599
+ bits = self.induction_bits(rname)
600
+ if bits is not None and bits <= 63:
601
+ self.env_int[rname] = min(bits, _MAX_BITS)
602
+
603
+ # Phase 1: int analysis (fixed-point, 4 iterations)
604
+ # Iterate over the union of assigns and aug keys so that
605
+ # variables only modified via +=/-= (never directly assigned
606
+ # with =) are also processed — e.g. binary_trees' `depth` param.
607
+ all_names = set(sc.assigns.keys()) | set(sc.aug.keys())
608
+ for _ in range(4):
609
+ changed = False
610
+ for name in all_names:
611
+ if name in sc.opaque_assigned or name in sc.complex_use:
612
+ continue
613
+ if name in self.env_float or name in self.env_int \
614
+ or name in self.env_int_any:
615
+ continue
616
+ values = sc.assigns.get(name, [])
617
+ aug_vals = [v for _, v in sc.aug.get(name, [])]
618
+ all_v = list(values) + aug_vals
619
+ if not all_v:
620
+ continue
621
+ bits = [self.int_bits(v) for v in all_v]
622
+ if all(b is not None for b in bits):
623
+ safe_aug = all(
624
+ op in (ast.Add, ast.Sub, ast.Mult)
625
+ for op, _ in sc.aug.get(name, []))
626
+ if safe_aug:
627
+ bound = min(max(bits) +
628
+ (1 if sc.aug.get(name) else 0), 63)
629
+ if bound <= 63:
630
+ self.env_int[name] = min(bound, _MAX_BITS)
631
+ changed = True
632
+ elif all(self._is_int_expr(v) for v in all_v) and all(
633
+ op in (ast.Add, ast.Sub, ast.Mult)
634
+ for op, _ in sc.aug.get(name, [])):
635
+ self.env_int_any.add(name)
636
+ changed = True
637
+ if not changed:
638
+ break
639
+
640
+ # Phase 2 + Phase 3: float analysis, iterated to a fixed point so
641
+ # inferences propagate through the name chain (mag -> b1m -> dx ...).
642
+ # Max 5 rounds; each round only adds names so it terminates.
643
+ for _round in range(5):
644
+ changed = False
645
+ for name in all_names:
646
+ if name in sc.opaque_assigned or name in sc.complex_use:
647
+ continue
648
+ if name in self.env_float or name in self.env_int \
649
+ or name in self.env_int_any:
650
+ continue
651
+ values = sc.assigns.get(name, [])
652
+ aug_vals = [v for _, v in sc.aug.get(name, [])]
653
+ all_v = list(values) + aug_vals
654
+ if any(self.is_float_expr(v) for v in all_v):
655
+ all_numeric = True
656
+ for v in all_v:
657
+ if not (self.is_float_expr(v) or self._is_int_expr(v)):
658
+ all_numeric = False
659
+ break
660
+ if all_numeric:
661
+ self.env_float.add(name)
662
+ changed = True
663
+
664
+ # Phase 3: for-tuple-var float inference
665
+ for name, iter_expr in sc.for_tuple_vars.items():
666
+ if name in self.env_float or name in self.env_int \
667
+ or name in sc.opaque_assigned or name in sc.complex_use:
668
+ continue
669
+ # If iterating over a list of floats (list literal or list comp)
670
+ if isinstance(iter_expr, (ast.List, ast.Tuple)) and iter_expr.elts:
671
+ if any(self.is_float_expr(e) for e in iter_expr.elts):
672
+ self.env_float.add(name)
673
+ changed = True
674
+ elif isinstance(iter_expr, ast.ListComp):
675
+ if self.is_float_expr(iter_expr.elt):
676
+ self.env_float.add(name)
677
+ changed = True
678
+ elif isinstance(iter_expr, (ast.Name, ast.Attribute, ast.Call,
679
+ ast.Subscript)):
680
+ # Aggressive: iterating over an opaque sequence (e.g. a
681
+ # precomputed pairs list). If every use of the var is a
682
+ # scalar-numeric context and the function shows float
683
+ # evidence, type the var as double. Cython coerces the
684
+ # unpacked PyObject once per iteration; int values used in
685
+ # float math are converted losslessly for |v| < 2^53.
686
+ if sc.scalar_ok.get(name) and sc.has_float_evidence:
687
+ self.env_float.add(name)
688
+ changed = True
689
+ if not changed:
690
+ break
691
+
692
+ @property
693
+ def typed_locals(self) -> dict[str, str]:
694
+ result = {}
695
+ for name, bits in self.env_int.items():
696
+ result[name] = LONGLONG
697
+ for name in self.env_int_any:
698
+ result[name] = LONGLONG
699
+ for name in self.env_float:
700
+ result[name] = DOUBLE
701
+ return result
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # ClassPlan
706
+ # ---------------------------------------------------------------------------
707
+
708
+ @dataclass
709
+ class ClassPlan:
710
+ node: ast.ClassDef
711
+ name: str
712
+ skip: bool = True
713
+ reason: str = ""
714
+ all_attrs: dict[str, str] = field(default_factory=dict)
715
+ needs_dict: bool = False
716
+ cfunc_methods: dict[str, str] = field(default_factory=dict) # method → fast_name
717
+
718
+
719
+ # ---------------------------------------------------------------------------
720
+ # Main Transformer
721
+ # ---------------------------------------------------------------------------
722
+
723
+
724
+ # -----------------------------------------------------------------
725
+ # Pass 7: SoA container unboxing (dev-validated in /tmp/unbox_dev)
726
+ # Pattern: module dict of 3-tuples -> list(dict.values()) -> pairs.
727
+ # Rewrites iterating functions to flat typed arrays behind a runtime
728
+ # snapshot guard with copy-in/copy-out sync and full fallback to the
729
+ # original functions (externally observable state stays CPython-identical).
730
+ # -----------------------------------------------------------------
731
+
732
+ class _PbFail(Exception):
733
+ pass
734
+
735
+
736
+ def _L(name):
737
+ return ast.Name(id=name, ctx=ast.Load())
738
+
739
+
740
+ def _slice_of(base, off=None):
741
+ """base: ('name', n) or ('floordiv', n, k); off: int|None -> AST index expr."""
742
+ kind = base[0]
743
+ if kind == 'name':
744
+ inner = _L(base[1])
745
+ elif kind == 'floordiv':
746
+ inner = ast.BinOp(left=_L(base[1]), op=ast.FloorDiv(),
747
+ right=ast.Constant(value=base[2]))
748
+ else:
749
+ raise _PbFail("base kind")
750
+ if off is None:
751
+ return inner
752
+ return ast.BinOp(left=inner, op=ast.Add(), right=ast.Constant(value=off))
753
+
754
+
755
+ def _rd(arr, base, off):
756
+ return ast.Subscript(value=_L(arr), slice=_slice_of(base, off), ctx=ast.Load())
757
+
758
+
759
+ def _wr(arr, base, flat):
760
+ return ast.Subscript(value=_L(arr), slice=_slice_of(base, flat), ctx=ast.Store())
761
+
762
+
763
+ def _pb_mk_num_expr(tree: ast.Module):
764
+ """Returns num_expr(e) -> bool: numeric-constant expression detector that
765
+ accepts literals, unary minus, and arithmetic over module-level numeric
766
+ constants (PI * 2, X * SOLAR_MASS...)."""
767
+ num_consts = set()
768
+ pending = []
769
+ for mod in tree.body:
770
+ if isinstance(mod, ast.Assign) and len(mod.targets) == 1 \
771
+ and isinstance(mod.targets[0], ast.Name):
772
+ mv = mod.value
773
+ if isinstance(mv, ast.Constant) and isinstance(mv.value, (int, float)) \
774
+ and not isinstance(mv.value, bool):
775
+ num_consts.add(mod.targets[0].id)
776
+ elif isinstance(mv, ast.UnaryOp) and isinstance(mv.operand, ast.Constant) \
777
+ and isinstance(mv.operand.value, (int, float)):
778
+ num_consts.add(mod.targets[0].id)
779
+ elif isinstance(mv, (ast.BinOp, ast.UnaryOp)):
780
+ pending.append((mod.targets[0].id, mv))
781
+ # fixpoint: allow arithmetic over already-numeric module constants
782
+ changed = True
783
+ while changed and pending:
784
+ changed = False
785
+ rest = []
786
+ for name, mv in pending:
787
+ names = {n.id for n in ast.walk(mv) if isinstance(n, ast.Name)}
788
+ _ALLOWED = (ast.Name, ast.Constant, ast.BinOp, ast.UnaryOp,
789
+ ast.operator, ast.unaryop, ast.expr_context)
790
+ kinds_ok = all(isinstance(n, _ALLOWED) for n in ast.walk(mv))
791
+ consts_ok = all(isinstance(n.value, (int, float))
792
+ and not isinstance(n.value, bool)
793
+ for n in ast.walk(mv) if isinstance(n, ast.Constant))
794
+ if kinds_ok and consts_ok and names <= num_consts:
795
+ num_consts.add(name)
796
+ changed = True
797
+ else:
798
+ rest.append((name, mv))
799
+ pending = rest
800
+
801
+ def num_expr(e):
802
+ if isinstance(e, ast.Constant):
803
+ return isinstance(e.value, (int, float)) and not isinstance(e.value, bool)
804
+ if isinstance(e, ast.Name):
805
+ return e.id in num_consts
806
+ if isinstance(e, ast.List):
807
+ return (len(e.elts) == 3
808
+ and all(num_expr(c) for c in e.elts))
809
+ if isinstance(e, ast.UnaryOp):
810
+ return num_expr(e.operand)
811
+ if isinstance(e, ast.BinOp):
812
+ return num_expr(e.left) and num_expr(e.right)
813
+ return False
814
+ return num_expr
815
+
816
+
817
+ def _pb_unbox_tree(tree: ast.Module, applied: list, cdefs: bool = False) -> bool:
818
+ # ── locate dict / system / pairs ───────────────────────────────────
819
+ dict_name = system_name = pairs_name = None
820
+ num_expr_cached = _pb_mk_num_expr(tree)
821
+ for node in tree.body:
822
+ if not (isinstance(node, ast.Assign) and len(node.targets) == 1
823
+ and isinstance(node.targets[0], ast.Name)):
824
+ continue
825
+ v = node.value
826
+ if not (isinstance(v, ast.Dict) and v.keys
827
+ and all(isinstance(k, ast.Constant) and isinstance(k.value, str)
828
+ for k in v.keys)):
829
+ continue
830
+ ok = True
831
+ for val in v.values:
832
+ if not (isinstance(val, ast.Tuple) and len(val.elts) == 3):
833
+ ok = False; break
834
+ for e in val.elts:
835
+ good = num_expr_cached(e)
836
+ if not good:
837
+ ok = False; break
838
+ if not ok:
839
+ break
840
+ if ok:
841
+ dict_name = node.targets[0].id
842
+ break
843
+ if dict_name is None:
844
+ return False
845
+ for node in tree.body:
846
+ if (isinstance(node, ast.Assign) and len(node.targets) == 1
847
+ and isinstance(node.targets[0], ast.Name)
848
+ and isinstance(node.value, ast.Call)
849
+ and isinstance(node.value.func, ast.Name)
850
+ and node.value.func.id == "list"
851
+ and len(node.value.args) == 1):
852
+ a0 = node.value.args[0]
853
+ base = None
854
+ if isinstance(a0, ast.Attribute) and a0.attr == "values":
855
+ base = a0.value # DICT.values
856
+ elif (isinstance(a0, ast.Call)
857
+ and isinstance(a0.func, ast.Attribute)
858
+ and a0.func.attr == "values"
859
+ and not a0.args and not a0.keywords):
860
+ base = a0.func.value # DICT.values()
861
+ if (base is not None and isinstance(base, ast.Name)
862
+ and base.id == dict_name):
863
+ system_name = node.targets[0].id
864
+ break
865
+ if system_name is None:
866
+ return False
867
+ for node in tree.body:
868
+ if (isinstance(node, ast.Assign) and len(node.targets) == 1
869
+ and isinstance(node.targets[0], ast.Name)
870
+ and node.targets[0].id not in (dict_name, system_name)):
871
+ names = {n.id for n in ast.walk(node.value) if isinstance(n, ast.Name)}
872
+ if system_name in names:
873
+ pairs_name = node.targets[0].id
874
+ break
875
+ if pairs_name is None:
876
+ return False
877
+
878
+ # ── rewrite each eligible function ─────────────────────────────────
879
+ rewrites = []
880
+ for node in tree.body:
881
+ if isinstance(node, ast.FunctionDef):
882
+ fast = _pb_rewrite_function(node, system_name, pairs_name, cdefs)
883
+ if fast is not None:
884
+ rewrites.append((node, fast))
885
+ if not rewrites:
886
+ return False
887
+ for orig, fast in rewrites:
888
+ orig.name = "__pb_orig_" + orig.name
889
+ tree.body.insert(tree.body.index(orig) + 1, fast)
890
+ tree.body.extend(_pb_build_snapshot(system_name, pairs_name))
891
+ applied.append("soa-unbox")
892
+ return True
893
+
894
+
895
+ def _pb_build_snapshot(system_name, pairs_name):
896
+ SRC = f'''
897
+ class _PbFallback(Exception):
898
+ pass
899
+ from array import array as __pb_array
900
+ __pb_pos = __pb_array('d')
901
+ __pb_vel = __pb_array('d')
902
+ __pb_mass = __pb_array('d')
903
+ __pb_pair = __pb_array('l')
904
+ __pb_idx_of = {{}}
905
+ __pb_nb = 0
906
+ __pb_npair = 0
907
+ __pb_ok = False
908
+ try:
909
+ __pb_nb = len({system_name})
910
+ for __bi in range(__pb_nb):
911
+ __b = {system_name}[__bi]
912
+ __pb_idx_of[id(__b)] = __bi
913
+ __pb_pos.extend(__b[0])
914
+ __pb_vel.extend(__b[1])
915
+ __pb_mass.append(__b[2])
916
+ for __p in {pairs_name}:
917
+ __pb_pair.append(__pb_idx_of[id(__p[0])])
918
+ __pb_pair.append(__pb_idx_of[id(__p[1])])
919
+ __pb_npair = len({pairs_name})
920
+ __pb_ok = True
921
+ except Exception:
922
+ __pb_ok = False
923
+ # aliases for cdef memoryview pickup inside rewritten functions
924
+ __pb_pos_src = __pb_pos
925
+ __pb_vel_src = __pb_vel
926
+ __pb_mass_src = __pb_mass
927
+ __pb_pair_src = __pb_pair
928
+ '''
929
+ return ast.parse(SRC).body
930
+
931
+
932
+ def _pb_rewrite_function(fn: ast.FunctionDef, system_name, pairs_name, cdefs=False):
933
+ refs = {n.id for n in ast.walk(fn) if isinstance(n, ast.Name)}
934
+ if system_name not in refs and pairs_name not in refs:
935
+ return None
936
+ params = [a.arg for a in fn.args.args]
937
+ # params whose default IS the canonical system/pairs object are treated
938
+ # as aliases of it inside this function (entry guard guarantees identity)
939
+ alias = {}
940
+ args_list = fn.args.args
941
+ defaults = fn.args.defaults
942
+ if defaults and len(defaults) <= len(args_list):
943
+ for a, dv in zip(args_list[len(args_list) - len(defaults):], defaults):
944
+ if isinstance(dv, ast.Name) and dv.id == system_name:
945
+ alias[a.arg] = system_name
946
+ elif isinstance(dv, ast.Name) and dv.id == pairs_name:
947
+ alias[a.arg] = pairs_name
948
+
949
+ cur: dict[str, tuple] = {}
950
+
951
+ def t_expr(e):
952
+ if isinstance(e, ast.Name):
953
+ b = cur.get(e.id)
954
+ if b is None:
955
+ return copy.deepcopy(e)
956
+ arr, base, off = b
957
+ return _rd(arr, base, off)
958
+ if isinstance(e, ast.Constant):
959
+ return copy.deepcopy(e)
960
+ if isinstance(e, ast.BinOp):
961
+ return ast.BinOp(left=t_expr(e.left), op=copy.deepcopy(e.op),
962
+ right=t_expr(e.right))
963
+ if isinstance(e, ast.UnaryOp):
964
+ return ast.UnaryOp(op=copy.deepcopy(e.op), operand=t_expr(e.operand))
965
+ if isinstance(e, ast.Call):
966
+ return ast.Call(func=t_expr(e.func),
967
+ args=[t_expr(a) for a in e.args],
968
+ keywords=[copy.deepcopy(k) for k in e.keywords])
969
+ if isinstance(e, ast.Attribute):
970
+ return ast.Attribute(value=t_expr(e.value), attr=e.attr, ctx=ast.Load())
971
+ raise _PbFail(f"expr {type(e).__name__}")
972
+
973
+ def t_sub_store(t):
974
+ # t: Subscript(Name, const-int) bound to a list slot
975
+ b = cur.get(t.value.id)
976
+ if b is None or b[2] is not None:
977
+ raise _PbFail("bad store target")
978
+ if not (isinstance(t.slice, ast.Constant) and isinstance(t.slice.value, int)
979
+ and not isinstance(t.slice.value, bool)):
980
+ raise _PbFail("non-const index")
981
+ arr, base, _ = b
982
+ return arr, base, t.slice.value
983
+
984
+ def t_stmts(s) -> list:
985
+ if isinstance(s, ast.For):
986
+ return t_for(s)
987
+ if isinstance(s, ast.Assign) and len(s.targets) == 1:
988
+ return t_assign(s)
989
+ if isinstance(s, ast.AugAssign):
990
+ return [t_augassign(s)]
991
+ if isinstance(s, ast.Expr):
992
+ return [ast.Expr(value=t_expr(s.value))]
993
+ if isinstance(s, ast.Return):
994
+ return [ast.Return(value=t_expr(s.value) if s.value is not None else None)]
995
+ if isinstance(s, ast.Pass):
996
+ return [s]
997
+ raise _PbFail(f"stmt {type(s).__name__}")
998
+
999
+ def t_for(node):
1000
+ it = node.iter
1001
+ # canonical iteration source: module name or guarded param alias
1002
+ src = None
1003
+ if isinstance(it, ast.Name):
1004
+ if it.id == pairs_name or alias.get(it.id) == pairs_name:
1005
+ src = 'pairs'
1006
+ elif it.id == system_name or alias.get(it.id) == system_name:
1007
+ src = 'bodies'
1008
+ # pairs loop
1009
+ if src == 'pairs':
1010
+ tA, tB = node.target.elts
1011
+ ok_unpack = (isinstance(node.target, ast.Tuple)
1012
+ and len(node.target.elts) == 2
1013
+ and all(isinstance(x, ast.Tuple) and len(x.elts) == 3
1014
+ and isinstance(x.elts[0], (ast.List, ast.Tuple))
1015
+ and len(x.elts[0].elts) == 3
1016
+ and all(isinstance(y, ast.Name) for y in x.elts[0].elts)
1017
+ and isinstance(x.elts[1], ast.Name)
1018
+ and isinstance(x.elts[2], ast.Name)
1019
+ for x in node.target.elts))
1020
+ if not ok_unpack:
1021
+ raise _PbFail("pairs unpack")
1022
+ saved = dict(cur)
1023
+ for k, nm in enumerate(tA.elts[0].elts):
1024
+ cur[nm.id] = ("__pb_pos", ("name", "__pb_a3"), k)
1025
+ cur[tA.elts[1].id] = ("__pb_vel", ("name", "__pb_a3"), None)
1026
+ cur[tA.elts[2].id] = ("__pb_mass", ("name", "__pb_a"), None)
1027
+ for k, nm in enumerate(tB.elts[0].elts):
1028
+ cur[nm.id] = ("__pb_pos", ("name", "__pb_b3"), k)
1029
+ cur[tB.elts[1].id] = ("__pb_vel", ("name", "__pb_b3"), None)
1030
+ cur[tB.elts[2].id] = ("__pb_mass", ("name", "__pb_b"), None)
1031
+ body = []
1032
+ for sub in node.body:
1033
+ body.extend(t_stmts(sub))
1034
+ cur.clear(); cur.update(saved)
1035
+ pre = ast.parse(
1036
+ "__pb_a = __pb_pair[2 * __pb_pi]\n"
1037
+ "__pb_b = __pb_pair[2 * __pb_pi + 1]\n"
1038
+ "__pb_a3 = __pb_a * 3\n"
1039
+ "__pb_b3 = __pb_b * 3\n").body
1040
+ return [ast.For(
1041
+ target=ast.Name(id="__pb_pi", ctx=ast.Store()),
1042
+ iter=ast.Call(func=_L("range"), args=[_L("__pb_npair")], keywords=[]),
1043
+ body=pre + body, orelse=[])]
1044
+ # bodies loop
1045
+ if src == 'bodies':
1046
+ t = node.target
1047
+ if not (isinstance(t, ast.Tuple) and len(t.elts) == 3
1048
+ and isinstance(t.elts[0], ast.Name)
1049
+ and isinstance(t.elts[1], ast.List) and len(t.elts[1].elts) == 3
1050
+ and all(isinstance(y, ast.Name) for y in t.elts[1].elts)
1051
+ and isinstance(t.elts[2], ast.Name)):
1052
+ raise _PbFail("bodies unpack")
1053
+ saved = dict(cur)
1054
+ cur[t.elts[0].id] = ("__pb_pos", ("name", "__pb_k3"), None)
1055
+ for k2, nm in enumerate(t.elts[1].elts):
1056
+ cur[nm.id] = ("__pb_vel", ("name", "__pb_k3"), k2)
1057
+ cur[t.elts[2].id] = ("__pb_mass", ("floordiv", "__pb_k3", 3), None)
1058
+ body = []
1059
+ for sub in node.body:
1060
+ body.extend(t_stmts(sub))
1061
+ cur.clear(); cur.update(saved)
1062
+ return [ast.For(
1063
+ target=ast.Name(id="__pb_k3", ctx=ast.Store()),
1064
+ iter=ast.Call(func=_L("range"),
1065
+ args=[ast.Constant(value=0),
1066
+ ast.BinOp(left=_L("__pb_nb"), op=ast.Mult(),
1067
+ right=ast.Constant(value=3)),
1068
+ ast.Constant(value=3)],
1069
+ keywords=[]),
1070
+ body=body, orelse=[])]
1071
+ # generic range loop: keep, translate body
1072
+ if isinstance(it, ast.Call) and isinstance(it.func, ast.Name) \
1073
+ and it.func.id == "range":
1074
+ body = []
1075
+ for sub in node.body:
1076
+ body.extend(t_stmts(sub))
1077
+ return [ast.For(target=copy.deepcopy(node.target),
1078
+ iter=copy.deepcopy(it), body=body, orelse=[])]
1079
+ raise _PbFail("for iter")
1080
+
1081
+ def t_assign(s):
1082
+ t = s.targets[0]
1083
+ # ref-unpack: (r, v, m) = someparam
1084
+ if isinstance(t, ast.Tuple) and isinstance(s.value, ast.Name) \
1085
+ and s.value.id in params:
1086
+ if not (len(t.elts) == 3 and isinstance(t.elts[0], ast.Name)
1087
+ and isinstance(t.elts[1], ast.Name) and isinstance(t.elts[2], ast.Name)):
1088
+ raise _PbFail("ref unpack")
1089
+ cur[t.elts[0].id] = ("__pb_pos", ("name", "__pb_ri3"), None)
1090
+ cur[t.elts[1].id] = ("__pb_vel", ("name", "__pb_ri3"), None)
1091
+ cur[t.elts[2].id] = ("__pb_mass", ("name", "__pb_ri"), None)
1092
+ return ast.parse(
1093
+ f"__pb_ri = __pb_idx_of.get(id({s.value.id}), -1)\n"
1094
+ "if __pb_ri < 0:\n"
1095
+ " raise _PbFallback()\n"
1096
+ "__pb_ri3 = __pb_ri * 3\n").body
1097
+ if isinstance(t, ast.Name):
1098
+ return [ast.Assign(targets=[copy.deepcopy(t)], value=t_expr(s.value))]
1099
+ if isinstance(t, ast.Subscript) and isinstance(t.value, ast.Name):
1100
+ arr, base, flat = t_sub_store(t)
1101
+ return [ast.Assign(targets=[_wr(arr, base, flat)], value=t_expr(s.value))]
1102
+ raise _PbFail("assign")
1103
+
1104
+ def t_augassign(s):
1105
+ t = s.target
1106
+ if isinstance(t, ast.Name):
1107
+ return ast.AugAssign(target=copy.deepcopy(t), op=copy.deepcopy(s.op),
1108
+ value=t_expr(s.value))
1109
+ if isinstance(t, ast.Subscript) and isinstance(t.value, ast.Name):
1110
+ arr, base, flat = t_sub_store(t)
1111
+ return ast.AugAssign(target=_wr(arr, base, flat), op=copy.deepcopy(s.op),
1112
+ value=t_expr(s.value))
1113
+ raise _PbFail("augassign")
1114
+
1115
+ try:
1116
+ new_body = []
1117
+ for s in fn.body:
1118
+ new_body.extend(t_stmts(s))
1119
+ except _PbFail:
1120
+ return None
1121
+
1122
+ # entry guard: not ok / args differ from canonical → call original
1123
+ guard_tests = [ast.UnaryOp(op=ast.Not(), operand=_L("__pb_ok"))]
1124
+ args_list = fn.args.args
1125
+ defaults = fn.args.defaults
1126
+ bodies_param = pairs_param = None
1127
+ if defaults and len(defaults) <= len(args_list):
1128
+ for a, dv in zip(args_list[len(args_list) - len(defaults):], defaults):
1129
+ if isinstance(dv, ast.Name) and dv.id in (system_name, pairs_name):
1130
+ guard_tests.append(ast.Compare(left=_L(a.arg), ops=[ast.IsNot()],
1131
+ comparators=[_L(dv.id)]))
1132
+ if dv.id == system_name:
1133
+ bodies_param = a.arg
1134
+ else:
1135
+ pairs_param = a.arg
1136
+ # entry sync (copy-in): pull the CURRENT values of the canonical
1137
+ # containers into the flat arrays so in-place mutations between calls
1138
+ # are visible; re-derive pair indices by identity, miss → fallback.
1139
+ # exit sync (copy-out): write mutated pos/vel back so external readers
1140
+ # observing the original lists see the same state CPython would leave.
1141
+ if bodies_param is None:
1142
+ bodies_param = system_name
1143
+ len_checks = [f"len({bodies_param}) != __pb_nb"]
1144
+ pairs_sync = ""
1145
+ if pairs_param is not None:
1146
+ len_checks.append(f"len({pairs_param}) != __pb_npair")
1147
+ pairs_sync = (
1148
+ f"for __pb_pj in range(__pb_npair):\n"
1149
+ f" __pb_ia = __pb_idx_of.get(id({pairs_param}[__pb_pj][0]), -1)\n"
1150
+ f" __pb_ib = __pb_idx_of.get(id({pairs_param}[__pb_pj][1]), -1)\n"
1151
+ f" if __pb_ia < 0 or __pb_ib < 0:\n"
1152
+ f" raise _PbFallback()\n"
1153
+ f" __pb_pair[2 * __pb_pj] = __pb_ia\n"
1154
+ f" __pb_pair[2 * __pb_pj + 1] = __pb_ib\n")
1155
+ entry_sync = ast.parse(
1156
+ f"if {' or '.join(len_checks)}:\n"
1157
+ f" raise _PbFallback()\n"
1158
+ f"for __pb_ci in range(__pb_nb):\n"
1159
+ f" __pb_b = {bodies_param}[__pb_ci]\n"
1160
+ f" __pb_pos[__pb_ci * 3 + 0] = __pb_b[0][0]\n"
1161
+ f" __pb_pos[__pb_ci * 3 + 1] = __pb_b[0][1]\n"
1162
+ f" __pb_pos[__pb_ci * 3 + 2] = __pb_b[0][2]\n"
1163
+ f" __pb_vel[__pb_ci * 3 + 0] = __pb_b[1][0]\n"
1164
+ f" __pb_vel[__pb_ci * 3 + 1] = __pb_b[1][1]\n"
1165
+ f" __pb_vel[__pb_ci * 3 + 2] = __pb_b[1][2]\n"
1166
+ f" __pb_mass[__pb_ci] = __pb_b[2]\n"
1167
+ f"{pairs_sync}").body
1168
+ exit_sync = ast.parse(
1169
+ f"for __pb_co in range(__pb_nb):\n"
1170
+ f" __pb_b = {bodies_param}[__pb_co]\n"
1171
+ f" __pb_b[0][0] = __pb_pos[__pb_co * 3 + 0]\n"
1172
+ f" __pb_b[0][1] = __pb_pos[__pb_co * 3 + 1]\n"
1173
+ f" __pb_b[0][2] = __pb_pos[__pb_co * 3 + 2]\n"
1174
+ f" __pb_b[1][0] = __pb_vel[__pb_co * 3 + 0]\n"
1175
+ f" __pb_b[1][1] = __pb_vel[__pb_co * 3 + 1]\n"
1176
+ f" __pb_b[1][2] = __pb_vel[__pb_co * 3 + 2]\n").body
1177
+ test = ast.BoolOp(op=ast.Or(), values=guard_tests)
1178
+ fallback_call = ast.Return(value=ast.Call(
1179
+ func=_L("__pb_orig_" + fn.name),
1180
+ args=[_L(a) for a in params], keywords=[]))
1181
+ guard_if = ast.If(test=test, body=[fallback_call], orelse=[])
1182
+ # try/except _PbFallback → original (for ref-identity misses)
1183
+ try_body = entry_sync + new_body + exit_sync
1184
+ catch = [ast.Return(value=ast.Call(
1185
+ func=_L("__pb_orig_" + fn.name),
1186
+ args=[_L(a) for a in params], keywords=[]))]
1187
+ wrapped = ast.Try(
1188
+ body=try_body,
1189
+ handlers=[ast.ExceptHandler(type=_L("_PbFallback"), name=None, body=catch)],
1190
+ orelse=[], finalbody=[])
1191
+
1192
+ dec = ast.parse(
1193
+ "@cython.boundscheck(False)\n"
1194
+ "@cython.wraparound(False)\n"
1195
+ "@cython.cdivision(True)\n"
1196
+ "@cython.locals(__pb_pi=cython.longlong, __pb_a=cython.longlong, "
1197
+ "__pb_ci=cython.longlong, __pb_co=cython.longlong, __pb_pj=cython.longlong, "
1198
+ "__pb_ia=cython.longlong, __pb_ib=cython.longlong, "
1199
+ "__pb_a3=cython.longlong, __pb_b3=cython.longlong, "
1200
+ "__pb_k3=cython.longlong, __pb_ri=cython.longlong, __pb_ri3=cython.longlong, "
1201
+ "dt=cython.double, dx=cython.double, dy=cython.double, dz=cython.double, "
1202
+ "mag=cython.double, b1m=cython.double, b2m=cython.double, "
1203
+ "m1=cython.double, m2=cython.double, vx=cython.double, vy=cython.double, "
1204
+ "vz=cython.double, px=cython.double, py=cython.double, pz=cython.double, "
1205
+ "e=cython.double, x1=cython.double, x2=cython.double, y1=cython.double, "
1206
+ "y2=cython.double, z1=cython.double, z2=cython.double)\n"
1207
+ "def _x():\n pass\n").body[0].decorator_list
1208
+
1209
+ # cdef memoryviews give C-speed element access on the array('d'/'l') buffers.
1210
+ # Python's ast cannot parse "cdef" (Cython syntax), so we emit a textual
1211
+ # marker statement; the pyx builder string-replaces it before compilation.
1212
+ # (plain assignment is harmless on the pure-Python semantic path)
1213
+ mv = [ast.parse("__pb_cdef_marker = 0").body[0]]
1214
+
1215
+ fast = ast.FunctionDef(
1216
+ name=fn.name, args=copy.deepcopy(fn.args),
1217
+ body=mv + [guard_if, wrapped], decorator_list=dec,
1218
+ type_comment=None, lineno=fn.lineno, col_offset=fn.col_offset)
1219
+ return fast
1220
+
1221
+
1222
+
1223
+
1224
+
1225
+ # -----------------------------------------------------------------
1226
+ # Pass 8: integer-list unboxing (fannkuch shape, dev-validated)
1227
+ # V=list(range(n)) + slice-flip P[:k+1]=P[k::-1] + bound insert/pop
1228
+ # rotate. Rewrites to array('q') + loops + typed views. Markers:
1229
+ # __pb_type_X = 0 → add X=cython.longlong to locals (stripped pre-unparse)
1230
+ # -----------------------------------------------------------------
1231
+
1232
+ _FK_PASS_VARS = ("__pb_i", "__pb_j", "__pb_t", "__pb_p0")
1233
+
1234
+
1235
+ class _FkFail(Exception):
1236
+ pass
1237
+
1238
+
1239
+ def _fk_unbox_tree(tree, applied):
1240
+ changed = False
1241
+ for fn in [n for n in tree.body if isinstance(n, ast.FunctionDef)]:
1242
+ try:
1243
+ changed |= _fk_rewrite_fn(fn)
1244
+ except _FkFail:
1245
+ continue
1246
+ if changed:
1247
+ applied.append("fannkuch-unbox")
1248
+ return changed
1249
+
1250
+
1251
+ def _fk_rewrite_fn(fn):
1252
+ changed = False
1253
+ used = {n.id for n in ast.walk(fn) if isinstance(n, ast.Name)}
1254
+ if any(v in used for v in _FK_PASS_VARS):
1255
+ return False
1256
+
1257
+ # ── 1. flip-loopify ─────────────────────────────────────────────
1258
+ flips = _fk_find_flips(fn)
1259
+ for stmt, var, kupper, kval in flips:
1260
+ _fk_replace_stmt(fn, stmt, _fk_build_swap(var, kval))
1261
+ changed = True
1262
+
1263
+ # ── 2. rotate-loop: X_ins(R, X_pop(0)) → shift ──────────────────
1264
+ binds = {} # name -> (listvar, method)
1265
+ bind_stmts = {} # bindname -> the Assign stmt (to drop after rewrite)
1266
+ for node in ast.walk(fn):
1267
+ if (isinstance(node, ast.Assign) and len(node.targets) == 1
1268
+ and isinstance(node.targets[0], ast.Name)
1269
+ and isinstance(node.value, ast.Attribute)
1270
+ and isinstance(node.value.value, ast.Name)
1271
+ and node.value.attr in ("insert", "pop")):
1272
+ binds[node.targets[0].id] = (node.value.value.id, node.value.attr)
1273
+ bind_stmts[node.targets[0].id] = node
1274
+ rewrites = []
1275
+ for node in ast.walk(fn):
1276
+ call = node.value if isinstance(node, (ast.Expr, ast.Assign)) else None
1277
+ if not (isinstance(call, ast.Call)
1278
+ and isinstance(call.func, ast.Name)
1279
+ and call.func.id in binds
1280
+ and len(call.args) == 2
1281
+ and isinstance(call.args[1], ast.Call)
1282
+ and isinstance(call.args[1].func, ast.Name)
1283
+ and call.args[1].func.id in binds):
1284
+ continue
1285
+ ins_bind = binds[call.func.id]
1286
+ pop_bind = binds[call.args[1].func.id]
1287
+ if not (ins_bind[1] == "insert" and pop_bind[1] == "pop"
1288
+ and ins_bind[0] == pop_bind[0]
1289
+ and (call.args[1].args == []
1290
+ or (len(call.args[1].args) == 1
1291
+ and isinstance(call.args[1].args[0], ast.Constant)
1292
+ and call.args[1].args[0].value == 0))):
1293
+ continue
1294
+ if not isinstance(node, ast.Expr):
1295
+ raise _FkFail("rotate result assigned")
1296
+ # X_ins(R, X_pop(0)) → __pb_p0 = X[0]
1297
+ # for __pb_i in range(R): X[__pb_i] = X[__pb_i + 1]
1298
+ # X[R] = __pb_p0
1299
+ X, R = ins_bind[0], call.args[0]
1300
+ shift = ast.parse(
1301
+ f"__pb_p0 = {X}[0]\n"
1302
+ "for __pb_i in range(RP):\n"
1303
+ f" {X}[__pb_i] = {X}[__pb_i + 1]\n"
1304
+ f"{X}[RP] = __pb_p0\n")
1305
+ shift.body[1].iter.args[0] = R
1306
+ shift.body[2].targets[0].slice = R
1307
+ rewrites.append((node, shift.body))
1308
+ changed = True
1309
+ for old, new in rewrites:
1310
+ _fk_replace_stmt(fn, old, new)
1311
+ # drop the now-unused bind assignments
1312
+ for name, stmt in bind_stmts.items():
1313
+ still_used = any(isinstance(n, ast.Name) and n.id == name
1314
+ for n in ast.walk(fn) if n is not stmt.targets[0])
1315
+ if not still_used:
1316
+ try:
1317
+ _fk_replace_stmt(fn, stmt, [])
1318
+ except _FkFail:
1319
+ pass
1320
+
1321
+ # ── 3. array-unbox: list(range) vars → array('q', range) ────────
1322
+ rng_vars = set()
1323
+ for node in ast.walk(fn):
1324
+ if (isinstance(node, ast.Assign) and len(node.targets) == 1
1325
+ and isinstance(node.targets[0], ast.Name)
1326
+ and isinstance(node.value, ast.Call)
1327
+ and isinstance(node.value.func, ast.Name)
1328
+ and node.value.func.id == "list"
1329
+ and len(node.value.args) == 1
1330
+ and isinstance(node.value.args[0], ast.Call)
1331
+ and isinstance(node.value.args[0].func, ast.Name)
1332
+ and node.value.args[0].func.id == "range"):
1333
+ rng_vars.add(node.targets[0].id)
1334
+ ok_vars = {v for v in rng_vars if _fk_array_safe(fn, v)}
1335
+ for node in ast.walk(fn):
1336
+ if (isinstance(node, ast.Assign) and len(node.targets) == 1
1337
+ and isinstance(node.targets[0], ast.Name)
1338
+ and node.targets[0].id in ok_vars
1339
+ and isinstance(node.value, ast.Call)
1340
+ and isinstance(node.value.func, ast.Name)
1341
+ and node.value.func.id == "list"):
1342
+ rng_call = node.value.args[0]
1343
+ node.value = ast.Call(
1344
+ func=ast.Name(id="__pb_array", ctx=ast.Load()),
1345
+ args=[ast.Constant(value="q"), rng_call], keywords=[])
1346
+ changed = True
1347
+ if changed:
1348
+ # 3a. slice-copy W = V[:] (both unboxed) → elementwise copy loop
1349
+ # (avoids per-flip array object + view allocation in compiled form;
1350
+ # identical semantics for any sequence of equal length)
1351
+ unboxed = ok_vars
1352
+ for node in ast.walk(fn):
1353
+ if not (isinstance(node, ast.Assign) and len(node.targets) == 1
1354
+ and isinstance(node.targets[0], ast.Name)
1355
+ and isinstance(node.value, ast.Subscript)
1356
+ and isinstance(node.value.value, ast.Name)
1357
+ and isinstance(node.value.slice, ast.Slice)
1358
+ and node.value.slice.lower is None
1359
+ and node.value.slice.upper is None
1360
+ and node.value.slice.step is None
1361
+ and node.targets[0].id in unboxed
1362
+ and node.value.value.id in unboxed):
1363
+ continue
1364
+ src_name = node.value.value.id
1365
+ dst_name = node.targets[0].id
1366
+ node.value = ast.parse(
1367
+ f"[0] * len({src_name})", mode="eval").body # placeholder; replaced below
1368
+ # build: for __pb_i in range(len(S)): D[__pb_i] = S[__pb_i]
1369
+ loop = ast.parse(
1370
+ "for __pb_i in range(0):\n"
1371
+ f" {dst_name}[__pb_i] = {src_name}[__pb_i]\n")
1372
+ loop.body[0].iter.args[0] = ast.Call(
1373
+ func=ast.Name(id="len", ctx=ast.Load()),
1374
+ args=[ast.Name(id=src_name, ctx=ast.Load())], keywords=[])
1375
+ # replace the (now placeholder) assign stmt with loop
1376
+ _fk_replace_stmt(fn, node, loop.body)
1377
+ # 3b. register loop temps for typing (markers consumed by engine hook)
1378
+ k_candidates = set()
1379
+ for node in ast.walk(fn):
1380
+ if (isinstance(node, ast.Assign) and len(node.targets) == 1
1381
+ and isinstance(node.targets[0], ast.Name)
1382
+ and node.targets[0].id in ok_vars
1383
+ and isinstance(node.value, ast.Call)
1384
+ and isinstance(node.value.func, ast.Name)
1385
+ and node.value.func.id == "__pb_array"):
1386
+ r0 = node.value.args[1].args[0] if (
1387
+ isinstance(node.value.args[1], ast.Call)
1388
+ and isinstance(node.value.args[1].func, ast.Name)
1389
+ and node.value.args[1].func.id == "range"
1390
+ and len(node.value.args[1].args) == 1) else None
1391
+ if isinstance(r0, ast.Name):
1392
+ k_candidates.add(r0.id)
1393
+ for node in ast.walk(fn):
1394
+ if (isinstance(node, ast.Assign) and len(node.targets) == 1
1395
+ and isinstance(node.targets[0], ast.Name)
1396
+ and isinstance(node.value, ast.Subscript)
1397
+ and isinstance(node.value.value, ast.Name)
1398
+ and node.value.value.id in ok_vars):
1399
+ k_candidates.add(node.targets[0].id)
1400
+ for t in tuple({"__pb_i", "__pb_j", "__pb_t", "__pb_p0"} | k_candidates):
1401
+ if any(isinstance(n, ast.Name) and n.id == t for n in ast.walk(fn)):
1402
+ fn.body.append(ast.parse(f"__pb_type_{t} = 0"))
1403
+ return changed
1404
+
1405
+
1406
+ def _fk_array_safe(fn, v):
1407
+ """All uses of v: element get/set, slice-copy source/target, len arg,
1408
+ or a bound-method capture (insert/pop) whose calls were rewritten."""
1409
+ for node in ast.walk(fn):
1410
+ if isinstance(node, ast.Subscript):
1411
+ if isinstance(node.value, ast.Name) and node.value.id == v:
1412
+ continue # element get/set (any slice is copy form)
1413
+ continue
1414
+ if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) \
1415
+ and node.value.id == v and node.attr in ("insert", "pop"):
1416
+ continue # bound-method capture, handled by rotate pass
1417
+ if isinstance(node, ast.Compare) and isinstance(node.left, ast.Name) \
1418
+ and node.left.id == v:
1419
+ return False
1420
+ if isinstance(node, ast.BinOp) and isinstance(node.left, ast.Name) \
1421
+ and node.left.id == v:
1422
+ return False
1423
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \
1424
+ and node.func.id != "len":
1425
+ for a in node.args:
1426
+ if isinstance(a, ast.Name) and a.id == v:
1427
+ return False # passed to arbitrary function
1428
+ return True
1429
+
1430
+
1431
+ # ---- shared helpers (same as fk_dev) ------------------------------------
1432
+
1433
+ def _fk_find_flips(fn):
1434
+ flips = []
1435
+ for node in ast.walk(fn):
1436
+ if not (isinstance(node, ast.Assign) and len(node.targets) == 1):
1437
+ continue
1438
+ t, v = node.targets[0], node.value
1439
+ if (isinstance(t, ast.Subscript) and isinstance(t.value, ast.Name)
1440
+ and isinstance(t.slice, ast.Slice) and _fk_is_from0(t.slice)
1441
+ and isinstance(v, ast.Subscript) and isinstance(v.value, ast.Name)
1442
+ and isinstance(v.slice, ast.Slice) and _fk_is_rev_full(v.slice)
1443
+ and v.value.id == t.value.id):
1444
+ flips.append((node, t.value.id, t.slice.upper, v.slice.lower))
1445
+ return flips
1446
+
1447
+
1448
+ def _fk_is_from0(sl):
1449
+ return (sl.lower is None
1450
+ or (isinstance(sl.lower, ast.Constant) and sl.lower.value == 0)) \
1451
+ and sl.upper is not None and sl.step is None
1452
+
1453
+
1454
+ def _fk_is_rev_full(sl):
1455
+ if sl.upper is not None:
1456
+ return False
1457
+ st = sl.step
1458
+ if isinstance(st, ast.Constant):
1459
+ return st.value == -1
1460
+ return (isinstance(st, ast.UnaryOp) and isinstance(st.op, ast.USub)
1461
+ and isinstance(st.operand, ast.Constant) and st.operand.value == 1)
1462
+
1463
+
1464
+ def _fk_build_swap(var, kval):
1465
+ swap = ast.parse(
1466
+ "__pb_i = 0\n"
1467
+ "__pb_j = 0\n"
1468
+ "while __pb_i < __pb_j:\n"
1469
+ f" __pb_t = {var}[__pb_i]\n"
1470
+ f" {var}[__pb_i] = {var}[__pb_j]\n"
1471
+ f" {var}[__pb_j] = __pb_t\n"
1472
+ " __pb_i += 1\n"
1473
+ " __pb_j -= 1\n")
1474
+ swap.body[1].value = kval
1475
+ return swap.body
1476
+
1477
+
1478
+ def _fk_replace_stmt(fn, old, new_stmts):
1479
+ def rec(body):
1480
+ for i, s in enumerate(body):
1481
+ if s is old:
1482
+ body[i:i + 1] = new_stmts
1483
+ return True
1484
+ for field in ("body", "orelse", "finalbody"):
1485
+ sub = getattr(s, field, None)
1486
+ if isinstance(sub, list) and rec(sub):
1487
+ return True
1488
+ for h in getattr(s, "handlers", []):
1489
+ if isinstance(h.body, list) and rec(h.body):
1490
+ return True
1491
+ return False
1492
+ if not rec(fn.body):
1493
+ raise _FkFail("stmt not found")
1494
+
1495
+
1496
+ class _Transformer:
1497
+ """Orchestrates type inference + cdef class + cfunc transforms."""
1498
+
1499
+ def __init__(self, tree: ast.Module):
1500
+ self.tree = tree
1501
+ self.class_plans: dict[int, ClassPlan] = {}
1502
+ self.cdef_class_names: set[str] = set()
1503
+ self.applied_strategies: list[str] = []
1504
+ # Map: function name → solved local types (for cross-function arg inference)
1505
+ self.func_types: dict[str, dict[str, str]] = {}
1506
+ # Map: function name → set of arg names that are int/float
1507
+ self.func_arg_types: dict[str, dict[str, str]] = {}
1508
+ # Map: function name → var names the loopify pass wants typed double
1509
+ # (index-loop value vars whose element expr carries float evidence)
1510
+ self.loopify_float_vars: dict[str, set[str]] = {}
1511
+
1512
+ def run(self) -> bool:
1513
+ # Pass 0: conservative expression-function inlining (V8-style call
1514
+ # boundary elimination, but proven statically instead of speculated).
1515
+ # Runs BEFORE loopify so sum(genexp) elements become cheap arithmetic
1516
+ # (inlined callees) instead of calls that loopify must reject.
1517
+ self._inline_functions()
1518
+
1519
+ # Pass 1: sum(genexp) → typed accumulator loop (kills the boxed
1520
+ # generator protocol; before type solving so the solver sees the loop)
1521
+ if self._loopify_sums():
1522
+ self.applied_strategies.append("sum-loopify")
1523
+
1524
+ # Analyse classes (collect cdef candidates before type solving)
1525
+ self._analyse_classes()
1526
+
1527
+ # Pass 2: pre-solve all module-level functions to get local var types
1528
+ self._pre_solve_functions()
1529
+
1530
+ # Pass 3: infer attribute types using solved function types
1531
+ if self.cdef_class_names:
1532
+ self._infer_attr_types_from_calls()
1533
+ self._check_arithmetic_safety()
1534
+ self._detect_needs_dict()
1535
+
1536
+ # Pass 4: infer loop variable object types (needed before cfunc gen)
1537
+ self._infer_loop_var_types()
1538
+
1539
+ # Pass 5: transform classes (cdef + cfunc only for called methods)
1540
+ if self.cdef_class_names:
1541
+ self._transform_classes()
1542
+
1543
+ # Pass 6: transform functions (directives + locals + cfunc call rewrite)
1544
+ self._transform_functions()
1545
+
1546
+ # Pass 7: SoA container unboxing (nbody shape: dict-of-tuples ->
1547
+ # list(values) -> pairs). Runs last so all other rewrites have
1548
+ # settled; failures are silent no-ops (pattern simply not found).
1549
+ try:
1550
+ _pb_unbox_tree(self.tree, self.applied_strategies)
1551
+ except Exception:
1552
+ pass
1553
+
1554
+ # Pass 8: integer-list unboxing (fannkuch shape). Also last.
1555
+ try:
1556
+ _fk_unbox_tree(self.tree, self.applied_strategies)
1557
+ except Exception:
1558
+ pass
1559
+
1560
+ # Pass 9 + 10: recursive-function cdef lowering and pure-function
1561
+ # sum-LICM folding (binary_trees shape). Uses func_arg_types
1562
+ # solved in Pass 2; failures are silent no-ops.
1563
+ try:
1564
+ from .recpass import apply_recursive_passes
1565
+ # int-return proof: reuse solver data — a pure recursive
1566
+ # candidate returns int when every return expr is int-typed.
1567
+ int_returns = set()
1568
+ for fname, ftypes in self.func_types.items():
1569
+ if any(v == LONGLONG for v in ftypes.values()):
1570
+ pass # per-var info only; int-return computed below
1571
+ for node in self.tree.body:
1572
+ if isinstance(node, ast.FunctionDef):
1573
+ sc = _Scanner(node)
1574
+ sol = _Solver(sc)
1575
+ sol.solve()
1576
+ rets = [s.value for s in node.body
1577
+ if isinstance(s, ast.Return) and s.value]
1578
+ if (not sc.dynamic and not sc.generator
1579
+ and rets
1580
+ and all(sol._is_int_expr(r) or
1581
+ sol.int_bits(r) is not None
1582
+ for r in rets)):
1583
+ int_returns.add(node.name)
1584
+ tags = apply_recursive_passes(self.tree,
1585
+ self.func_arg_types,
1586
+ int_returns)
1587
+ self.applied_strategies.extend(tags)
1588
+ except Exception:
1589
+ pass
1590
+
1591
+ # Add `import cython` if we did anything
1592
+ self._ensure_cython_import()
1593
+ return True
1594
+
1595
+ # -----------------------------------------------------------------
1596
+ # Pass -1: sum(genexp) loopification
1597
+ # -----------------------------------------------------------------
1598
+
1599
+ def _loopify_sums(self) -> bool:
1600
+ """Statement-level rewrite of `x = sum(<genexp>)` and
1601
+ `return [sum(<genexp>) …]` list-comp bodies.
1602
+
1603
+ Only two shapes, both taken straight from real benchmark code:
1604
+
1605
+ A. assign form:
1606
+ <t> = sum(E for T in ITER)
1607
+ → <t> = 0.0; for T in ITER: <t> += E
1608
+
1609
+ B. list-comp return (spectral_norm):
1610
+ return [sum(E for T in ITER) for OUTER in OITER]
1611
+ → out = []
1612
+ for OUTER in OITER:
1613
+ acc = 0.0
1614
+ for T in ITER: acc += E
1615
+ out.append(acc)
1616
+ return out
1617
+
1618
+ Constraints on E: cheap arithmetic only (Name/Constant/BinOp/
1619
+ UnaryOp — calls are left to the inliner; if a call survived inlining
1620
+ it is not rewritten here). Tuple targets require `enumerate(seq)`;
1621
+ single-Name targets accept any cheap iterable expression. On any
1622
+ doubt the node is returned unchanged. Accumulator typing is left to
1623
+ the existing float solver, which sees `= 0.0` / `+= E` and infers
1624
+ double — the whole point of the rewrite.
1625
+ """
1626
+ changed = False
1627
+ for node in self.tree.body:
1628
+ if isinstance(node, ast.FunctionDef):
1629
+ if self._loopify_in_func(node):
1630
+ changed = True
1631
+ return changed
1632
+
1633
+ def _build_sum_loop(self, func: ast.FunctionDef,
1634
+ sum_call: ast.Call,
1635
+ outer_target: ast.Name | None,
1636
+ outer_iter: ast.expr | None) -> list | None:
1637
+ """Build replacement statements for one sum(genexp); None = bail."""
1638
+ gen = sum_call.args[0]
1639
+ comp = gen.generators[0]
1640
+ elem = gen.elt
1641
+ tgt = comp.target
1642
+ if not isinstance(tgt, (ast.Name, ast.Tuple)):
1643
+ return None
1644
+ if isinstance(tgt, ast.Tuple) and not (
1645
+ len(tgt.elts) >= 1
1646
+ and all(isinstance(e, ast.Name) for e in tgt.elts)):
1647
+ return None
1648
+ iter_expr = comp.iter
1649
+ if not _cheap_expr(elem):
1650
+ return None
1651
+
1652
+ # enumerate-tuple fast path: when the tuple is (idx, val) over
1653
+ # enumerate(Name) AND the same Name is already used in a len(Name)
1654
+ # call somewhere in this function (the original code assumed it is a
1655
+ # sized sequence), rewrite as an index loop `for idx in range(len(Name)):`
1656
+ # + `val = Name[idx]` — avoids one tuple allocation per iteration,
1657
+ # which dominates otherwise (10x on spectral_norm).
1658
+ tuple_idx_loop = None
1659
+ if isinstance(tgt, ast.Tuple) and len(tgt.elts) == 2 \
1660
+ and isinstance(iter_expr, ast.Call) \
1661
+ and isinstance(iter_expr.func, ast.Name) \
1662
+ and iter_expr.func.id == "enumerate" \
1663
+ and len(iter_expr.args) == 1 and not iter_expr.keywords \
1664
+ and isinstance(iter_expr.args[0], ast.Name):
1665
+ seq_name = iter_expr.args[0].id
1666
+ uses_len = any(
1667
+ isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
1668
+ and n.func.id == "len" and len(n.args) == 1
1669
+ and isinstance(n.args[0], ast.Name)
1670
+ and n.args[0].id == seq_name
1671
+ for n in ast.walk(func))
1672
+ if uses_len:
1673
+ tuple_idx_loop = (
1674
+ tgt.elts[0], tgt.elts[1], seq_name)
1675
+ # float evidence gate (same precedent as the enumerate
1676
+ # inference): typing the value var double is only sound when
1677
+ # the element expr is float arithmetic; otherwise leave it an
1678
+ # object and the loop still runs, just unoptimized
1679
+ has_float_ev = any(
1680
+ isinstance(n, ast.Constant) and isinstance(n.value, float)
1681
+ for n in ast.walk(elem)) or any(
1682
+ isinstance(n, ast.BinOp) and isinstance(n.op, ast.Div)
1683
+ for n in ast.walk(elem))
1684
+ if has_float_ev:
1685
+ self.loopify_float_vars.setdefault(
1686
+ func.name, set()).add(tgt.elts[1].id)
1687
+
1688
+ acc_store = lambda: ast.Name(id="__pb_acc", ctx=ast.Store())
1689
+ acc_load = lambda: ast.Name(id="__pb_acc", ctx=ast.Load())
1690
+ if tuple_idx_loop is not None:
1691
+ idx_name, val_name, seq = tuple_idx_loop
1692
+ inner_for = ast.For(
1693
+ target=ast.Name(id=idx_name.id, ctx=ast.Store()),
1694
+ iter=ast.Call(
1695
+ func=ast.Name(id="range", ctx=ast.Load()),
1696
+ args=[ast.Call(
1697
+ func=ast.Name(id="len", ctx=ast.Load()),
1698
+ args=[ast.Name(id=seq, ctx=ast.Load())],
1699
+ keywords=[])],
1700
+ keywords=[]),
1701
+ body=[
1702
+ ast.Assign(
1703
+ targets=[ast.Name(id=val_name.id, ctx=ast.Store())],
1704
+ value=ast.Subscript(
1705
+ value=ast.Name(id=seq, ctx=ast.Load()),
1706
+ slice=ast.Name(id=idx_name.id, ctx=ast.Load()),
1707
+ ctx=ast.Load())),
1708
+ ast.AugAssign(target=acc_store(), op=ast.Add(),
1709
+ value=copy.deepcopy(elem)),
1710
+ ],
1711
+ orelse=[])
1712
+ else:
1713
+ inner_for = ast.For(
1714
+ target=copy.deepcopy(tgt),
1715
+ iter=iter_expr,
1716
+ body=[ast.AugAssign(target=acc_store(), op=ast.Add(),
1717
+ value=copy.deepcopy(elem))],
1718
+ orelse=[])
1719
+ if outer_target is None:
1720
+ # shape A: assign — accumulator is the user's own var (no temp
1721
+ # pollution for the solver); caller retargets it
1722
+ return [copy.deepcopy(inner_for)]
1723
+ if not isinstance(outer_target, ast.Name) or outer_iter is None:
1724
+ return None
1725
+ # shape B: build out/outer-loop/return around the inner loop
1726
+ return [
1727
+ ast.Assign(targets=[ast.Name(id="__pb_out", ctx=ast.Store())],
1728
+ value=ast.List(elts=[], ctx=ast.Load())),
1729
+ ast.For(
1730
+ target=copy.deepcopy(outer_target),
1731
+ iter=outer_iter,
1732
+ body=[
1733
+ ast.Assign(targets=[acc_store()],
1734
+ value=ast.Constant(value=0.0)),
1735
+ copy.deepcopy(inner_for),
1736
+ ast.Expr(value=ast.Call(
1737
+ func=ast.Attribute(value=ast.Name(
1738
+ id="__pb_out", ctx=ast.Load()),
1739
+ attr="append", ctx=ast.Load()),
1740
+ args=[acc_load()], keywords=[])),
1741
+ ],
1742
+ orelse=[]),
1743
+ ast.Return(value=ast.Name(id="__pb_out", ctx=ast.Load())),
1744
+ ]
1745
+
1746
+ def _loopify_in_func(self, func: ast.FunctionDef) -> bool:
1747
+ """Rewrite sum(genexp) call sites inside `func` (its own statements,
1748
+ not nested defs). Returns True if anything changed."""
1749
+
1750
+ transformer = self
1751
+
1752
+ class _LoopRewriter(ast.NodeTransformer):
1753
+ def __init__(self):
1754
+ self.hit = False
1755
+
1756
+ def visit_Assign(self, node: ast.Assign) -> ast.AST:
1757
+ self.generic_visit(node)
1758
+ if not (len(node.targets) == 1
1759
+ and isinstance(node.targets[0], ast.Name)
1760
+ and _is_sum_genexp(node.value)):
1761
+ return node
1762
+ orig = node.targets[0].id
1763
+ # the accumulator must not collide with any name the loop
1764
+ # itself binds (e.g. `x = sum(x for x in u)` would produce
1765
+ # `for x in u: x += …` and silently drop iterations)
1766
+ bound = {n.id for n in ast.walk(node.value) if isinstance(n, ast.Name)}
1767
+ if orig in bound and any(
1768
+ isinstance(t, ast.Name) and t.id == orig
1769
+ for t in ([node.value.args[0].generators[0].target]
1770
+ if isinstance(node.value.args[0].generators[0].target, ast.Name)
1771
+ else list(getattr(node.value.args[0].generators[0].target, 'elts', [])))):
1772
+ return node
1773
+ stmts = transformer._build_sum_loop(func, node.value, None, None)
1774
+ if stmts is None:
1775
+ return node
1776
+ # retarget the accumulator onto the user's variable so the
1777
+ # solver sees <orig> = 0.0-accumulation and types it double
1778
+ class _Ren(ast.NodeTransformer):
1779
+ def visit_Name(self, n: ast.Name) -> ast.AST:
1780
+ if n.id == "__pb_acc":
1781
+ return ast.copy_location(
1782
+ ast.Name(id=orig, ctx=n.ctx), n)
1783
+ return n
1784
+ stmts = [_Ren().visit(s) for s in stmts]
1785
+ self.hit = True
1786
+ return _StmtList([ast.Assign(
1787
+ targets=[ast.Name(id=orig, ctx=ast.Store())],
1788
+ value=ast.Constant(value=0.0))] + stmts)
1789
+
1790
+ def visit_Return(self, node: ast.Return) -> ast.AST:
1791
+ self.generic_visit(node)
1792
+ if not (node.value is not None
1793
+ and isinstance(node.value, ast.ListComp)
1794
+ and len(node.value.generators) == 1
1795
+ and not node.value.generators[0].ifs
1796
+ and isinstance(node.value.generators[0].target, ast.Name)
1797
+ and _is_sum_genexp(node.value.elt)):
1798
+ return node
1799
+ g = node.value.generators[0]
1800
+ stmts = transformer._build_sum_loop(
1801
+ func, node.value.elt, g.target, g.iter)
1802
+ if stmts is None:
1803
+ return node
1804
+ self.hit = True
1805
+ return _StmtList(stmts)
1806
+
1807
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
1808
+ if node is not func:
1809
+ return node # never descend into nested defs
1810
+ self.generic_visit(node)
1811
+ return node
1812
+
1813
+ rewriter = _LoopRewriter()
1814
+ new_body = []
1815
+ for stmt in func.body:
1816
+ out = rewriter.visit(stmt)
1817
+ if isinstance(out, _StmtList):
1818
+ new_body.extend(out.stmts)
1819
+ else:
1820
+ new_body.append(out)
1821
+ # flatten any _StmtList that landed inside nested statement lists
1822
+ # (If/For/While bodies) — a _StmtList left in place breaks the AST
1823
+ func.body = self._flatten_stmt_lists(new_body)
1824
+ return rewriter.hit
1825
+
1826
+ @staticmethod
1827
+ def _flatten_stmt_lists(stmts: list) -> list:
1828
+ out: list = []
1829
+ for s in stmts:
1830
+ if isinstance(s, _StmtList):
1831
+ out.extend(_Transformer._flatten_stmt_lists(s.stmts))
1832
+ continue
1833
+ for fld in ("body", "orelse", "finalbody"):
1834
+ val = getattr(s, fld, None)
1835
+ if isinstance(val, list):
1836
+ setattr(s, fld, _Transformer._flatten_stmt_lists(val))
1837
+ out.append(s)
1838
+ return out
1839
+
1840
+ # -----------------------------------------------------------------
1841
+ # Pass 0: expression-function inlining
1842
+ # -----------------------------------------------------------------
1843
+
1844
+ def _inline_functions(self) -> None:
1845
+ """Inline small pure expression functions at their call sites.
1846
+
1847
+ Conservative AOT analogue of a JIT's speculative inlining: instead of
1848
+ type feedback we require the callee body to be straight-line cheap
1849
+ arithmetic over its params (no calls, no branches, no globals), so
1850
+ splicing it into the caller can never duplicate a side effect. The
1851
+ callee def itself is kept for external callers.
1852
+ """
1853
+ funcs = [n for n in self.tree.body if isinstance(n, ast.FunctionDef)]
1854
+
1855
+ # names reassigned at module level disqualify their function (the
1856
+ # call site may legally see the rebound object)
1857
+ rebound: set[str] = set()
1858
+ for node in self.tree.body:
1859
+ if isinstance(node, ast.Assign):
1860
+ for tgt in node.targets:
1861
+ if isinstance(tgt, ast.Name):
1862
+ rebound.add(tgt.id)
1863
+
1864
+ candidates: dict[str, dict[str, ast.AST]] = {}
1865
+ for func in funcs:
1866
+ if func.name in rebound or func.name.startswith("_"):
1867
+ continue
1868
+ subst = _inlineable_shape(func)
1869
+ if subst is not None:
1870
+ candidates[func.name] = subst
1871
+
1872
+ if not candidates:
1873
+ return
1874
+
1875
+ candidate_defs = {f.name: f for f in funcs if f.name in candidates}
1876
+ inlined_any = False
1877
+
1878
+ class _Inliner(ast.NodeTransformer):
1879
+ def __init__(self, owner: ast.AST):
1880
+ self.owner = owner
1881
+ self.depth = 0
1882
+
1883
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
1884
+ # rewrite only the owner's own body; never descend into
1885
+ # nested defs (their scoping/semantics stay untouched)
1886
+ if node is not self.owner:
1887
+ return node
1888
+ self.generic_visit(node)
1889
+ return node
1890
+
1891
+ def visit_Call(self, node: ast.Call) -> ast.AST:
1892
+ self.generic_visit(node)
1893
+ if not (isinstance(node.func, ast.Name)
1894
+ and node.func.id in candidates
1895
+ and not node.keywords):
1896
+ return node
1897
+ subst = candidates[node.func.id]
1898
+ params = list(func_params[node.func.id])
1899
+ if len(node.args) != len(params):
1900
+ return node
1901
+ arg_map: dict[str, ast.AST] = {}
1902
+ for p, a in zip(params, node.args):
1903
+ if not _cheap_expr(a):
1904
+ return node # duplicated evaluation could be costly
1905
+ arg_map[p] = copy.deepcopy(a)
1906
+ # two-stage resolution, both single-pass:
1907
+ # 1. resolve each temp body against the caller's args (temp
1908
+ # bodies contain only param names by construction)
1909
+ # 2. one pass over the return expr with temps + param
1910
+ # bindings — each name replaced exactly once
1911
+ temp_map = {
1912
+ name: _subst_names(expr, arg_map)
1913
+ for name, expr in subst.items() if name not in arg_map}
1914
+ final_map = dict(temp_map)
1915
+ final_map.update(arg_map)
1916
+ ret_expr = candidate_defs[node.func.id].body[-1].value
1917
+ return ast.copy_location(
1918
+ _subst_names(ret_expr, final_map), node)
1919
+
1920
+ # param order per candidate, for arity checks
1921
+ func_params = {
1922
+ f.name: [a.arg for a in f.args.args] for f in funcs
1923
+ if f.name in candidates}
1924
+
1925
+ for func in funcs:
1926
+ if func.name in candidates:
1927
+ continue # keep candidate bodies pristine
1928
+ inliner = _Inliner(func)
1929
+ before = ast.dump(func)
1930
+ new_func = inliner.visit(func)
1931
+ if ast.dump(new_func) != before:
1932
+ inlined_any = True
1933
+
1934
+ if inlined_any:
1935
+ self.applied_strategies.append("inline")
1936
+
1937
+ def _pre_solve_functions(self) -> None:
1938
+ """Scan and solve all module-level functions to learn variable types.
1939
+
1940
+ This lets us infer attribute types from call sites like
1941
+ Account(i, 1000.0) where i is a solved longlong loop variable.
1942
+ """
1943
+ for node in self.tree.body:
1944
+ if not isinstance(node, ast.FunctionDef):
1945
+ continue
1946
+ sc = _Scanner(node)
1947
+ if sc.dynamic or sc.generator:
1948
+ continue
1949
+ solver = _Solver(sc)
1950
+ solver.solve()
1951
+ self.func_types[node.name] = dict(solver.typed_locals)
1952
+ # Infer arg types from solved types + usage patterns
1953
+ arg_types: dict[str, str] = {}
1954
+ arg_names = {a.arg for a in node.args.args}
1955
+ for name, ctype in solver.typed_locals.items():
1956
+ if name in arg_names:
1957
+ arg_types[name] = ctype
1958
+ # Check if args are used in float context (Div, float literal)
1959
+ for name in arg_names:
1960
+ if name in arg_types:
1961
+ continue
1962
+ for sub in ast.walk(node):
1963
+ if isinstance(sub, ast.BinOp) and isinstance(sub.op, ast.Div):
1964
+ for side in (sub.left, sub.right):
1965
+ if isinstance(side, ast.Name) and side.id == name:
1966
+ arg_types[name] = DOUBLE
1967
+ break
1968
+ if isinstance(sub, ast.BinOp):
1969
+ for side in (sub.left, sub.right):
1970
+ if (isinstance(side, ast.Constant)
1971
+ and isinstance(side.value, float)):
1972
+ other = sub.right if side is sub.left else sub.left
1973
+ if isinstance(other, ast.Name) and other.id == name:
1974
+ arg_types[name] = DOUBLE
1975
+ break
1976
+ self.func_arg_types[node.name] = arg_types
1977
+
1978
+ # -----------------------------------------------------------------
1979
+ # Class analysis
1980
+ # -----------------------------------------------------------------
1981
+
1982
+ def _analyse_classes(self) -> None:
1983
+ for node in self.tree.body:
1984
+ if not isinstance(node, ast.ClassDef):
1985
+ continue
1986
+ plan = self._analyse_one_class(node)
1987
+ self.class_plans[id(node)] = plan
1988
+ if not plan.skip:
1989
+ self.cdef_class_names.add(plan.name)
1990
+ # Note: we do NOT skip parents of non-cdef subclasses here.
1991
+ # The _detect_needs_dict step 2 sets needs_dict=True on parents
1992
+ # that have non-cdef subclasses, and _has_noncdef_subclass
1993
+ # prevents cfunc generation on those classes.
1994
+
1995
+ def _analyse_one_class(self, node: ast.ClassDef) -> ClassPlan:
1996
+ plan = ClassPlan(node=node, name=node.name)
1997
+
1998
+ has_slots = any(
1999
+ isinstance(stmt, ast.Assign)
2000
+ and any(isinstance(t, ast.Name) and t.id == "__slots__"
2001
+ for t in stmt.targets)
2002
+ for stmt in node.body)
2003
+ init = next((s for s in node.body
2004
+ if isinstance(s, ast.FunctionDef) and s.name == "__init__"),
2005
+ None)
2006
+
2007
+ if not has_slots and not init:
2008
+ plan.skip = True
2009
+ plan.reason = "no __slots__/__init__"
2010
+ return plan
2011
+
2012
+ # Must inherit only from object (or no bases)
2013
+ for base in node.bases:
2014
+ if not (isinstance(base, ast.Name) and base.id == "object"):
2015
+ plan.skip = True
2016
+ plan.reason = "non-object base"
2017
+ return plan
2018
+
2019
+ # Collect attributes from __init__
2020
+ if init:
2021
+ for stmt in ast.walk(init):
2022
+ if (isinstance(stmt, ast.Assign)
2023
+ and len(stmt.targets) == 1
2024
+ and isinstance(stmt.targets[0], ast.Attribute)
2025
+ and isinstance(stmt.targets[0].value, ast.Name)
2026
+ and stmt.targets[0].value.id == "self"):
2027
+ attr_name = stmt.targets[0].attr
2028
+ ctype = _classify_literal(stmt.value)
2029
+ plan.all_attrs[attr_name] = ctype
2030
+
2031
+ if not plan.all_attrs and not has_slots:
2032
+ plan.skip = True
2033
+ plan.reason = "no self attrs"
2034
+ return plan
2035
+
2036
+ plan.skip = False
2037
+ return plan
2038
+
2039
+ # -----------------------------------------------------------------
2040
+ # Attribute type inference from call sites
2041
+ # -----------------------------------------------------------------
2042
+
2043
+ def _infer_attr_types_from_calls(self) -> None:
2044
+ class_params: dict[str, list[str]] = {}
2045
+ for plan in self.class_plans.values():
2046
+ if plan.skip:
2047
+ continue
2048
+ init = next((s for s in plan.node.body
2049
+ if isinstance(s, ast.FunctionDef) and s.name == "__init__"),
2050
+ None)
2051
+ if init:
2052
+ class_params[plan.name] = [
2053
+ a.arg for a in init.args.args if a.arg != "self"]
2054
+
2055
+ # Walk the tree. When we enter a function, remember its solved types
2056
+ # so we can use solved variable types to classify call arguments.
2057
+ for top_node in self.tree.body:
2058
+ if isinstance(top_node, ast.FunctionDef):
2059
+ solved = self.func_types.get(top_node.name, {})
2060
+ else:
2061
+ solved = {}
2062
+
2063
+ for node in ast.walk(top_node):
2064
+ if not isinstance(node, ast.Call):
2065
+ continue
2066
+ func = node.func
2067
+ if not (isinstance(func, ast.Name) and func.id in class_params):
2068
+ continue
2069
+ cls_name = func.id
2070
+ params = class_params[cls_name]
2071
+ plan = next((p for p in self.class_plans.values()
2072
+ if p.name == cls_name), None)
2073
+ if not plan:
2074
+ continue
2075
+ for i, arg in enumerate(node.args):
2076
+ if i >= len(params):
2077
+ break
2078
+ param_name = params[i]
2079
+ if param_name not in plan.all_attrs:
2080
+ continue
2081
+ ctype = self._classify_call_arg(arg, solved)
2082
+ if ctype and plan.all_attrs.get(param_name) == OBJECT:
2083
+ plan.all_attrs[param_name] = ctype
2084
+
2085
+ def _classify_call_arg(self, node: ast.AST,
2086
+ solved: dict[str, str]) -> str | None:
2087
+ """Classify a call argument using solved local variable types."""
2088
+ # Direct literal classification
2089
+ ctype = _classify_expr(node)
2090
+ if ctype:
2091
+ return ctype
2092
+ # Solved variable names
2093
+ if isinstance(node, ast.Name) and node.id in solved:
2094
+ return solved[node.id]
2095
+ # BinOp: if either side is solved-float or float literal → double
2096
+ # If both sides are solved-int or int literal → longlong
2097
+ if isinstance(node, ast.BinOp):
2098
+ lt = self._classify_call_arg(node.left, solved)
2099
+ rt = self._classify_call_arg(node.right, solved)
2100
+ if lt and rt:
2101
+ if DOUBLE in (lt, rt):
2102
+ return DOUBLE
2103
+ return LONGLONG
2104
+ # UnaryOp
2105
+ if isinstance(node, ast.UnaryOp):
2106
+ return self._classify_call_arg(node.operand, solved)
2107
+ # Call: float() → double, int()/len()/abs() → longlong
2108
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
2109
+ if node.func.id == "float":
2110
+ return DOUBLE
2111
+ if node.func.id in ("int", "len", "abs"):
2112
+ return LONGLONG
2113
+ return None
2114
+
2115
+ # -----------------------------------------------------------------
2116
+ # Arithmetic safety: downgrade longlong attrs in Mult/Pow/LShift
2117
+ # -----------------------------------------------------------------
2118
+
2119
+ def _check_arithmetic_safety(self) -> None:
2120
+ for plan in self.class_plans.values():
2121
+ if plan.skip:
2122
+ continue
2123
+ longlong_attrs = {k for k, v in plan.all_attrs.items()
2124
+ if v == LONGLONG}
2125
+ if not longlong_attrs:
2126
+ continue
2127
+ for method in plan.node.body:
2128
+ if not isinstance(method, ast.FunctionDef):
2129
+ continue
2130
+ for node in ast.walk(method):
2131
+ if (isinstance(node, ast.BinOp)
2132
+ and isinstance(node.op, (ast.Mult, ast.Pow, ast.LShift))):
2133
+ for child in (node.left, node.right):
2134
+ if (isinstance(child, ast.Attribute)
2135
+ and isinstance(child.value, ast.Name)
2136
+ and child.value.id == "self"
2137
+ and child.attr in longlong_attrs):
2138
+ longlong_attrs.discard(child.attr)
2139
+ for attr_name, ctype in list(plan.all_attrs.items()):
2140
+ if ctype == LONGLONG and attr_name not in longlong_attrs:
2141
+ plan.all_attrs[attr_name] = OBJECT
2142
+
2143
+ # -----------------------------------------------------------------
2144
+ # needs_dict detection
2145
+ # -----------------------------------------------------------------
2146
+
2147
+ def _detect_needs_dict(self) -> None:
2148
+ attr_to_classes: dict[str, set[str]] = {}
2149
+ for plan in self.class_plans.values():
2150
+ if plan.skip:
2151
+ continue
2152
+ for attr_name in plan.all_attrs:
2153
+ attr_to_classes.setdefault(attr_name, set()).add(plan.name)
2154
+
2155
+ # 1. Dunder methods accessing other.attr
2156
+ for plan in self.class_plans.values():
2157
+ if plan.skip:
2158
+ continue
2159
+ for method in plan.node.body:
2160
+ if not isinstance(method, ast.FunctionDef):
2161
+ continue
2162
+ is_dunder = (method.name.startswith("__")
2163
+ and method.name.endswith("__"))
2164
+ if not is_dunder:
2165
+ continue
2166
+ param_names = {a.arg for a in method.args.args
2167
+ if a.arg != "self"}
2168
+ for node in ast.walk(method):
2169
+ if (isinstance(node, ast.Attribute)
2170
+ and node.attr in attr_to_classes
2171
+ and isinstance(node.value, ast.Name)
2172
+ and node.value.id in param_names):
2173
+ plan.needs_dict = True
2174
+
2175
+ # 2. Non-cdef subclasses of cdef classes
2176
+ cdef_names = self.cdef_class_names
2177
+ for plan in self.class_plans.values():
2178
+ if plan.skip:
2179
+ for base in plan.node.bases:
2180
+ base_name = (base.id if isinstance(base, ast.Name)
2181
+ else getattr(base, "attr", None))
2182
+ if base_name in cdef_names:
2183
+ for parent in self.class_plans.values():
2184
+ if parent.name == base_name and not parent.skip:
2185
+ parent.needs_dict = True
2186
+
2187
+ # 3. External attribute access from module-level function bodies.
2188
+ # Class methods access same-type attrs via C-level (AnnAssign is fine).
2189
+ # But module-level functions that do obj.attr need visibility="public".
2190
+ cdef_attr_set = set(attr_to_classes.keys())
2191
+ for top in self.tree.body:
2192
+ if isinstance(top, ast.ClassDef):
2193
+ continue # class methods access same-type attrs via C-level
2194
+ for node in ast.walk(top):
2195
+ if (isinstance(node, ast.Attribute)
2196
+ and node.attr in cdef_attr_set
2197
+ and isinstance(node.value, ast.Name)):
2198
+ for cls_name in attr_to_classes.get(node.attr, set()):
2199
+ for plan in self.class_plans.values():
2200
+ if plan.name == cls_name and not plan.skip:
2201
+ plan.needs_dict = True
2202
+
2203
+ # -----------------------------------------------------------------
2204
+ # Loop variable object type inference (needed before cfunc generation)
2205
+ # -----------------------------------------------------------------
2206
+
2207
+ def _infer_loop_var_types(self) -> None:
2208
+ """Infer loop variable types and record which methods need cfunc.
2209
+
2210
+ A method only gets a cfunc variant if it's called on a typed loop
2211
+ variable — otherwise the wrapper just adds overhead.
2212
+ """
2213
+ # Map: class_name → set of method names called on typed loop vars
2214
+ self.cfunc_needed: dict[str, set[str]] = {}
2215
+
2216
+ for node in self.tree.body:
2217
+ if not isinstance(node, ast.FunctionDef):
2218
+ continue
2219
+ loop_var_types: dict[str, str] = {}
2220
+ for inner in ast.walk(node):
2221
+ if isinstance(inner, ast.For) and isinstance(inner.target, ast.Name):
2222
+ var_name = inner.target.id
2223
+ iter_node = inner.iter
2224
+ if (isinstance(iter_node, ast.ListComp)
2225
+ and isinstance(iter_node.elt, ast.Call)
2226
+ and isinstance(iter_node.elt.func, ast.Name)
2227
+ and iter_node.elt.func.id in self.cdef_class_names):
2228
+ loop_var_types[var_name] = iter_node.elt.func.id
2229
+ elif isinstance(iter_node, ast.Name):
2230
+ cls = self._find_list_var_type(node, iter_node.id)
2231
+ if cls:
2232
+ loop_var_types[var_name] = cls
2233
+
2234
+ # Find which methods are called on typed loop vars
2235
+ if loop_var_types:
2236
+ for inner in ast.walk(node):
2237
+ if (isinstance(inner, ast.Call)
2238
+ and isinstance(inner.func, ast.Attribute)
2239
+ and isinstance(inner.func.value, ast.Name)
2240
+ and inner.func.value.id in loop_var_types):
2241
+ cls_name = loop_var_types[inner.func.value.id]
2242
+ method_name = inner.func.attr
2243
+ self.cfunc_needed.setdefault(cls_name, set()).add(method_name)
2244
+
2245
+ if self.cfunc_needed:
2246
+ self.applied_strategies.append("loop-var-obj-type")
2247
+
2248
+ # -----------------------------------------------------------------
2249
+ # Transform class ASTs → cdef classes
2250
+ # -----------------------------------------------------------------
2251
+
2252
+ def _transform_classes(self) -> None:
2253
+ for plan in self.class_plans.values():
2254
+ if plan.skip:
2255
+ continue
2256
+ self._build_cdef_class(plan)
2257
+ if self.cdef_class_names:
2258
+ self.applied_strategies.append("cdef-class")
2259
+
2260
+ def _has_noncdef_subclass(self, cls_name: str) -> bool:
2261
+ """Check if any non-cdef (skipped) class inherits from cls_name."""
2262
+ for plan in self.class_plans.values():
2263
+ if plan.skip:
2264
+ for base in plan.node.bases:
2265
+ base_name = (base.id if isinstance(base, ast.Name)
2266
+ else getattr(base, "attr", None))
2267
+ if base_name == cls_name:
2268
+ return True
2269
+ return False
2270
+
2271
+ def _expand_method_aliases(self, node: ast.ClassDef) -> None:
2272
+ """Expand method aliases like __rmul__ = __mul__ into proper methods.
2273
+
2274
+ Cython cdef classes don't support class-attribute method aliases.
2275
+ Convert: __rmul__ = __mul__
2276
+ Into: def __rmul__(self, other):
2277
+ return self.__mul__(other)
2278
+ """
2279
+ # Collect names of actual methods and their arg signatures
2280
+ method_sigs: dict[str, ast.arguments] = {}
2281
+ for stmt in node.body:
2282
+ if isinstance(stmt, ast.FunctionDef):
2283
+ method_sigs[stmt.name] = stmt.args
2284
+
2285
+ new_body = []
2286
+ for stmt in node.body:
2287
+ if (isinstance(stmt, ast.Assign)
2288
+ and len(stmt.targets) == 1
2289
+ and isinstance(stmt.targets[0], ast.Name)
2290
+ and isinstance(stmt.value, ast.Name)
2291
+ and stmt.targets[0].id.startswith("__")
2292
+ and stmt.targets[0].id.endswith("__")
2293
+ and stmt.value.id in method_sigs):
2294
+ # This is a method alias like __rmul__ = __mul__
2295
+ alias_name = stmt.targets[0].id
2296
+ target_method = stmt.value.id
2297
+ target_args = method_sigs[target_method]
2298
+ # Copy the target method's args (excluding self)
2299
+ call_args = [
2300
+ ast.Name(id=a.arg, ctx=ast.Load())
2301
+ for a in target_args.args
2302
+ if a.arg != "self"
2303
+ ]
2304
+ wrapper = ast.FunctionDef(
2305
+ name=alias_name,
2306
+ args=copy.deepcopy(target_args),
2307
+ body=[ast.Return(value=ast.Call(
2308
+ func=ast.Attribute(
2309
+ value=ast.Name(id="self", ctx=ast.Load()),
2310
+ attr=target_method, ctx=ast.Load()),
2311
+ args=call_args,
2312
+ keywords=[],
2313
+ ))],
2314
+ decorator_list=[],
2315
+ returns=None,
2316
+ )
2317
+ new_body.append(wrapper)
2318
+ else:
2319
+ new_body.append(stmt)
2320
+ node.body = new_body
2321
+
2322
+ def _build_cdef_class(self, plan: ClassPlan) -> None:
2323
+ node = plan.node
2324
+
2325
+ # @cython.cclass
2326
+ node.decorator_list.insert(0, ast.Attribute(
2327
+ value=ast.Name(id="cython", ctx=ast.Load()),
2328
+ attr="cclass", ctx=ast.Load()))
2329
+
2330
+ # Attribute declarations
2331
+ # needs_dict means external code accesses obj.attr — use visibility="public"
2332
+ # so the field is accessible from non-cdef code. But we do NOT add __dict__
2333
+ # because visibility="public" already provides Python-level access and
2334
+ # __dict__ forces a dict allocation + lookup overhead on every access.
2335
+ attr_decls = []
2336
+ use_public = plan.needs_dict
2337
+ for attr_name, ctype in sorted(plan.all_attrs.items()):
2338
+ type_expr = _ctype_to_ast(ctype)
2339
+ if use_public:
2340
+ attr_decls.append(ast.Assign(
2341
+ targets=[ast.Name(id=attr_name, ctx=ast.Store())],
2342
+ value=ast.Call(
2343
+ func=ast.Attribute(
2344
+ value=ast.Name(id="cython", ctx=ast.Load()),
2345
+ attr="declare", ctx=ast.Load()),
2346
+ args=[type_expr],
2347
+ keywords=[ast.keyword(
2348
+ arg="visibility",
2349
+ value=ast.Constant(value="public"))])))
2350
+ else:
2351
+ attr_decls.append(ast.AnnAssign(
2352
+ target=ast.Name(id=attr_name, ctx=ast.Store()),
2353
+ annotation=type_expr,
2354
+ value=None, simple=1))
2355
+
2356
+ # Remove __slots__
2357
+ node.body = [stmt for stmt in node.body
2358
+ if not (isinstance(stmt, ast.Assign)
2359
+ and any(isinstance(t, ast.Name)
2360
+ and t.id == "__slots__"
2361
+ for t in stmt.targets))]
2362
+ node.body = attr_decls + node.body
2363
+
2364
+ # Expand method aliases: __rmul__ = __mul__ → def __rmul__(self, *a, **kw)
2365
+ # Cython cdef classes don't support class-attribute method aliases.
2366
+ self._expand_method_aliases(node)
2367
+
2368
+ # cfunc method generation — generate for ALL eligible methods.
2369
+ # Even when the receiver is untyped (Python object), the cfunc method
2370
+ # has typed self and typed params, making attribute access C-level.
2371
+ # BUT: skip cfunc for classes that have non-cdef subclasses —
2372
+ # polymorphic dispatch goes through Python anyway, and the wrapper
2373
+ # just adds overhead.
2374
+ has_noncdef_subclass = self._has_noncdef_subclass(plan.name)
2375
+ new_body = []
2376
+ for stmt in node.body:
2377
+ new_body.append(stmt)
2378
+ if (isinstance(stmt, ast.FunctionDef)
2379
+ and self._is_cfunc_eligible(stmt)
2380
+ and not has_noncdef_subclass):
2381
+ fast_name, fast_method = self._make_cfunc_method(stmt, plan)
2382
+ plan.cfunc_methods[stmt.name] = fast_name
2383
+ idx = new_body.index(stmt)
2384
+ new_body.insert(idx + 1, fast_method)
2385
+
2386
+ # Rewrite original methods → thin wrappers with typed params
2387
+ for i, stmt in enumerate(new_body):
2388
+ if (isinstance(stmt, ast.FunctionDef)
2389
+ and stmt.name in plan.cfunc_methods):
2390
+ fast_name = plan.cfunc_methods[stmt.name]
2391
+ new_body[i] = self._make_wrapper(stmt, fast_name, plan)
2392
+
2393
+ node.body = new_body
2394
+ if plan.cfunc_methods:
2395
+ self.applied_strategies.append("cfunc-method")
2396
+
2397
+ def _is_cfunc_eligible(self, method: ast.FunctionDef) -> bool:
2398
+ name = method.name
2399
+ if name.startswith("__") and name.endswith("__"):
2400
+ return False
2401
+ if method.decorator_list:
2402
+ return False
2403
+ for node in ast.walk(method):
2404
+ if isinstance(node, (ast.Yield, ast.YieldFrom)):
2405
+ return False
2406
+ args = method.args
2407
+ if args.vararg or args.kwarg or args.kwonlyargs or args.defaults:
2408
+ return False
2409
+ return True
2410
+
2411
+ def _make_cfunc_method(self, method: ast.FunctionDef,
2412
+ plan: ClassPlan) -> tuple[str, ast.FunctionDef]:
2413
+ fast_name = f"_{method.name}_fast"
2414
+ new_body = [copy.deepcopy(stmt) for stmt in method.body]
2415
+ new_args = copy.deepcopy(method.args)
2416
+
2417
+ # @cython.locals for typed params (inferred from __init__ assignments)
2418
+ locals_decls: dict[str, str] = {}
2419
+ init = next((s for s in plan.node.body
2420
+ if isinstance(s, ast.FunctionDef)
2421
+ and s.name == "__init__"), None)
2422
+ if init:
2423
+ for stmt in ast.walk(init):
2424
+ if (isinstance(stmt, ast.Assign)
2425
+ and len(stmt.targets) == 1
2426
+ and isinstance(stmt.targets[0], ast.Attribute)
2427
+ and isinstance(stmt.targets[0].value, ast.Name)
2428
+ and stmt.targets[0].value.id == "self"):
2429
+ attr_name = stmt.targets[0].attr
2430
+ attr_type = plan.all_attrs.get(attr_name, OBJECT)
2431
+ if attr_type != OBJECT and isinstance(stmt.value, ast.Name):
2432
+ locals_decls[stmt.value.id] = attr_type
2433
+
2434
+ # Infer param types from comparisons with typed attrs
2435
+ for node in ast.walk(method):
2436
+ if (isinstance(node, ast.Compare)
2437
+ and isinstance(node.left, ast.Attribute)
2438
+ and isinstance(node.left.value, ast.Name)
2439
+ and node.left.value.id == "self"):
2440
+ attr_type = plan.all_attrs.get(node.left.attr, OBJECT)
2441
+ if attr_type != OBJECT:
2442
+ for comparator in node.comparators:
2443
+ if isinstance(comparator, ast.Name):
2444
+ locals_decls.setdefault(comparator.id, attr_type)
2445
+
2446
+ # Annotate params that access cdef class attrs (e.g. other: 'Point')
2447
+ # This enables C-level attribute access inside the cfunc method.
2448
+ cls_name = plan.name
2449
+ for arg in new_args.args:
2450
+ if arg.arg == "self":
2451
+ continue
2452
+ for node in ast.walk(method):
2453
+ if (isinstance(node, ast.Attribute)
2454
+ and isinstance(node.value, ast.Name)
2455
+ and node.value.id == arg.arg
2456
+ and node.attr in plan.all_attrs):
2457
+ arg.annotation = ast.Constant(value=cls_name)
2458
+ break
2459
+
2460
+ fast_method = ast.FunctionDef(
2461
+ name=fast_name,
2462
+ args=new_args,
2463
+ body=new_body,
2464
+ decorator_list=[],
2465
+ returns=copy.deepcopy(method.returns),
2466
+ )
2467
+ fast_method.decorator_list.append(ast.Attribute(
2468
+ value=ast.Name(id="cython", ctx=ast.Load()),
2469
+ attr="cfunc", ctx=ast.Load()))
2470
+
2471
+ if locals_decls:
2472
+ fast_method.decorator_list.append(ast.Call(
2473
+ func=ast.Attribute(
2474
+ value=ast.Name(id="cython", ctx=ast.Load()),
2475
+ attr="locals", ctx=ast.Load()),
2476
+ args=[],
2477
+ keywords=[ast.keyword(arg=n, value=_ctype_to_ast(t))
2478
+ for n, t in sorted(locals_decls.items())]))
2479
+
2480
+ # Return type inference
2481
+ ret_type = self._infer_return_type(method, plan)
2482
+ if ret_type:
2483
+ fast_method.decorator_list.append(ast.Call(
2484
+ func=ast.Attribute(
2485
+ value=ast.Name(id="cython", ctx=ast.Load()),
2486
+ attr="returns", ctx=ast.Load()),
2487
+ args=[_ctype_to_ast(ret_type)],
2488
+ keywords=[]))
2489
+
2490
+ return fast_name, fast_method
2491
+
2492
+ def _infer_return_type(self, method: ast.FunctionDef,
2493
+ plan: ClassPlan) -> str | None:
2494
+ for node in ast.walk(method):
2495
+ if isinstance(node, ast.Return) and node.value is not None:
2496
+ val = node.value
2497
+ if (isinstance(val, ast.Attribute)
2498
+ and isinstance(val.value, ast.Name)
2499
+ and val.value.id == "self"):
2500
+ attr_type = plan.all_attrs.get(val.attr, OBJECT)
2501
+ if attr_type == DOUBLE:
2502
+ return DOUBLE
2503
+ if isinstance(val, ast.Compare):
2504
+ return BINT
2505
+ if isinstance(val, ast.BinOp):
2506
+ lt = self._expr_type(val.left, plan)
2507
+ rt = self._expr_type(val.right, plan)
2508
+ if DOUBLE in (lt, rt):
2509
+ return DOUBLE
2510
+ if lt == LONGLONG and rt == LONGLONG:
2511
+ return LONGLONG
2512
+ if isinstance(val, ast.Constant):
2513
+ if isinstance(val.value, bool):
2514
+ return BINT
2515
+ if isinstance(val.value, float):
2516
+ return DOUBLE
2517
+ return None
2518
+
2519
+ def _expr_type(self, node, plan: ClassPlan) -> str:
2520
+ if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) \
2521
+ and node.value.id == "self":
2522
+ return plan.all_attrs.get(node.attr, OBJECT)
2523
+ if isinstance(node, ast.Constant):
2524
+ if isinstance(node.value, float):
2525
+ return DOUBLE
2526
+ if isinstance(node.value, int):
2527
+ return LONGLONG
2528
+ return OBJECT
2529
+
2530
+ def _make_wrapper(self, method: ast.FunctionDef, fast_name: str,
2531
+ plan: ClassPlan) -> ast.FunctionDef:
2532
+ args = copy.deepcopy(method.args)
2533
+ call_args = [ast.Name(id=a.arg, ctx=ast.Load())
2534
+ for a in args.args if a.arg != "self"]
2535
+ cls_name = plan.name
2536
+
2537
+ # Infer parameter types for the wrapper signature.
2538
+ # Typed params let Cython avoid Python-level boxing/unboxing at the
2539
+ # call boundary, which is the dominant overhead for thin wrappers.
2540
+ wrapper_param_types = self._infer_wrapper_param_types(method, plan)
2541
+
2542
+ for arg in args.args:
2543
+ if arg.arg == "self":
2544
+ continue
2545
+ inferred = wrapper_param_types.get(arg.arg)
2546
+ if inferred == cls_name:
2547
+ # Same-class param (e.g. other: 'P') — string annotation
2548
+ arg.annotation = ast.Constant(value=cls_name)
2549
+ elif inferred and inferred != OBJECT:
2550
+ # Scalar param (e.g. s: cython.double) — Cython type
2551
+ arg.annotation = _ctype_to_ast(inferred)
2552
+
2553
+ return ast.FunctionDef(
2554
+ name=method.name,
2555
+ args=args,
2556
+ body=[ast.Return(value=ast.Call(
2557
+ func=ast.Attribute(
2558
+ value=ast.Name(id="self", ctx=ast.Load()),
2559
+ attr=fast_name, ctx=ast.Load()),
2560
+ args=call_args,
2561
+ keywords=[]))],
2562
+ decorator_list=copy.deepcopy(method.decorator_list),
2563
+ returns=copy.deepcopy(method.returns),
2564
+ )
2565
+
2566
+ def _infer_wrapper_param_types(self, method: ast.FunctionDef,
2567
+ plan: ClassPlan) -> dict[str, str]:
2568
+ """Infer Cython types for wrapper parameters.
2569
+
2570
+ A parameter gets a type if it interacts with a typed self.attr:
2571
+ - Direct attribute access: other.x -> other is the cdef class type
2572
+ - Arithmetic with typed attr: self.x *= s -> s is the attr's type
2573
+ - Comparison with typed attr: self.x == v -> v is the attr's type
2574
+ - Assignment from param: self.x = s -> s is the attr's type
2575
+ """
2576
+ result: dict[str, str] = {}
2577
+ cls_name = plan.name
2578
+
2579
+ for arg in method.args.args:
2580
+ if arg.arg == "self":
2581
+ continue
2582
+ arg_name = arg.arg
2583
+ found = False
2584
+ for node in ast.walk(method):
2585
+ # Case 1: param.attr — param is cdef class type
2586
+ if (isinstance(node, ast.Attribute)
2587
+ and isinstance(node.value, ast.Name)
2588
+ and node.value.id == arg_name
2589
+ and node.attr in plan.all_attrs):
2590
+ result[arg_name] = cls_name
2591
+ found = True
2592
+ break
2593
+
2594
+ # Case 2: self.attr OP= param (augmented assign)
2595
+ if (isinstance(node, ast.AugAssign)
2596
+ and isinstance(node.target, ast.Attribute)
2597
+ and isinstance(node.target.value, ast.Name)
2598
+ and node.target.value.id == "self"
2599
+ and node.target.attr in plan.all_attrs
2600
+ and isinstance(node.value, ast.Name)
2601
+ and node.value.id == arg_name):
2602
+ attr_type = plan.all_attrs.get(node.target.attr, OBJECT)
2603
+ if attr_type != OBJECT:
2604
+ result[arg_name] = attr_type
2605
+ found = True
2606
+ break
2607
+
2608
+ # Case 3: param OP= self.attr (augmented assign, reversed)
2609
+ if (isinstance(node, ast.AugAssign)
2610
+ and isinstance(node.target, ast.Name)
2611
+ and node.target.id == arg_name
2612
+ and isinstance(node.value, ast.Attribute)
2613
+ and isinstance(node.value.value, ast.Name)
2614
+ and node.value.value.id == "self"
2615
+ and node.value.attr in plan.all_attrs):
2616
+ attr_type = plan.all_attrs.get(node.value.attr, OBJECT)
2617
+ if attr_type != OBJECT:
2618
+ result[arg_name] = attr_type
2619
+ found = True
2620
+ break
2621
+
2622
+ # Case 4: self.attr OP param or param OP self.attr
2623
+ if isinstance(node, ast.BinOp):
2624
+ for side, other_side in [
2625
+ (node.left, node.right),
2626
+ (node.right, node.left)]:
2627
+ if (isinstance(side, ast.Name)
2628
+ and side.id == arg_name
2629
+ and isinstance(other_side, ast.Attribute)
2630
+ and isinstance(other_side.value, ast.Name)
2631
+ and other_side.value.id == "self"
2632
+ and other_side.attr in plan.all_attrs):
2633
+ attr_type = plan.all_attrs.get(
2634
+ other_side.attr, OBJECT)
2635
+ if attr_type != OBJECT:
2636
+ result[arg_name] = attr_type
2637
+ found = True
2638
+ break
2639
+
2640
+ # Case 5: self.attr == param (comparison)
2641
+ if isinstance(node, ast.Compare):
2642
+ if (isinstance(node.left, ast.Attribute)
2643
+ and isinstance(node.left.value, ast.Name)
2644
+ and node.left.value.id == "self"
2645
+ and node.left.attr in plan.all_attrs):
2646
+ for comp in node.comparators:
2647
+ if (isinstance(comp, ast.Name)
2648
+ and comp.id == arg_name):
2649
+ attr_type = plan.all_attrs.get(
2650
+ node.left.attr, OBJECT)
2651
+ if attr_type != OBJECT:
2652
+ result[arg_name] = attr_type
2653
+ found = True
2654
+ break
2655
+
2656
+ # Case 6: self.attr = param (assignment)
2657
+ if (isinstance(node, ast.Assign)
2658
+ and len(node.targets) == 1
2659
+ and isinstance(node.targets[0], ast.Attribute)
2660
+ and isinstance(node.targets[0].value, ast.Name)
2661
+ and node.targets[0].value.id == "self"
2662
+ and node.targets[0].attr in plan.all_attrs
2663
+ and isinstance(node.value, ast.Name)
2664
+ and node.value.id == arg_name):
2665
+ attr_type = plan.all_attrs.get(
2666
+ node.targets[0].attr, OBJECT)
2667
+ if attr_type != OBJECT:
2668
+ result[arg_name] = attr_type
2669
+ found = True
2670
+ break
2671
+
2672
+ if found:
2673
+ break
2674
+
2675
+ return result
2676
+
2677
+ # -----------------------------------------------------------------
2678
+ # Function-level transforms
2679
+ # -----------------------------------------------------------------
2680
+
2681
+ def _transform_functions(self) -> None:
2682
+ all_cfunc_methods: dict[str, dict[str, str]] = {}
2683
+ for plan in self.class_plans.values():
2684
+ if plan.skip:
2685
+ continue
2686
+ all_cfunc_methods[plan.name] = plan.cfunc_methods
2687
+
2688
+ for node in self.tree.body:
2689
+ if not isinstance(node, ast.FunctionDef):
2690
+ continue
2691
+ self._transform_one_function(node, all_cfunc_methods)
2692
+
2693
+ def _transform_one_function(self, func: ast.FunctionDef,
2694
+ all_cfunc_methods: dict[str, dict[str, str]]) -> None:
2695
+ sc = _Scanner(func)
2696
+ if sc.dynamic or sc.generator or sc.bad_except or sc.global_nonlocal:
2697
+ return
2698
+
2699
+ solver = _Solver(sc)
2700
+ solver.solve()
2701
+
2702
+ # Directives
2703
+ directives = [
2704
+ ("boundscheck", False),
2705
+ ("wraparound", False),
2706
+ ("cdivision", True),
2707
+ ]
2708
+ for name, val in directives:
2709
+ dec = ast.Call(
2710
+ func=ast.Attribute(
2711
+ value=ast.Name(id="cython", ctx=ast.Load()),
2712
+ attr=name, ctx=ast.Load()),
2713
+ args=[ast.Constant(value=val)],
2714
+ keywords=[])
2715
+ dec.lineno = max(func.lineno - 1, 1)
2716
+ dec.col_offset = func.col_offset
2717
+ func.decorator_list.append(dec)
2718
+
2719
+ # Reuse loop var types from _infer_loop_var_types
2720
+ loop_var_types: dict[str, str] = {}
2721
+ for inner in ast.walk(func):
2722
+ if isinstance(inner, ast.For) and isinstance(inner.target, ast.Name):
2723
+ var_name = inner.target.id
2724
+ iter_node = inner.iter
2725
+ if (isinstance(iter_node, ast.ListComp)
2726
+ and isinstance(iter_node.elt, ast.Call)
2727
+ and isinstance(iter_node.elt.func, ast.Name)
2728
+ and iter_node.elt.func.id in self.cdef_class_names):
2729
+ loop_var_types[var_name] = iter_node.elt.func.id
2730
+ elif isinstance(iter_node, ast.Name):
2731
+ cls = self._find_list_var_type(func, iter_node.id)
2732
+ if cls:
2733
+ loop_var_types[var_name] = cls
2734
+
2735
+ # Merge: typed locals from solver + loop var object types
2736
+ typed_locals = dict(solver.typed_locals)
2737
+ for var in self.loopify_float_vars.get(func.name, ()):
2738
+ if var not in typed_locals:
2739
+ typed_locals[var] = DOUBLE
2740
+ for var, cls in loop_var_types.items():
2741
+ typed_locals[var] = cls
2742
+
2743
+ # Closure-capture guard: a variable referenced inside a nested
2744
+ # genexp/listcomp/setcomp/dictcomp *body* (not its own loop target)
2745
+ # is captured by a Python closure. Cython C-types locals live
2746
+ # outside the cell protocol, so a typed captured var silently
2747
+ # yields wrong values (e.g. binary_trees `d`). Never type them.
2748
+ captured = set()
2749
+ for comp in ast.walk(func):
2750
+ if isinstance(comp, (ast.GeneratorExp, ast.ListComp,
2751
+ ast.SetComp, ast.DictComp)):
2752
+ for name_node in ast.walk(comp):
2753
+ if isinstance(name_node, ast.Name):
2754
+ captured.add(name_node.id)
2755
+ # comprehension loop targets are local to the comprehension, not captures
2756
+ for comp in ast.walk(func):
2757
+ if isinstance(comp, (ast.GeneratorExp, ast.ListComp,
2758
+ ast.SetComp, ast.DictComp)):
2759
+ for gen in comp.generators:
2760
+ tgt = gen.target
2761
+ for name_node in ast.walk(tgt):
2762
+ if isinstance(name_node, ast.Name):
2763
+ captured.discard(name_node.id)
2764
+ for name in captured:
2765
+ typed_locals.pop(name, None)
2766
+
2767
+ # Infer cdef class types for locals that access cdef attributes
2768
+ # (e.g. a = points[i]; a.x → a is 'P' type for C-level field access)
2769
+ cdef_locals = self._infer_cdef_locals(func, loop_var_types)
2770
+ for var, cls in cdef_locals.items():
2771
+ if var not in typed_locals:
2772
+ typed_locals[var] = cls
2773
+
2774
+ # Merge cdef_locals into loop_var_types for cfunc rewriting
2775
+ all_obj_vars = dict(loop_var_types)
2776
+ all_obj_vars.update(cdef_locals)
2777
+
2778
+ # Rewrite cfunc calls for typed obj vars
2779
+ if all_obj_vars:
2780
+ self._rewrite_cfunc_calls(func, all_obj_vars, all_cfunc_methods)
2781
+
2782
+ # @cython.locals
2783
+ if typed_locals:
2784
+ dec = ast.Call(
2785
+ func=ast.Attribute(
2786
+ value=ast.Name(id="cython", ctx=ast.Load()),
2787
+ attr="locals", ctx=ast.Load()),
2788
+ args=[],
2789
+ keywords=[ast.keyword(arg=n, value=_ctype_to_ast(t))
2790
+ for n, t in sorted(typed_locals.items())])
2791
+ dec.lineno = max(func.lineno - 1, 1)
2792
+ dec.col_offset = func.col_offset
2793
+ func.decorator_list.append(dec)
2794
+ self.applied_strategies.append("type-infer")
2795
+
2796
+ def _find_list_var_type(self, func: ast.FunctionDef,
2797
+ var_name: str) -> str | None:
2798
+ for node in ast.walk(func):
2799
+ if isinstance(node, ast.Assign):
2800
+ for target in node.targets:
2801
+ if isinstance(target, ast.Name) and target.id == var_name:
2802
+ val = node.value
2803
+ if (isinstance(val, ast.ListComp)
2804
+ and isinstance(val.elt, ast.Call)
2805
+ and isinstance(val.elt.func, ast.Name)
2806
+ and val.elt.func.id in self.cdef_class_names):
2807
+ return val.elt.func.id
2808
+ if (isinstance(val, ast.List) and val.elts
2809
+ and isinstance(val.elts[0], ast.Call)
2810
+ and isinstance(val.elts[0].func, ast.Name)
2811
+ and val.elts[0].func.id in self.cdef_class_names):
2812
+ return val.elts[0].func.id
2813
+ return None
2814
+
2815
+ def _infer_cdef_locals(self, func: ast.FunctionDef,
2816
+ known_vars: dict[str, str]) -> dict[str, str]:
2817
+ """Infer cdef class types for locals that access cdef attributes.
2818
+
2819
+ When a module-level function does:
2820
+ a = points[i]
2821
+ dx = a.x - b.x
2822
+ Cython doesn't know that `a` and `b` are cdef class instances,
2823
+ so `a.x` goes through Python attribute lookup. By adding
2824
+ @cython.locals(a='P', b='P'), Cython does C-level field access.
2825
+
2826
+ Strategy: find all Name nodes that are used as the receiver of
2827
+ an Attribute access where the attribute name matches a cdef class
2828
+ field. Then trace back to find how that Name was assigned — if it
2829
+ came from a subscription/iteration of a list known to hold cdef
2830
+ instances, we can type it.
2831
+
2832
+ Also handle for-loop targets: for p in points → p is 'P' if
2833
+ points is known to hold P instances (tracked via known_vars or
2834
+ list construction).
2835
+ """
2836
+ result: dict[str, str] = {}
2837
+
2838
+ # Build a map: attr_name -> set of cdef class names that have it
2839
+ attr_to_classes: dict[str, set[str]] = {}
2840
+ for plan in self.class_plans.values():
2841
+ if plan.skip:
2842
+ continue
2843
+ for attr_name in plan.all_attrs:
2844
+ attr_to_classes.setdefault(attr_name, set()).add(plan.name)
2845
+
2846
+ # Build a map: var_name -> cdef class type from assignments
2847
+ # We look for patterns like:
2848
+ # var = list_var[index] (where list_var holds cdef instances)
2849
+ # var = Call(cdef_class, ...) (direct construction)
2850
+ # for var in list_var (iteration over cdef list)
2851
+ var_types: dict[str, str] = dict(known_vars)
2852
+
2853
+ # Track which list vars hold cdef instances
2854
+ # by looking at append calls: points.append(P(...))
2855
+ list_to_class: dict[str, str] = {}
2856
+ for node in ast.walk(func):
2857
+ if (isinstance(node, ast.Expr)
2858
+ and isinstance(node.value, ast.Call)
2859
+ and isinstance(node.value.func, ast.Attribute)
2860
+ and node.value.func.attr == "append"
2861
+ and isinstance(node.value.func.value, ast.Name)
2862
+ and node.value.args
2863
+ and isinstance(node.value.args[0], ast.Call)
2864
+ and isinstance(node.value.args[0].func, ast.Name)
2865
+ and node.value.args[0].func.id in self.cdef_class_names):
2866
+ list_to_class[node.value.func.value.id] = \
2867
+ node.value.args[0].func.id
2868
+
2869
+ # Also check: var = [P(...) for ...] or var = [P(...), ...]
2870
+ for node in ast.walk(func):
2871
+ if isinstance(node, ast.Assign):
2872
+ for target in node.targets:
2873
+ if isinstance(target, ast.Name):
2874
+ cls = self._find_list_var_type(func, target.id)
2875
+ if cls:
2876
+ list_to_class[target.id] = cls
2877
+
2878
+ # Infer from for-loop targets
2879
+ for node in ast.walk(func):
2880
+ if (isinstance(node, ast.For)
2881
+ and isinstance(node.target, ast.Name)
2882
+ and isinstance(node.iter, ast.Name)
2883
+ and node.iter.id in list_to_class):
2884
+ var_types[node.target.id] = list_to_class[node.iter.id]
2885
+
2886
+ # Infer from assignments: var = list_var[index]
2887
+ for node in ast.walk(func):
2888
+ if (isinstance(node, ast.Assign)
2889
+ and len(node.targets) == 1
2890
+ and isinstance(node.targets[0], ast.Name)
2891
+ and isinstance(node.value, ast.Subscript)
2892
+ and isinstance(node.value.value, ast.Name)
2893
+ and node.value.value.id in list_to_class):
2894
+ var_types[node.targets[0].id] = \
2895
+ list_to_class[node.value.value.id]
2896
+
2897
+ # Infer from direct construction: var = CdefClass(...)
2898
+ for node in ast.walk(func):
2899
+ if (isinstance(node, ast.Assign)
2900
+ and len(node.targets) == 1
2901
+ and isinstance(node.targets[0], ast.Name)
2902
+ and isinstance(node.value, ast.Call)
2903
+ and isinstance(node.value.func, ast.Name)
2904
+ and node.value.func.id in self.cdef_class_names):
2905
+ var_types[node.targets[0].id] = node.value.func.id
2906
+
2907
+ # Now find all Name.attr where attr is a cdef field,
2908
+ # and the Name is in var_types
2909
+ for node in ast.walk(func):
2910
+ if (isinstance(node, ast.Attribute)
2911
+ and isinstance(node.value, ast.Name)
2912
+ and node.value.id in var_types
2913
+ and node.attr in attr_to_classes):
2914
+ cls = var_types[node.value.id]
2915
+ if cls in attr_to_classes[node.attr]:
2916
+ result[node.value.id] = cls
2917
+
2918
+ # Also find: var.attr = value (AugAssign on cdef field)
2919
+ for node in ast.walk(func):
2920
+ if (isinstance(node, ast.AugAssign)
2921
+ and isinstance(node.target, ast.Attribute)
2922
+ and isinstance(node.target.value, ast.Name)
2923
+ and node.target.value.id in var_types
2924
+ and node.target.attr in attr_to_classes):
2925
+ cls = var_types[node.target.value.id]
2926
+ if cls in attr_to_classes[node.target.attr]:
2927
+ result[node.target.value.id] = cls
2928
+
2929
+ # Also include vars that call cdef class methods: p.scale(...)
2930
+ # Even though 'scale' is a method name (not in all_attrs), typing p
2931
+ # as the cdef class lets Cython resolve the method call faster.
2932
+ # Check both direct: p.method() and chained: points[i].method()
2933
+ cdef_class_names = self.cdef_class_names
2934
+ for node in ast.walk(func):
2935
+ if (isinstance(node, ast.Call)
2936
+ and isinstance(node.func, ast.Attribute)
2937
+ and isinstance(node.func.value, ast.Name)
2938
+ and node.func.value.id in var_types):
2939
+ var_name = node.func.value.id
2940
+ cls = var_types[var_name]
2941
+ if cls in cdef_class_names:
2942
+ result[var_name] = cls
2943
+
2944
+ # Also handle direct subscript calls: points[i].distance(...)
2945
+ # points[i] is not a Name, so it won't be in var_types directly.
2946
+ # But we can type the list var itself so Cython optimizes subscription.
2947
+ # This is handled implicitly: if points is not a Name.attr pattern,
2948
+ # we at least ensure any loop var or assigned var from points is typed.
2949
+
2950
+ return result
2951
+
2952
+ def _rewrite_cfunc_calls(self, func: ast.FunctionDef,
2953
+ loop_var_types: dict[str, str],
2954
+ all_cfunc_methods: dict[str, dict[str, str]]) -> None:
2955
+ class _Rewriter(ast.NodeTransformer):
2956
+ def __init__(self, var_types, cfunc_map):
2957
+ self.var_types = var_types
2958
+ self.cfunc_map = cfunc_map
2959
+
2960
+ def visit_Call(self, node):
2961
+ self.generic_visit(node)
2962
+ if (isinstance(node.func, ast.Attribute)
2963
+ and isinstance(node.func.value, ast.Name)
2964
+ and node.func.value.id in self.var_types):
2965
+ var_type = self.var_types[node.func.value.id]
2966
+ method_name = node.func.attr
2967
+ fast_map = self.cfunc_map.get(var_type, {})
2968
+ if method_name in fast_map and not node.keywords:
2969
+ node.func.attr = fast_map[method_name]
2970
+ return node
2971
+ _Rewriter(loop_var_types, all_cfunc_methods).visit(func)
2972
+
2973
+ # -----------------------------------------------------------------
2974
+ # Ensure `import cython`
2975
+ # -----------------------------------------------------------------
2976
+
2977
+ def _ensure_cython_import(self) -> None:
2978
+ for node in self.tree.body:
2979
+ if (isinstance(node, ast.Import)
2980
+ and any(a.name == "cython" for a in node.names)):
2981
+ return
2982
+ # Check if it's already there as ast.ImportFrom
2983
+ for node in self.tree.body:
2984
+ if (isinstance(node, ast.ImportFrom)
2985
+ and node.module == "cython"):
2986
+ return
2987
+ self.tree.body.insert(0, ast.Import(
2988
+ names=[ast.alias(name="cython", asname=None)]))
2989
+
2990
+
2991
+ # ---------------------------------------------------------------------------
2992
+ # Type classification helpers (for attribute inference)
2993
+ # ---------------------------------------------------------------------------
2994
+
2995
+ def _classify_literal(node: ast.AST) -> str:
2996
+ if isinstance(node, ast.Constant):
2997
+ if isinstance(node.value, bool):
2998
+ return LONGLONG
2999
+ if isinstance(node.value, float):
3000
+ return DOUBLE
3001
+ if isinstance(node.value, int):
3002
+ return LONGLONG
3003
+ return OBJECT
3004
+
3005
+
3006
+ def _classify_expr(node: ast.AST) -> str | None:
3007
+ if isinstance(node, ast.Constant):
3008
+ if isinstance(node.value, bool):
3009
+ return LONGLONG
3010
+ if isinstance(node.value, float):
3011
+ return DOUBLE
3012
+ if isinstance(node.value, int):
3013
+ return LONGLONG
3014
+ if isinstance(node, ast.BinOp):
3015
+ lt = _classify_expr(node.left)
3016
+ rt = _classify_expr(node.right)
3017
+ if lt and rt:
3018
+ if DOUBLE in (lt, rt):
3019
+ return DOUBLE
3020
+ return LONGLONG
3021
+ return None
3022
+ if isinstance(node, ast.UnaryOp):
3023
+ return _classify_expr(node.operand)
3024
+ if isinstance(node, ast.Call):
3025
+ func = node.func
3026
+ if isinstance(func, ast.Name):
3027
+ if func.id == "float":
3028
+ return DOUBLE
3029
+ if func.id in ("int", "len", "abs"):
3030
+ return LONGLONG
3031
+ if isinstance(node, ast.Compare):
3032
+ return LONGLONG
3033
+ return None