pycodemath 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,554 @@
1
+ """Emission of readable Python/NumPy code from IR (with subexpression elimination).
2
+
3
+ This is where "better code than naive Python" is born: ``sympy.cse`` catches
4
+ repeated subexpressions and computes them once, into helper variables.
5
+ The result is ready-to-use function source + its argument names.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ import sympy as sp
13
+ from sympy.printing.numpy import NumPyPrinter
14
+
15
+ from ..core.errors import PycodemathError
16
+ from ..core.ir import Expr, Matrix
17
+ from ..engine.numerics import _DOMAIN_ERRORS
18
+ from ..engine.ode import _DP_A, _DP_B5, _DP_C, _DP_E, _DP_P
19
+
20
+ _printer = NumPyPrinter()
21
+
22
+ #: Tuple of domain exceptions in the EMITTED code — printed from the same
23
+ #: constant as the engine (``numerics._DOMAIN_ERRORS``), so that
24
+ #: codegen<->engine parity does not drift apart with future changes.
25
+ _DOMAIN_ERRORS_SRC = "(" + ", ".join(e.__name__ for e in _DOMAIN_ERRORS) + ")"
26
+
27
+ # Header making the emitted source standalone (runnable after copying).
28
+ IMPORT_HEADER = "import numpy as np"
29
+
30
+ #: Names that a symbol/variable must NOT carry: they would become a parameter
31
+ #: of the emitted function and shadow the NumPy alias inside it.
32
+ _RESERVED_NAMES = {"np", "numpy"}
33
+
34
+
35
+ def _check_names(names, what: str) -> None:
36
+ # non-identifiers of Python (e.g. func_name from the direct API) injected
37
+ # into the emitted source would break the syntax or open an injection into exec —
38
+ # we reject them before they reach compile()
39
+ invalid = sorted(n for n in names if not str(n).isidentifier())
40
+ if invalid:
41
+ raise PycodemathError(
42
+ f"{what}: name {', '.join(map(repr, invalid))} is not a valid "
43
+ f"Python identifier — use letters/digits/underscores"
44
+ )
45
+ bad = sorted(_RESERVED_NAMES & set(names))
46
+ if bad:
47
+ raise PycodemathError(
48
+ f"{what}: name {', '.join(bad)} collides with the NumPy alias "
49
+ f"in the generated code — rename the symbol"
50
+ )
51
+
52
+
53
+ def _check_ode_params(var: str, func: str, func_name: str, what: str) -> None:
54
+ """Name validation for ODE emitters: valid identifiers + ``var != func``.
55
+
56
+ Emitters generate ``def _rhs(var, func):`` — when ``var == func``, it would produce
57
+ ``def _rhs(y, y):`` (duplicate argument = ``SyntaxError`` when running
58
+ the artifact). The engine (``engine.ode``) already blocks this; codegen must do the same,
59
+ so that the contract "always ``PycodemathError``" holds on both paths.
60
+ """
61
+ _check_names([var, func, func_name], what)
62
+ if var == func:
63
+ raise PycodemathError(
64
+ f"{what}: the name of the independent variable and the function must differ "
65
+ f"(both are {var!r}) — otherwise '_rhs({var}, {func})' has a duplicate argument"
66
+ )
67
+
68
+
69
+ def _pycode(sy: sp.Expr) -> str:
70
+ # NumPyPrinter prints calls as ``numpy.sin(...)``; we normalize to
71
+ # ``np.`` — consistent with the header ``import numpy as np``.
72
+ return _printer.doprint(sy).replace("numpy.", "np.")
73
+
74
+
75
+ @dataclass
76
+ class GeneratedCode:
77
+ """Generator result: standalone function source + metadata."""
78
+
79
+ source: str
80
+ func_name: str
81
+ arg_names: list[str]
82
+ header: str = IMPORT_HEADER
83
+
84
+ def __str__(self) -> str: # convenient preview in the REPL
85
+ return self.source
86
+
87
+
88
+ def _assemble(header: str, body_lines: list[str]) -> str:
89
+ """Assemble the complete, runnable source: import header + function body."""
90
+ return header + "\n\n\n" + "\n".join(body_lines)
91
+
92
+
93
+ def emit_function(
94
+ expr: Expr,
95
+ func_name: str = "f",
96
+ use_cse: bool = True,
97
+ ) -> GeneratedCode:
98
+ """Build the source of a Python/NumPy function computing ``expr``.
99
+
100
+ When ``use_cse`` — repeated subexpressions go into variables ``_c0``,
101
+ ``_c1``… and are computed once (faster code than a naive expansion).
102
+ """
103
+ arg_names = expr.symbol_names()
104
+ _check_names([*arg_names, func_name], "emit_function")
105
+ signature = f"def {func_name}({', '.join(arg_names)}):"
106
+ docstring = f' """Pycodemath: {func_name}({", ".join(arg_names)}) — NumPy code."""'
107
+
108
+ lines: list[str] = [signature, docstring]
109
+ if use_cse:
110
+ replacements, reduced = sp.cse(expr.sy, symbols=sp.numbered_symbols("_c"))
111
+ for tmp, sub in replacements:
112
+ lines.append(f" {tmp} = {_pycode(sub)}")
113
+ result = reduced[0]
114
+ else:
115
+ result = expr.sy
116
+ lines.append(f" return {_pycode(result)}")
117
+
118
+ return GeneratedCode(
119
+ source=_assemble(IMPORT_HEADER, lines),
120
+ func_name=func_name,
121
+ arg_names=arg_names,
122
+ )
123
+
124
+
125
+ def emit_ode(
126
+ rhs: Expr,
127
+ var: str = "t",
128
+ func: str = "y",
129
+ func_name: str = "solve_ode",
130
+ use_cse: bool = True,
131
+ ) -> GeneratedCode:
132
+ """Build the source of a standalone RK4 integrator for ``y'(t) = f(t, y)``.
133
+
134
+ ``rhs`` is the right-hand side (scalar ODE; emission of systems out of scope),
135
+ depending solely on ``var`` and ``func``. CSE acts on the right-hand side —
136
+ repeated subexpressions are computed once per ``_rhs`` call. The emitted
137
+ function takes RUNTIME arguments ``(y0, t0, t1, n)`` — and those are
138
+ in ``arg_names`` — and returns samples ``(ts, ys)``: exactly the same result
139
+ as ``engine.ode.solve_ode_num``.
140
+ """
141
+ rhs = Expr(rhs)
142
+ _check_ode_params(var, func, func_name, "emit_ode")
143
+ extra = sorted(set(rhs.symbol_names()) - {var, func})
144
+ if extra:
145
+ raise PycodemathError(
146
+ f"emit_ode: the right-hand side also depends on {', '.join(extra)} — "
147
+ f"substitute parameter values before generation"
148
+ )
149
+
150
+ if use_cse:
151
+ replacements, reduced = sp.cse(rhs.sy, symbols=sp.numbered_symbols("_c"))
152
+ result = reduced[0]
153
+ else:
154
+ replacements, result = [], rhs.sy
155
+
156
+ lines: list[str] = [
157
+ f"def {func_name}(y0, t0, t1, n):",
158
+ f' """Pycodemath: {func_name}(y0, t0, t1, n) '
159
+ f"— RK4 for {func}'({var}) = f({var}, {func}).\"\"\"",
160
+ # _rhs parameters are literally the symbol names — the CSE printout matches
161
+ f" def _rhs({var}, {func}):",
162
+ ]
163
+ for tmp, sub in replacements:
164
+ lines.append(f" {tmp} = {_pycode(sub)}")
165
+ lines += [
166
+ f" return {_pycode(result)}",
167
+ " h = (t1 - t0) / n",
168
+ " t = t0",
169
+ " y = y0",
170
+ " ts = [t]",
171
+ " ys = [y]",
172
+ " for _i in range(n):",
173
+ # consistent with the engine: divergence/out of domain -> readable error,
174
+ # never inf/NaN in the result (ASCII messages — the artifact is sometimes run
175
+ # on cp1252 consoles)
176
+ " try:",
177
+ " k1 = _rhs(t, y)",
178
+ " k2 = _rhs(t + h / 2, y + h / 2 * k1)",
179
+ " k3 = _rhs(t + h / 2, y + h / 2 * k2)",
180
+ " k4 = _rhs(t + h, y + h * k3)",
181
+ # IEEE order identical to the engine (_rk4): h/6.0 * (...) — bit-exact parity
182
+ " y = y + h / 6.0 * (k1 + 2 * k2 + 2 * k3 + k4)",
183
+ f" except {_DOMAIN_ERRORS_SRC} as exc:",
184
+ " raise ValueError(",
185
+ f" f'{func_name}: cannot compute step at "
186
+ "t={t:g} ({exc})') from exc",
187
+ " t = t0 + (_i + 1) * h",
188
+ " if not np.isfinite(y):",
189
+ " raise ValueError(",
190
+ f" f'{func_name}: solution diverges at "
191
+ "t={t:g} (NaN/inf) - narrow the interval or increase n')",
192
+ " ts.append(t)",
193
+ " ys.append(y)",
194
+ " return ts, ys",
195
+ ]
196
+
197
+ return GeneratedCode(
198
+ source=_assemble(IMPORT_HEADER, lines),
199
+ func_name=func_name,
200
+ arg_names=["y0", "t0", "t1", "n"],
201
+ )
202
+
203
+
204
+ def _floats(seq) -> str:
205
+ """Print a tuple of floats with full precision (``repr`` round-trips)."""
206
+ vals = ", ".join(repr(float(v)) for v in seq)
207
+ return f"({vals},)" if len(seq) == 1 else f"({vals})"
208
+
209
+
210
+ def emit_ode_adaptive(
211
+ rhs: Expr,
212
+ var: str = "t",
213
+ func: str = "y",
214
+ func_name: str = "solve_ode_adaptive",
215
+ use_cse: bool = True,
216
+ ) -> GeneratedCode:
217
+ """Build the source of a standalone ADAPTIVE integrator (DOPRI5).
218
+
219
+ The emitted solver mirrors the engine ``engine.ode.solve_ode_adaptive``:
220
+ a Dormand-Prince 5(4) pair with FSAL, step control with weight
221
+ ``atol + rtol·|y|`` (safety factor 0.9, exponent 1/5, growth ≤5×),
222
+ BACKWARD integration for ``t1 < t0`` (step sign), refusal at a
223
+ singularity. The coefficients and error formula are LITERALLY the same as
224
+ in the engine (import of ``_DP_*`` constants) — node-for-node parity.
225
+
226
+ The function takes ``(y0, t0, t1, rtol=1e-6, atol=1e-9,
227
+ max_steps=100000)`` and returns samples ``(ts, ys)``. Divergence /
228
+ singularity -> plain ``ValueError`` (ASCII messages — the artifact
229
+ is sometimes run on cp1252 consoles). Deliberately out of scope:
230
+ dense output and events in codegen.
231
+ """
232
+ rhs = Expr(rhs)
233
+ _check_ode_params(var, func, func_name, "emit_ode_adaptive")
234
+ extra = sorted(set(rhs.symbol_names()) - {var, func})
235
+ if extra:
236
+ raise PycodemathError(
237
+ f"emit_ode_adaptive: the right-hand side also depends on {', '.join(extra)} — "
238
+ f"substitute parameter values before generation"
239
+ )
240
+
241
+ if use_cse:
242
+ replacements, reduced = sp.cse(rhs.sy, symbols=sp.numbered_symbols("_c"))
243
+ result = reduced[0]
244
+ else:
245
+ replacements, result = [], rhs.sy
246
+
247
+ lines: list[str] = [
248
+ f"def {func_name}(y0, t0, t1, rtol=1e-6, atol=1e-9, max_steps=100000):",
249
+ f' """Pycodemath: {func_name}(y0, t0, t1, rtol, atol, max_steps) '
250
+ f"— DOPRI5 for {func}'({var}) = f({var}, {func}).\"\"\"",
251
+ f" def _rhs({var}, {func}):",
252
+ ]
253
+ for tmp, sub in replacements:
254
+ lines.append(f" {tmp} = {_pycode(sub)}")
255
+ lines += [
256
+ f" return {_pycode(result)}",
257
+ # DOPRI5 coefficients — bit-identical to the engine constants
258
+ f" _C = {_floats(_DP_C)}",
259
+ " _A = (" + ", ".join(_floats(row) for row in _DP_A) + ")",
260
+ f" _B5 = {_floats(_DP_B5)}",
261
+ f" _E = {_floats(_DP_E)}",
262
+ " if t1 == t0:",
263
+ f" raise ValueError('{func_name}: interval cannot have zero length')",
264
+ " if not (rtol > 0.0 and atol > 0.0):",
265
+ f" raise ValueError('{func_name}: rtol and atol must be positive')",
266
+ " hmin = 1e-12 * max(1.0, abs(t0), abs(t1))",
267
+ " sgn = 1.0 if t1 > t0 else -1.0",
268
+ " h = (t1 - t0) / 100.0",
269
+ " t = t0",
270
+ " y = y0",
271
+ " ts = [t]",
272
+ " ys = [y]",
273
+ " steps = 0",
274
+ " try:",
275
+ " k1 = _rhs(t, y)",
276
+ " if not np.isfinite(k1):",
277
+ " raise ValueError('start out of domain')",
278
+ f" except {_DOMAIN_ERRORS_SRC} as exc:",
279
+ " raise ValueError(",
280
+ f" f'{func_name}: cannot sample the right-hand side at the starting "
281
+ "point t={t:g}') from exc",
282
+ " while (t1 - t) * sgn > 0:",
283
+ " if steps >= max_steps:",
284
+ " raise ValueError(",
285
+ f" f'{func_name}: exceeded " + "{max_steps} steps "
286
+ "at t={t:g} - solution is probably singular')",
287
+ " last = (t + h - t1) * sgn >= 0",
288
+ " if last:",
289
+ " h = t1 - t",
290
+ " try:",
291
+ " ks = [k1]",
292
+ " for _ci, _ai in zip(_C, _A):",
293
+ " _yi = y + h * sum(_ai[_j] * ks[_j] for _j in range(len(_ai)))",
294
+ " ks.append(_rhs(t + _ci * h, _yi))",
295
+ " y5 = y + h * sum(_B5[_i] * ks[_i] for _i in range(7))",
296
+ " err = h * sum(_E[_i] * ks[_i] for _i in range(7))",
297
+ " if not (np.isfinite(y5) and np.isfinite(err)):",
298
+ " raise ValueError('step out of domain')",
299
+ f" except {_DOMAIN_ERRORS_SRC}:",
300
+ " h *= 0.5",
301
+ " if abs(h) < hmin:",
302
+ " raise ValueError(",
303
+ f" f'{func_name}: cannot sample the right-hand side "
304
+ "at t={t:g} (singularity or out of domain)')",
305
+ " continue",
306
+ " sc = atol + rtol * np.maximum(np.abs(y), np.abs(y5))",
307
+ " e = float(np.sqrt(np.mean((err / sc) ** 2)))",
308
+ " if e <= 1.0:",
309
+ " t = t1 if last else t + h",
310
+ " y = y5",
311
+ " k1 = ks[6]",
312
+ " ts.append(t)",
313
+ " ys.append(y)",
314
+ " steps += 1",
315
+ " grow = 5.0 if e == 0.0 else 0.9 * e ** -0.2",
316
+ " h *= min(5.0, grow)",
317
+ " else:",
318
+ " h *= max(0.2, 0.9 * e ** -0.2)",
319
+ " if abs(h) < hmin:",
320
+ " raise ValueError(",
321
+ f" f'{func_name}: step shrank below the limit "
322
+ "at t={t:g} - singularity (integration refuses)')",
323
+ " return ts, ys",
324
+ ]
325
+
326
+ return GeneratedCode(
327
+ source=_assemble(IMPORT_HEADER, lines),
328
+ func_name=func_name,
329
+ arg_names=["y0", "t0", "t1", "rtol", "atol", "max_steps"],
330
+ )
331
+
332
+
333
+ def emit_ode_dense(
334
+ rhs: Expr,
335
+ var: str = "t",
336
+ func: str = "y",
337
+ func_name: str = "solve_ode_dense",
338
+ use_cse: bool = True,
339
+ ) -> GeneratedCode:
340
+ """Build the source of a standalone solver with DENSE OUTPUT (DOPRI5).
341
+
342
+ The emitted solver integrates like ``emit_ode_adaptive`` (the same loop,
343
+ coefficients and error formula — bit-exact parity with the engine), but on
344
+ each accepted step it stores a segment ``(t_old, h, y_old, ks)``
345
+ and instead of samples alone returns an INTERPOLANT FUNCTION ``sol``:
346
+ ``sol(t)`` reads ``y(t)`` at ANY point of the interval through the
347
+ continuous 4th-order DOPRI5 extension (formula from ``engine._seg_eval``,
348
+ coefficients ``_DP_P`` imported from the engine constants and printed
349
+ with ``repr`` — a single source of truth). Nodes available as ``sol.ts`` /
350
+ ``sol.ys``; scalar -> ``float``, sequence -> ``np.ndarray``;
351
+ ``t`` out of the interval / singularity -> plain ``ValueError``
352
+ (ASCII messages — the artifact is sometimes run on cp1252 consoles).
353
+
354
+ The function takes ``(y0, t0, t1, rtol=1e-6, atol=1e-9,
355
+ max_steps=100000)``. Deliberately out of scope: events in codegen.
356
+ """
357
+ rhs = Expr(rhs)
358
+ _check_ode_params(var, func, func_name, "emit_ode_dense")
359
+ extra = sorted(set(rhs.symbol_names()) - {var, func})
360
+ if extra:
361
+ raise PycodemathError(
362
+ f"emit_ode_dense: the right-hand side also depends on {', '.join(extra)} — "
363
+ f"substitute parameter values before generation"
364
+ )
365
+
366
+ if use_cse:
367
+ replacements, reduced = sp.cse(rhs.sy, symbols=sp.numbered_symbols("_c"))
368
+ result = reduced[0]
369
+ else:
370
+ replacements, result = [], rhs.sy
371
+
372
+ lines: list[str] = [
373
+ f"def {func_name}(y0, t0, t1, rtol=1e-6, atol=1e-9, max_steps=100000):",
374
+ f' """Pycodemath: {func_name}(y0, t0, t1, rtol, atol, max_steps) '
375
+ f"— DOPRI5 with dense output for {func}'({var}) = f({var}, {func}); "
376
+ 'returns sol(t) (nodes: sol.ts, sol.ys)."""',
377
+ f" def _rhs({var}, {func}):",
378
+ ]
379
+ for tmp, sub in replacements:
380
+ lines.append(f" {tmp} = {_pycode(sub)}")
381
+ lines += [
382
+ f" return {_pycode(result)}",
383
+ # DOPRI5 coefficients + interpolant — bit-identical to the engine
384
+ f" _C = {_floats(_DP_C)}",
385
+ " _A = (" + ", ".join(_floats(row) for row in _DP_A) + ")",
386
+ f" _B5 = {_floats(_DP_B5)}",
387
+ f" _E = {_floats(_DP_E)}",
388
+ " _P = np.array((" + ", ".join(_floats(row) for row in _DP_P) + "))",
389
+ " if t1 == t0:",
390
+ f" raise ValueError('{func_name}: interval cannot have zero length')",
391
+ " if not (rtol > 0.0 and atol > 0.0):",
392
+ f" raise ValueError('{func_name}: rtol and atol must be positive')",
393
+ " hmin = 1e-12 * max(1.0, abs(t0), abs(t1))",
394
+ " sgn = 1.0 if t1 > t0 else -1.0",
395
+ " h = (t1 - t0) / 100.0",
396
+ " t = t0",
397
+ " y = y0",
398
+ " ts = [t]",
399
+ " ys = [y]",
400
+ " segs = []",
401
+ " steps = 0",
402
+ " try:",
403
+ " k1 = _rhs(t, y)",
404
+ " if not np.isfinite(k1):",
405
+ " raise ValueError('start out of domain')",
406
+ f" except {_DOMAIN_ERRORS_SRC} as exc:",
407
+ " raise ValueError(",
408
+ f" f'{func_name}: cannot sample the right-hand side at the starting "
409
+ "point t={t:g}') from exc",
410
+ " while (t1 - t) * sgn > 0:",
411
+ " if steps >= max_steps:",
412
+ " raise ValueError(",
413
+ f" f'{func_name}: exceeded " + "{max_steps} steps "
414
+ "at t={t:g} - solution is probably singular')",
415
+ " last = (t + h - t1) * sgn >= 0",
416
+ " if last:",
417
+ " h = t1 - t",
418
+ " try:",
419
+ " ks = [k1]",
420
+ " for _ci, _ai in zip(_C, _A):",
421
+ " _yi = y + h * sum(_ai[_j] * ks[_j] for _j in range(len(_ai)))",
422
+ " ks.append(_rhs(t + _ci * h, _yi))",
423
+ " y5 = y + h * sum(_B5[_i] * ks[_i] for _i in range(7))",
424
+ " err = h * sum(_E[_i] * ks[_i] for _i in range(7))",
425
+ " if not (np.isfinite(y5) and np.isfinite(err)):",
426
+ " raise ValueError('step out of domain')",
427
+ f" except {_DOMAIN_ERRORS_SRC}:",
428
+ " h *= 0.5",
429
+ " if abs(h) < hmin:",
430
+ " raise ValueError(",
431
+ f" f'{func_name}: cannot sample the right-hand side "
432
+ "at t={t:g} (singularity or out of domain)')",
433
+ " continue",
434
+ " sc = atol + rtol * np.maximum(np.abs(y), np.abs(y5))",
435
+ " e = float(np.sqrt(np.mean((err / sc) ** 2)))",
436
+ " if e <= 1.0:",
437
+ " segs.append((t, h, y, ks))", # before update: t_old, y_old
438
+ " t = t1 if last else t + h",
439
+ " y = y5",
440
+ " k1 = ks[6]",
441
+ " ts.append(t)",
442
+ " ys.append(y)",
443
+ " steps += 1",
444
+ " grow = 5.0 if e == 0.0 else 0.9 * e ** -0.2",
445
+ " h *= min(5.0, grow)",
446
+ " else:",
447
+ " h *= max(0.2, 0.9 * e ** -0.2)",
448
+ " if abs(h) < hmin:",
449
+ " raise ValueError(",
450
+ f" f'{func_name}: step shrank below the limit "
451
+ "at t={t:g} - singularity (integration refuses)')",
452
+ # dense output: bisection over segment starts ALONG the run
453
+ # (multiplication by sgn brings them to ascending order — backward also
454
+ # works), read via the continuous 4th-order extension (formula _seg_eval)
455
+ " _starts = np.array([sgn * _s[0] for _s in segs])",
456
+ " _lo = t0 if t0 < t1 else t1",
457
+ " _hi = t1 if t0 < t1 else t0",
458
+ " _eps = 1e-9 * max(1.0, abs(_lo), abs(_hi))",
459
+ " def _eval_one(_tq):",
460
+ " if _tq < _lo - _eps or _tq > _hi + _eps:",
461
+ " raise ValueError(",
462
+ f" f'{func_name}: t=" + "{_tq:g} outside the interval "
463
+ "[{t0:g}, {t1:g}]')",
464
+ " _idx = int(np.searchsorted(_starts, sgn * _tq, side='right') - 1)",
465
+ " _idx = min(max(_idx, 0), len(segs) - 1)",
466
+ " _to, _h, _yo, _ks = segs[_idx]",
467
+ " _th = (_tq - _to) / _h",
468
+ " _pw = np.array([_th, _th**2, _th**3, _th**4])",
469
+ " _acc = sum(_ks[_i] * float(_P[_i] @ _pw) for _i in range(7))",
470
+ " return _yo + _h * _acc",
471
+ " def _sol(t_query):",
472
+ " if np.ndim(t_query) == 0:",
473
+ " return float(_eval_one(float(t_query)))",
474
+ " return np.array([float(_eval_one(float(_v))) for _v in t_query])",
475
+ " _sol.ts = ts",
476
+ " _sol.ys = ys",
477
+ " return _sol",
478
+ ]
479
+
480
+ return GeneratedCode(
481
+ source=_assemble(IMPORT_HEADER, lines),
482
+ func_name=func_name,
483
+ arg_names=["y0", "t0", "t1", "rtol", "atol", "max_steps"],
484
+ )
485
+
486
+
487
+ def emit_solver(
488
+ A,
489
+ b,
490
+ func_name: str = "solve",
491
+ use_cse: bool = True,
492
+ ) -> GeneratedCode:
493
+ """Build the source of a function solving the system ``A x = b`` via NumPy.
494
+
495
+ Instead of expanding a symbolic inverse (costly and numerically
496
+ unstable), we emit code that builds the matrices and calls
497
+ ``numpy.linalg.solve`` — this is the "optimized NumPy code".
498
+ When ``A``/``b`` have parameters, they become arguments of the function;
499
+ repeated matrix entries are caught by CSE.
500
+ """
501
+ A = Matrix(A)
502
+ b = Matrix(b)
503
+ n, m = A.sy.rows, A.sy.cols
504
+
505
+ arg_names = sorted({s.name for s in A.sy.free_symbols} | {s.name for s in b.sy.free_symbols})
506
+ _check_names([*arg_names, func_name], "emit_solver")
507
+ entries = list(A.sy) + list(b.sy) # row-major: n*m entries of A, then n entries of b
508
+
509
+ # dtype (DECISION — Phase 3): three cases, because SymPy knows realness as
510
+ # True/False/None. Explicitly complex entry → ``complex``. All explicitly
511
+ # real → ``float`` (readable, exact). UNKNOWN realness (``is_real
512
+ # is None`` — e.g. a bare ``Symbol('a')``) → we do NOT force dtype: if a
513
+ # runtime argument made the entry complex, ``dtype=float`` would silently cut off
514
+ # the imaginary part (garbage result — breaking the "zero garbage" contract). Without
515
+ # dtype NumPy infers from the values: real input stays ``float64``,
516
+ # complex gives correct ``complex128``.
517
+ if any(e.is_real is False for e in entries):
518
+ dtype = "complex"
519
+ elif entries and all(e.is_real for e in entries): # None is falsy → here only pure True
520
+ dtype = "float" # empty list does NOT force float (all([]) == True)
521
+ else:
522
+ dtype = None
523
+ dtype_kw = "" if dtype is None else f", dtype={dtype}"
524
+
525
+ if use_cse:
526
+ replacements, reduced = sp.cse(entries, symbols=sp.numbered_symbols("_c"))
527
+ else:
528
+ replacements, reduced = [], entries
529
+ a_red, b_red = reduced[: n * m], reduced[n * m :]
530
+
531
+ signature = f"def {func_name}({', '.join(arg_names)}):"
532
+ docstring = (
533
+ f' """Pycodemath: {func_name}({", ".join(arg_names)}) '
534
+ f'— solves A x = b via NumPy."""'
535
+ )
536
+ lines: list[str] = [signature, docstring]
537
+ for tmp, sub in replacements:
538
+ lines.append(f" {tmp} = {_pycode(sub)}")
539
+
540
+ matrix_rows = [
541
+ "[" + ", ".join(_pycode(a_red[i * m + j]) for j in range(m)) + "]"
542
+ for i in range(n)
543
+ ]
544
+ lines.append(f" _A = np.array([{', '.join(matrix_rows)}]{dtype_kw})")
545
+ lines.append(
546
+ f" _b = np.array([{', '.join(_pycode(e) for e in b_red)}]{dtype_kw})"
547
+ )
548
+ lines.append(" return np.linalg.solve(_A, _b)")
549
+
550
+ return GeneratedCode(
551
+ source=_assemble(IMPORT_HEADER, lines),
552
+ func_name=func_name,
553
+ arg_names=arg_names,
554
+ )
@@ -0,0 +1,139 @@
1
+ """Code generator pipeline (module 2): IR → simplify → CSE → code + callable.
2
+
3
+ Ties source emission (``emit``) to compilation into a live function.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Callable
10
+
11
+ import numpy as np
12
+
13
+ from ..core.ir import Expr
14
+ from .emit import (
15
+ GeneratedCode,
16
+ emit_function,
17
+ emit_ode,
18
+ emit_ode_adaptive,
19
+ emit_ode_dense,
20
+ emit_solver,
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class CodeArtifact:
26
+ """Full generation result: source, live function and metadata."""
27
+
28
+ code: GeneratedCode
29
+ func: Callable
30
+
31
+ @property
32
+ def source(self) -> str:
33
+ return self.code.source
34
+
35
+ def __call__(self, *args, **kwargs):
36
+ return self.func(*args, **kwargs)
37
+
38
+
39
+ def generate(
40
+ expr: Expr,
41
+ func_name: str = "f",
42
+ simplify: bool = True,
43
+ use_cse: bool = True,
44
+ ) -> CodeArtifact:
45
+ """Generate an optimized Python/NumPy function from an IR expression.
46
+
47
+ Steps: (optional) simplify → CSE → source emission → compilation.
48
+ """
49
+ work = expr.simplify() if simplify else expr
50
+ generated = emit_function(work, func_name=func_name, use_cse=use_cse)
51
+ return CodeArtifact(code=generated, func=_compile(generated))
52
+
53
+
54
+ def generate_system(
55
+ A,
56
+ b,
57
+ func_name: str = "solve",
58
+ use_cse: bool = True,
59
+ ) -> CodeArtifact:
60
+ """Generate an optimized NumPy function solving the system ``A x = b``.
61
+
62
+ Steps: solver emission (CSE over the matrix entries) → compilation. The
63
+ returned function takes the system parameters and returns the solution vector.
64
+ """
65
+ generated = emit_solver(A, b, func_name=func_name, use_cse=use_cse)
66
+ return CodeArtifact(code=generated, func=_compile(generated))
67
+
68
+
69
+ def generate_ode(
70
+ rhs: Expr,
71
+ var: str = "t",
72
+ func: str = "y",
73
+ func_name: str = "solve_ode",
74
+ use_cse: bool = True,
75
+ ) -> CodeArtifact:
76
+ """Generate a standalone RK4 integrator (Python/NumPy) for ``y' = f(t, y)``.
77
+
78
+ Steps: integrator emission (CSE over the right-hand side) → compilation. The
79
+ returned function takes ``(y0, t0, t1, n)`` and returns samples ``(ts, ys)``
80
+ — the same as ``engine.ode.solve_ode_num``.
81
+ """
82
+ generated = emit_ode(
83
+ Expr(rhs), var=var, func=func, func_name=func_name, use_cse=use_cse
84
+ )
85
+ return CodeArtifact(code=generated, func=_compile(generated))
86
+
87
+
88
+ def generate_ode_adaptive(
89
+ rhs: Expr,
90
+ var: str = "t",
91
+ func: str = "y",
92
+ func_name: str = "solve_ode_adaptive",
93
+ use_cse: bool = True,
94
+ ) -> CodeArtifact:
95
+ """Generate a standalone ADAPTIVE DOPRI5 integrator for ``y' = f(t, y)``.
96
+
97
+ Steps: integrator emission (CSE over the right-hand side) → compilation. The
98
+ returned function takes ``(y0, t0, t1, rtol=1e-6, atol=1e-9,
99
+ max_steps=100000)`` and returns samples ``(ts, ys)`` — node parity with
100
+ ``engine.ode.solve_ode_adaptive`` (the same coefficients and step control,
101
+ including backward integration).
102
+ """
103
+ generated = emit_ode_adaptive(
104
+ Expr(rhs), var=var, func=func, func_name=func_name, use_cse=use_cse
105
+ )
106
+ return CodeArtifact(code=generated, func=_compile(generated))
107
+
108
+
109
+ def generate_ode_dense(
110
+ rhs: Expr,
111
+ var: str = "t",
112
+ func: str = "y",
113
+ func_name: str = "solve_ode_dense",
114
+ use_cse: bool = True,
115
+ ) -> CodeArtifact:
116
+ """Generate a standalone solver with DENSE OUTPUT (DOPRI5) for ``y' = f(t, y)``.
117
+
118
+ Steps: integrator emission with an interpolant (CSE over the right-hand
119
+ side) → compilation. The returned function takes ``(y0, t0, t1, rtol=1e-6,
120
+ atol=1e-9, max_steps=100000)`` and returns an INTERPOLANT FUNCTION ``sol`` —
121
+ ``sol(t)`` reads ``y(t)`` at any point (bitwise parity of nodes and
122
+ interpolant with ``engine.ode.solve_ode_dense``); nodes: ``sol.ts`` / ``sol.ys``.
123
+ """
124
+ generated = emit_ode_dense(
125
+ Expr(rhs), var=var, func=func, func_name=func_name, use_cse=use_cse
126
+ )
127
+ return CodeArtifact(code=generated, func=_compile(generated))
128
+
129
+
130
+ def _compile(generated: GeneratedCode) -> Callable:
131
+ """Compile the emitted source into a live function.
132
+
133
+ The emitted code uses the ``np.``/``numpy.`` prefix (and imports NumPy
134
+ itself), so the namespace is limited to the module aliases — without
135
+ injecting hundreds of names from ``dir(np)``.
136
+ """
137
+ namespace: dict = {"np": np, "numpy": np}
138
+ exec(compile(generated.source, "<pycodemath>", "exec"), namespace)
139
+ return namespace[generated.func_name]