pygeomatic 0.2.2__tar.gz → 0.4__tar.gz
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.
- {pygeomatic-0.2.2 → pygeomatic-0.4}/.gitignore +2 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/PKG-INFO +1 -1
- {pygeomatic-0.2.2 → pygeomatic-0.4}/pyproject.toml +1 -1
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/__init__.py +8 -2
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/article.py +146 -2
- pygeomatic-0.4/src/pygeomatic/cond.py +246 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/autograd_functions.py +3 -1
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/curve_functions.py +5 -2
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/inference.py +46 -18
- pygeomatic-0.4/src/pygeomatic/katex_commands.json +1045 -0
- pygeomatic-0.4/src/pygeomatic/latex_lint.py +111 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/nodes.py +31 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/palette.py +10 -8
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/prompting.py +11 -1
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/store.py +6 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/tex.py +56 -6
- pygeomatic-0.4/src/pygeomatic/ui.py +339 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/LICENSE +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/README.md +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/coercions.json +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/coercions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/emit.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/extensions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/__init__.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/helpers.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/__init__.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/annotation_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/array.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/basic_figures.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/boolean_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/complex_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/intersections.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/ode_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/planar_geometry.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/polygons.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/property_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/rotation_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/scalar_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/special_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/tangent_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/tensor_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/translation_functions.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/__init__.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/abs.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/add.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/create_overload.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/div.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/exp.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/log.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/mul.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/neg.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/pow.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/sqrt.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/sub.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/generate.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/macros.json +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/macros.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/parse.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/registry.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/runner.py +0 -0
- {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/system_nodes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygeomatic
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: Python mirror of the geomatic DSL function library; records calls for deterministic DSL emission
|
|
5
5
|
Project-URL: Homepage, https://github.com/TinyVolt/pygeomatic
|
|
6
6
|
Project-URL: Repository, https://github.com/TinyVolt/pygeomatic
|
|
@@ -22,8 +22,12 @@ from .article import ( # noqa: F401
|
|
|
22
22
|
ArticleResult,
|
|
23
23
|
article_mode,
|
|
24
24
|
compile_article,
|
|
25
|
+
md,
|
|
25
26
|
run_article,
|
|
27
|
+
when,
|
|
26
28
|
)
|
|
29
|
+
from . import cond # noqa: F401 — gm.cond.ge(k, 2), gm.cond.all_(...), ...
|
|
30
|
+
from .cond import Cond, CondError # noqa: F401
|
|
27
31
|
from .coercions import allow_coercions, coercions_enabled # noqa: F401
|
|
28
32
|
from .emit import emit, render_command, render_token # noqa: F401
|
|
29
33
|
from .extensions import ( # noqa: F401
|
|
@@ -118,6 +122,8 @@ from .tex import ( # noqa: F401
|
|
|
118
122
|
rows,
|
|
119
123
|
tex,
|
|
120
124
|
)
|
|
125
|
+
from . import ui # noqa: F401 — gm.ui.slider(...), gm.ui.checkbox(...), ...
|
|
126
|
+
from .ui import UIError, render_widget_html # noqa: F401
|
|
121
127
|
|
|
122
128
|
|
|
123
129
|
def __getattr__(name: str) -> GNode:
|
|
@@ -175,8 +181,8 @@ from .functions.implementations.intersections import ( # noqa: F401
|
|
|
175
181
|
from .functions.implementations.curve_functions import ( # noqa: F401
|
|
176
182
|
clear_trail,
|
|
177
183
|
evaluate_polynomial,
|
|
178
|
-
plot,
|
|
179
184
|
plot_inverse,
|
|
185
|
+
plot_reactive,
|
|
180
186
|
polynomial,
|
|
181
187
|
trail,
|
|
182
188
|
)
|
|
@@ -254,7 +260,7 @@ from .functions.implementations.autograd_functions import ( # noqa: F401
|
|
|
254
260
|
gradient_descent_step,
|
|
255
261
|
minimize,
|
|
256
262
|
param,
|
|
257
|
-
|
|
263
|
+
partial_derivative,
|
|
258
264
|
reevaluate,
|
|
259
265
|
vector_field,
|
|
260
266
|
zero_grad,
|
|
@@ -35,6 +35,8 @@ import subprocess
|
|
|
35
35
|
import sys
|
|
36
36
|
from contextlib import contextmanager
|
|
37
37
|
from contextvars import ContextVar
|
|
38
|
+
from html import escape
|
|
39
|
+
from inspect import cleandoc
|
|
38
40
|
from dataclasses import dataclass, field
|
|
39
41
|
from typing import Optional, Sequence, Union
|
|
40
42
|
|
|
@@ -42,6 +44,7 @@ import json
|
|
|
42
44
|
|
|
43
45
|
from .coercions import allow_coercions
|
|
44
46
|
from .emit import emit, render_command
|
|
47
|
+
from .latex_lint import lint_latex
|
|
45
48
|
from .parse import DslParseError, parse_dsl
|
|
46
49
|
from .store import Store, _article_replay, _auto_create_enabled, current_store
|
|
47
50
|
from .tex import harvest_tex_bindings
|
|
@@ -96,6 +99,9 @@ class _Group:
|
|
|
96
99
|
class _Recorder:
|
|
97
100
|
groups: list[_Group] = field(default_factory=list)
|
|
98
101
|
open: Optional[str] = None
|
|
102
|
+
# Markdown the CURRENT executable has emitted via gm.md(), in call order.
|
|
103
|
+
# Reset by _execute before each fence; drained into the fence afterwards.
|
|
104
|
+
md: list[str] = field(default_factory=list)
|
|
99
105
|
|
|
100
106
|
|
|
101
107
|
_recorder: ContextVar[Optional[_Recorder]] = ContextVar(
|
|
@@ -137,6 +143,96 @@ def group(name: str):
|
|
|
137
143
|
rec.groups.append(_Group(name, start, end))
|
|
138
144
|
|
|
139
145
|
|
|
146
|
+
def md(text: str) -> None:
|
|
147
|
+
"""Write markdown into the article at the point this block sits.
|
|
148
|
+
|
|
149
|
+
A ```pygeomatic block normally contributes only hidden setup commands. This
|
|
150
|
+
is how it contributes visible prose as well — which is what makes a control
|
|
151
|
+
reachable, since `f"{r}"` only works inside python:
|
|
152
|
+
|
|
153
|
+
r = gm.ui.slider(1, 5, label="radius")
|
|
154
|
+
gm.md(f"Drag to resize the circle: {r}")
|
|
155
|
+
|
|
156
|
+
Calls accumulate in order and land after the block's setup commands.
|
|
157
|
+
`cleandoc` strips the uniform indentation of a triple-quoted string, so
|
|
158
|
+
prose written at any indent level reaches markdown flush-left rather than
|
|
159
|
+
as a code block.
|
|
160
|
+
|
|
161
|
+
Deliberately NOT rescanned for `{label}(command)` links: a link has to be
|
|
162
|
+
executed to produce its DSL, and by the time this text exists all execution
|
|
163
|
+
has finished. Maths, controls and conditional blocks are all fine.
|
|
164
|
+
"""
|
|
165
|
+
rec = _recorder.get()
|
|
166
|
+
if rec is None:
|
|
167
|
+
raise ArticleError(
|
|
168
|
+
None, "gm.md() is only usable inside a pygeomatic article block"
|
|
169
|
+
)
|
|
170
|
+
if not isinstance(text, str):
|
|
171
|
+
raise ArticleError(None, f"gm.md() takes a string, got {type(text).__name__}")
|
|
172
|
+
rec.md.append(cleandoc(text))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@contextmanager
|
|
176
|
+
def when(condition):
|
|
177
|
+
"""Show the markdown in this block only while `condition` holds.
|
|
178
|
+
|
|
179
|
+
The condition is checked in the READER's browser, against live store nodes,
|
|
180
|
+
so the prose appears and disappears as they move a control:
|
|
181
|
+
|
|
182
|
+
show = gm.ui.checkbox(False, label="Show the proof")
|
|
183
|
+
with gm.when(show):
|
|
184
|
+
gm.md("Because $ab=ba$, the map commutes.")
|
|
185
|
+
|
|
186
|
+
Only gm.md() output is gated. Commands recorded inside the block go onto
|
|
187
|
+
the tape as usual — hiding prose must not silently change the scene.
|
|
188
|
+
|
|
189
|
+
Blocks nest; an inner block shows only when both conditions hold.
|
|
190
|
+
|
|
191
|
+
Do not put command links inside a gated block. Commands are numbered by
|
|
192
|
+
document position, so hiding one doesn't remove it from the sequence: the
|
|
193
|
+
reader would be left waiting on a link they cannot see. Prose, maths and
|
|
194
|
+
controls are all fine.
|
|
195
|
+
"""
|
|
196
|
+
from .cond import evaluate, node_refs, to_payload
|
|
197
|
+
|
|
198
|
+
rec = _recorder.get()
|
|
199
|
+
if rec is None:
|
|
200
|
+
raise ArticleError(
|
|
201
|
+
None, "gm.when() is only usable inside a pygeomatic article block"
|
|
202
|
+
)
|
|
203
|
+
payload = to_payload(condition)
|
|
204
|
+
|
|
205
|
+
outer = rec.md
|
|
206
|
+
rec.md = []
|
|
207
|
+
try:
|
|
208
|
+
yield
|
|
209
|
+
finally:
|
|
210
|
+
inner = [chunk for chunk in rec.md if chunk.strip()]
|
|
211
|
+
rec.md = outer
|
|
212
|
+
|
|
213
|
+
if not inner:
|
|
214
|
+
raise ArticleError(None, "gm.when() block produced no markdown")
|
|
215
|
+
|
|
216
|
+
# Start hidden when the condition is already false, so the block never
|
|
217
|
+
# flashes on screen before the browser's watcher runs. Values come from the
|
|
218
|
+
# nodes as they stand right now; an unknown value reads as false.
|
|
219
|
+
store = current_store()
|
|
220
|
+
values = {}
|
|
221
|
+
for node_id in node_refs(payload, set()):
|
|
222
|
+
node = store.nodes.get(node_id)
|
|
223
|
+
values[node_id] = getattr(node, "numeric", None) if node else None
|
|
224
|
+
hidden = "" if evaluate(payload, values) else ' style="display:none"'
|
|
225
|
+
|
|
226
|
+
data = json.dumps(payload, separators=(",", ":"), sort_keys=False)
|
|
227
|
+
body = "\n\n".join(inner)
|
|
228
|
+
# The blank lines are load-bearing: markdown stops treating a block as raw
|
|
229
|
+
# HTML at the first blank line, so the prose inside is still formatted
|
|
230
|
+
# normally (and the raw file still reads correctly on GitHub).
|
|
231
|
+
rec.md.append(
|
|
232
|
+
f'<div class="nova-when" data-when=\'{escape(data)}\'{hidden}>\n\n{body}\n\n</div>'
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
140
236
|
# ---------------------------------------------------------------------------
|
|
141
237
|
# Span scanner
|
|
142
238
|
# ---------------------------------------------------------------------------
|
|
@@ -228,6 +324,36 @@ def _scan_spans(text: str) -> list[_Span]:
|
|
|
228
324
|
return spans
|
|
229
325
|
|
|
230
326
|
|
|
327
|
+
def _iter_math(text: str, base_lineno: int):
|
|
328
|
+
"""Yield `(latex_body, lineno)` for each `$…$` / `$$…$$` region in `text` —
|
|
329
|
+
the inverse of `_scan_spans`' skip rule, so the linter sees exactly the
|
|
330
|
+
regions the span scanner passes over."""
|
|
331
|
+
i, n = 0, len(text)
|
|
332
|
+
while i < n:
|
|
333
|
+
if text[i] == "$":
|
|
334
|
+
end = _skip_math_at(text, i)
|
|
335
|
+
if end is not None:
|
|
336
|
+
is_block = text[i + 1 : i + 2] == "$"
|
|
337
|
+
open_ = i + (2 if is_block else 1)
|
|
338
|
+
close = end - (2 if is_block else 1)
|
|
339
|
+
yield text[open_:close], base_lineno + text[:i].count("\n")
|
|
340
|
+
i = end
|
|
341
|
+
continue
|
|
342
|
+
i += 1
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _lint_math(parts: Sequence[Union["_Prose", "_Fence"]]) -> None:
|
|
346
|
+
"""Fail the compile on undefined KaTeX macros / the `#`-hex footgun in prose
|
|
347
|
+
math, before any Python runs. Skips code fences (Python / verbatim)."""
|
|
348
|
+
for part in parts:
|
|
349
|
+
if isinstance(part, _Fence) or not part.scan:
|
|
350
|
+
continue
|
|
351
|
+
for latex, lineno in _iter_math(part.text, part.lineno):
|
|
352
|
+
problems = lint_latex(latex)
|
|
353
|
+
if problems:
|
|
354
|
+
raise ArticleError(lineno, problems[0])
|
|
355
|
+
|
|
356
|
+
|
|
231
357
|
# ---------------------------------------------------------------------------
|
|
232
358
|
# Markdown segmentation
|
|
233
359
|
# ---------------------------------------------------------------------------
|
|
@@ -339,6 +465,7 @@ class _Executable:
|
|
|
339
465
|
span: Optional[_Span] = None # inline only
|
|
340
466
|
start: int = -1 # tape slice [start:end) once executed
|
|
341
467
|
end: int = -1
|
|
468
|
+
md: str = "" # markdown this executable emitted via gm.md()
|
|
342
469
|
|
|
343
470
|
|
|
344
471
|
def _author_lineno(exc: BaseException, fallback: int) -> int:
|
|
@@ -356,7 +483,7 @@ def _execute(executables: list[_Executable], allow: bool) -> tuple[Store, list[_
|
|
|
356
483
|
"""Run the article's Python in one shared store; returns it and the groups."""
|
|
357
484
|
import pygeomatic as gm # the fully-initialized package, for the namespace
|
|
358
485
|
|
|
359
|
-
namespace = {"gm": gm, "group": group}
|
|
486
|
+
namespace = {"gm": gm, "group": group, "md": md, "when": when}
|
|
360
487
|
rec = _Recorder()
|
|
361
488
|
rec_token = _recorder.set(rec)
|
|
362
489
|
try:
|
|
@@ -364,6 +491,7 @@ def _execute(executables: list[_Executable], allow: bool) -> tuple[Store, list[_
|
|
|
364
491
|
for ex in executables:
|
|
365
492
|
ex.start = len(store.commands)
|
|
366
493
|
groups_before = len(rec.groups)
|
|
494
|
+
rec.md = []
|
|
367
495
|
# Pad so tracebacks and SyntaxErrors carry article line numbers.
|
|
368
496
|
padded = "\n" * (ex.lineno - 1) + ex.code
|
|
369
497
|
try:
|
|
@@ -382,11 +510,20 @@ def _execute(executables: list[_Executable], allow: bool) -> tuple[Store, list[_
|
|
|
382
510
|
f"{type(err).__name__}: {err}",
|
|
383
511
|
) from err
|
|
384
512
|
ex.end = len(store.commands)
|
|
513
|
+
ex.md = "\n\n".join(chunk for chunk in rec.md if chunk.strip())
|
|
385
514
|
if ex.kind == "inline":
|
|
386
515
|
if len(rec.groups) != groups_before:
|
|
387
516
|
raise ArticleError(
|
|
388
517
|
ex.lineno, "group() is not allowed in an inline span"
|
|
389
518
|
)
|
|
519
|
+
if ex.md:
|
|
520
|
+
# An inline span is replaced by its commands mid-
|
|
521
|
+
# sentence; there is nowhere sensible to put a block of
|
|
522
|
+
# prose, so this is a mistake rather than a layout
|
|
523
|
+
# choice. Same reasoning as group() above.
|
|
524
|
+
raise ArticleError(
|
|
525
|
+
ex.lineno, "gm.md() is not allowed in an inline span"
|
|
526
|
+
)
|
|
390
527
|
if ex.end == ex.start:
|
|
391
528
|
raise ArticleError(
|
|
392
529
|
ex.lineno,
|
|
@@ -412,6 +549,7 @@ def compile_article(markdown: str, *, allow_coercions: bool = False) -> str:
|
|
|
412
549
|
must already be loaded); use `run_article` for a sandboxed subprocess run.
|
|
413
550
|
"""
|
|
414
551
|
parts = _segment(markdown)
|
|
552
|
+
_lint_math(parts)
|
|
415
553
|
|
|
416
554
|
# Document-order events: executables (fences, inline spans) and ref spans.
|
|
417
555
|
executables: list[_Executable] = []
|
|
@@ -522,9 +660,15 @@ def compile_article(markdown: str, *, allow_coercions: bool = False) -> str:
|
|
|
522
660
|
nth_fence = 0
|
|
523
661
|
for part in parts:
|
|
524
662
|
if isinstance(part, _Fence):
|
|
525
|
-
|
|
663
|
+
ex_idx = fence_order[nth_fence]
|
|
664
|
+
lines = fence_lines[ex_idx]
|
|
526
665
|
nth_fence += 1
|
|
527
666
|
out.append("".join(f"{{}}({line})\n" for line in lines))
|
|
667
|
+
# Prose the block emitted via gm.md() follows its setup commands,
|
|
668
|
+
# blank-line separated so markdown sees it as its own block.
|
|
669
|
+
emitted = executables[ex_idx].md
|
|
670
|
+
if emitted:
|
|
671
|
+
out.append(f"\n{emitted}\n\n")
|
|
528
672
|
continue
|
|
529
673
|
text = part.text
|
|
530
674
|
for span, new in sorted(
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""gm.cond — conditions over store nodes, for gating markdown with gm.when.
|
|
2
|
+
|
|
3
|
+
A condition is pure configuration: a small JSON tree naming nodes and constants,
|
|
4
|
+
recorded into the article and evaluated in the browser whenever one of those
|
|
5
|
+
nodes changes. It records no DSL and has no engine command behind it.
|
|
6
|
+
|
|
7
|
+
show = gm.ui.checkbox(False, label="Show the proof")
|
|
8
|
+
with gm.when(show):
|
|
9
|
+
gm.md("Because $ab=ba$, the map commutes.")
|
|
10
|
+
|
|
11
|
+
with gm.when(gm.cond.ge(k, 2)):
|
|
12
|
+
gm.md("Now the second eigenvector matters.")
|
|
13
|
+
|
|
14
|
+
**Why a `gm.cond` namespace instead of `k >= 2`.** Overloading comparison
|
|
15
|
+
operators on nodes would collide with geomatic's own comparison commands
|
|
16
|
+
(`\\gt`, `\\lt`, ...), which record DSL and return Bool nodes — the opposite of
|
|
17
|
+
what a condition is. Keeping them apart means `k >= 2` stays available for its
|
|
18
|
+
real meaning. `&`, `|` and `~` ARE overloaded, but only on `Cond` itself, which
|
|
19
|
+
is our own type and has no other reading.
|
|
20
|
+
|
|
21
|
+
The JSON shape is shared with the browser's `predicate.ts`; keep the two in
|
|
22
|
+
step.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Union
|
|
28
|
+
|
|
29
|
+
from .nodes import GNode
|
|
30
|
+
|
|
31
|
+
Operand = Union[GNode, float, int, str, bool]
|
|
32
|
+
|
|
33
|
+
_ORDERED_OPS = ("ge", "gt", "le", "lt")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CondError(ValueError):
|
|
37
|
+
"""A condition could not be built (bad operand or comparison)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Cond:
|
|
41
|
+
"""A built condition. Combine with `&`, `|`, `~`; pass to `gm.when`."""
|
|
42
|
+
|
|
43
|
+
__slots__ = ("payload",)
|
|
44
|
+
|
|
45
|
+
def __init__(self, payload: dict) -> None:
|
|
46
|
+
self.payload = payload
|
|
47
|
+
|
|
48
|
+
def __and__(self, other: "Cond") -> "Cond":
|
|
49
|
+
return all_(self, other)
|
|
50
|
+
|
|
51
|
+
def __or__(self, other: "Cond") -> "Cond":
|
|
52
|
+
return any_(self, other)
|
|
53
|
+
|
|
54
|
+
def __invert__(self) -> "Cond":
|
|
55
|
+
return not_(self)
|
|
56
|
+
|
|
57
|
+
def __bool__(self):
|
|
58
|
+
raise CondError(
|
|
59
|
+
"a condition cannot be used in a python `if` — it describes something "
|
|
60
|
+
"the READER's browser decides, not something known while compiling. "
|
|
61
|
+
"Use `with gm.when(...)`. For a compile-time branch, test the plain "
|
|
62
|
+
"python value instead."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"Cond({self.payload!r})"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _operand(value: Operand, where: str) -> dict:
|
|
70
|
+
if isinstance(value, Cond):
|
|
71
|
+
raise CondError(f"{where}: expected a node or a constant, got a condition")
|
|
72
|
+
if isinstance(value, GNode):
|
|
73
|
+
if not value.id:
|
|
74
|
+
raise CondError(f"{where}: node has no id yet, so it cannot be referenced")
|
|
75
|
+
return {"node": value.id}
|
|
76
|
+
if isinstance(value, bool):
|
|
77
|
+
return {"const": value}
|
|
78
|
+
if isinstance(value, (int, float)):
|
|
79
|
+
return {"const": float(value)}
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
return {"const": value}
|
|
82
|
+
raise CondError(f"{where}: unsupported operand {value!r}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _compare(op: str, a: Operand, b: Operand) -> Cond:
|
|
86
|
+
left = _operand(a, f"gm.cond.{op}")
|
|
87
|
+
right = _operand(b, f"gm.cond.{op}")
|
|
88
|
+
if op in _ORDERED_OPS:
|
|
89
|
+
for side in (left, right):
|
|
90
|
+
if isinstance(side.get("const"), str):
|
|
91
|
+
raise CondError(
|
|
92
|
+
f"gm.cond.{op} orders numbers; compare text with "
|
|
93
|
+
"gm.cond.eq / gm.cond.ne instead"
|
|
94
|
+
)
|
|
95
|
+
return Cond({"op": op, "a": left, "b": right})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def ge(a: Operand, b: Operand) -> Cond:
|
|
99
|
+
"""a >= b"""
|
|
100
|
+
return _compare("ge", a, b)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def gt(a: Operand, b: Operand) -> Cond:
|
|
104
|
+
"""a > b"""
|
|
105
|
+
return _compare("gt", a, b)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def le(a: Operand, b: Operand) -> Cond:
|
|
109
|
+
"""a <= b"""
|
|
110
|
+
return _compare("le", a, b)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def lt(a: Operand, b: Operand) -> Cond:
|
|
114
|
+
"""a < b"""
|
|
115
|
+
return _compare("lt", a, b)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def eq(a: Operand, b: Operand) -> Cond:
|
|
119
|
+
"""a == b (numbers, text or booleans)"""
|
|
120
|
+
return _compare("eq", a, b)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def ne(a: Operand, b: Operand) -> Cond:
|
|
124
|
+
"""a != b (numbers, text or booleans)"""
|
|
125
|
+
return _compare("ne", a, b)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _fold(op: str, conds: tuple, name: str) -> Cond:
|
|
129
|
+
if not conds:
|
|
130
|
+
raise CondError(f"gm.cond.{name} needs at least one condition")
|
|
131
|
+
built = []
|
|
132
|
+
for c in conds:
|
|
133
|
+
if not isinstance(c, Cond):
|
|
134
|
+
raise CondError(f"gm.cond.{name}: expected conditions, got {c!r}")
|
|
135
|
+
built.append(c.payload)
|
|
136
|
+
folded = built[0]
|
|
137
|
+
for nxt in built[1:]:
|
|
138
|
+
folded = {"op": op, "a": folded, "b": nxt}
|
|
139
|
+
return Cond(folded)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def all_(*conds: Cond) -> Cond:
|
|
143
|
+
"""Every condition holds."""
|
|
144
|
+
return _fold("and", conds, "all_")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def any_(*conds: Cond) -> Cond:
|
|
148
|
+
"""At least one condition holds."""
|
|
149
|
+
return _fold("or", conds, "any_")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def not_(c: Cond) -> Cond:
|
|
153
|
+
"""The condition does not hold."""
|
|
154
|
+
if not isinstance(c, Cond):
|
|
155
|
+
raise CondError(f"gm.cond.not_: expected a condition, got {c!r}")
|
|
156
|
+
return Cond({"op": "not", "a": c.payload})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def to_payload(condition: Union[Cond, GNode]) -> dict:
|
|
160
|
+
"""The JSON for a condition, or for a bare node used as a truth gate."""
|
|
161
|
+
if isinstance(condition, Cond):
|
|
162
|
+
return condition.payload
|
|
163
|
+
if isinstance(condition, GNode):
|
|
164
|
+
if not condition.id:
|
|
165
|
+
raise CondError("node has no id yet, so it cannot gate anything")
|
|
166
|
+
if condition.type not in ("Bool", "Scalar"):
|
|
167
|
+
raise CondError(
|
|
168
|
+
f"a {condition.type} node cannot be a gate on its own — compare it, "
|
|
169
|
+
f"e.g. gm.cond.eq({condition.id}, ...)"
|
|
170
|
+
)
|
|
171
|
+
return {"node": condition.id}
|
|
172
|
+
raise CondError(
|
|
173
|
+
f"gm.when() takes a Bool/Scalar node or a gm.cond condition, got {condition!r}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Compile-time evaluation
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _truthy(value) -> bool:
|
|
183
|
+
if value is None:
|
|
184
|
+
return False
|
|
185
|
+
if isinstance(value, bool):
|
|
186
|
+
return value
|
|
187
|
+
if isinstance(value, (int, float)):
|
|
188
|
+
return value != 0
|
|
189
|
+
return bool(value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def evaluate(payload: dict, values: dict) -> bool:
|
|
193
|
+
"""Whether the condition holds for `values` (node id → current value).
|
|
194
|
+
|
|
195
|
+
Used only to decide whether a block starts hidden, so the reader never sees
|
|
196
|
+
it flash before the browser takes over. A node with no known value counts as
|
|
197
|
+
absent, which reads as false — the safe direction: a block that should have
|
|
198
|
+
been visible appears a frame later, rather than one that should have been
|
|
199
|
+
hidden appearing at all.
|
|
200
|
+
|
|
201
|
+
Mirrors `evalPredicate` in the browser; keep the two in step.
|
|
202
|
+
"""
|
|
203
|
+
if "node" in payload:
|
|
204
|
+
return _truthy(values.get(payload["node"]))
|
|
205
|
+
op = payload["op"]
|
|
206
|
+
if op == "not":
|
|
207
|
+
return not evaluate(payload["a"], values)
|
|
208
|
+
if op in ("and", "or"):
|
|
209
|
+
left = evaluate(payload["a"], values)
|
|
210
|
+
right = evaluate(payload["b"], values)
|
|
211
|
+
return (left and right) if op == "and" else (left or right)
|
|
212
|
+
|
|
213
|
+
def side(operand: dict):
|
|
214
|
+
return values.get(operand["node"]) if "node" in operand else operand["const"]
|
|
215
|
+
|
|
216
|
+
a, b = side(payload["a"]), side(payload["b"])
|
|
217
|
+
if op == "eq":
|
|
218
|
+
return a == b
|
|
219
|
+
if op == "ne":
|
|
220
|
+
return a != b
|
|
221
|
+
if a is None or b is None or isinstance(a, str) or isinstance(b, str):
|
|
222
|
+
return False
|
|
223
|
+
if op == "ge":
|
|
224
|
+
return a >= b
|
|
225
|
+
if op == "gt":
|
|
226
|
+
return a > b
|
|
227
|
+
if op == "le":
|
|
228
|
+
return a <= b
|
|
229
|
+
return a < b
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def node_refs(payload: dict, into: set) -> set:
|
|
233
|
+
"""Every node id the condition reads."""
|
|
234
|
+
if "node" in payload:
|
|
235
|
+
into.add(payload["node"])
|
|
236
|
+
return into
|
|
237
|
+
op = payload["op"]
|
|
238
|
+
if op == "not":
|
|
239
|
+
return node_refs(payload["a"], into)
|
|
240
|
+
if op in ("and", "or"):
|
|
241
|
+
node_refs(payload["a"], into)
|
|
242
|
+
return node_refs(payload["b"], into)
|
|
243
|
+
for operand in (payload["a"], payload["b"]):
|
|
244
|
+
if "node" in operand:
|
|
245
|
+
into.add(operand["node"])
|
|
246
|
+
return into
|
{pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/autograd_functions.py
RENAMED
|
@@ -44,7 +44,9 @@ def backprop(node):
|
|
|
44
44
|
params=[P("target", "Scalar"), P("param", "Any")],
|
|
45
45
|
category=CATEGORY,
|
|
46
46
|
)
|
|
47
|
-
def
|
|
47
|
+
def partial_derivative(target, param_node):
|
|
48
|
+
# Computes the partial derivative of `target` w.r.t. `param` (NOT
|
|
49
|
+
# functools.partial). Emits `\partial`.
|
|
48
50
|
# Mirrors the engine: an Array param broadcasts element-wise into an Array
|
|
49
51
|
# of gradient nodes (PointGradient for Point-like elements, ScalarGradient
|
|
50
52
|
# otherwise). Target must be a single Scalar (engine-enforced).
|
{pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/curve_functions.py
RENAMED
|
@@ -75,8 +75,11 @@ def clear_trail(trail_id):
|
|
|
75
75
|
category=CATEGORY,
|
|
76
76
|
is_async=True,
|
|
77
77
|
)
|
|
78
|
-
def
|
|
79
|
-
"""Record-only:
|
|
78
|
+
def plot_reactive(x, y):
|
|
79
|
+
"""Record-only: traces the reactive graph in the engine. `x` and `y` are
|
|
80
|
+
single Scalar nodes in a reactive relationship (`y = f(x)`); the engine
|
|
81
|
+
sweeps `x` and traces the curve. NOT matplotlib: do not pass arrays of
|
|
82
|
+
precomputed samples. Emits `\\plot`."""
|
|
80
83
|
return Plot._new()
|
|
81
84
|
|
|
82
85
|
|
|
@@ -20,10 +20,13 @@ at jump targets, jumps, and any unmodeled opcode, so a name is only inferred
|
|
|
20
20
|
when the full data flow from the producing instruction to its STORE was
|
|
21
21
|
understood. Anything unclear — attribute/subscript
|
|
22
22
|
targets, expression statements — falls back to the auto-generated id, as does
|
|
23
|
-
an id that is invalid
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
an id that is invalid or engine-auto-shaped (`p1`, `num3`, ...). A taken
|
|
24
|
+
inferred name is no longer filtered out: it flows into `Store.allocate_id`,
|
|
25
|
+
which raises in plain-authoring mode (the duplicate-id guard that catches
|
|
26
|
+
loop reuse) and reassigns last-write-wins in article/macro replay — so the
|
|
27
|
+
inferred-name path and the explicit-`out=` path share one guard. Python
|
|
28
|
+
underscores translate to DSL dashes (`fwd_traj` → `fwd-traj`). An explicit
|
|
29
|
+
`out=` always wins; calls
|
|
27
30
|
originating inside pygeomatic itself (parse replay, macro bodies) are never
|
|
28
31
|
inferred.
|
|
29
32
|
"""
|
|
@@ -36,7 +39,7 @@ from types import CodeType, FrameType
|
|
|
36
39
|
from typing import Optional, Union
|
|
37
40
|
from weakref import WeakKeyDictionary
|
|
38
41
|
|
|
39
|
-
from .store import ENGINE_AUTO_ID_RE, IDENTIFIER_RE, Store
|
|
42
|
+
from .store import ENGINE_AUTO_ID_RE, IDENTIFIER_RE, Store
|
|
40
43
|
|
|
41
44
|
_STORE_OPS = frozenset({"STORE_NAME", "STORE_FAST", "STORE_GLOBAL", "STORE_DEREF"})
|
|
42
45
|
|
|
@@ -86,6 +89,26 @@ _DUNDER_FRAME_NAMES = frozenset(
|
|
|
86
89
|
}
|
|
87
90
|
)
|
|
88
91
|
|
|
92
|
+
# gm.ui widget constructors: each records its node by calling an ordinary
|
|
93
|
+
# geomatic function, so inference must hop their frame to reach the author's
|
|
94
|
+
# assignment (`r = gm.ui.slider(1, 5)` → node id `r`). Kept as an explicit set
|
|
95
|
+
# rather than "any pygeomatic.ui frame" so an unrelated helper in that module
|
|
96
|
+
# can never silently steal a name.
|
|
97
|
+
_UI_FRAME_NAMES = frozenset(
|
|
98
|
+
{
|
|
99
|
+
"slider",
|
|
100
|
+
"checkbox",
|
|
101
|
+
"dropdown",
|
|
102
|
+
"radio",
|
|
103
|
+
"number",
|
|
104
|
+
"text",
|
|
105
|
+
# Shared helper behind dropdown/radio: one MORE frame between the
|
|
106
|
+
# author and the recording call, so it has to be hopped too or those
|
|
107
|
+
# two would fall back to an auto id while the others named correctly.
|
|
108
|
+
"_choice",
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
89
112
|
# A marker is the offset of the instruction that produced the value; opaque
|
|
90
113
|
# stack entries are None; BUILD_TUPLE groups become lists of entries.
|
|
91
114
|
_Entry = Union[int, None, list]
|
|
@@ -202,15 +225,26 @@ def _assignment_targets(code: CodeType) -> tuple[list[int], dict[int, list[str]]
|
|
|
202
225
|
return [ins.offset for ins in instructions], targets
|
|
203
226
|
|
|
204
227
|
|
|
228
|
+
def _is_hoppable(frame: FrameType) -> bool:
|
|
229
|
+
"""True for a pygeomatic-internal frame that stands between the user's
|
|
230
|
+
expression and the recording wrapper, so inference should look past it."""
|
|
231
|
+
module = frame.f_globals.get("__name__") or ""
|
|
232
|
+
name = frame.f_code.co_name
|
|
233
|
+
if module == "pygeomatic.nodes" and name in _DUNDER_FRAME_NAMES:
|
|
234
|
+
return True
|
|
235
|
+
# gm.ui widget constructors call gm.scalar()/gm.text()/... on the author's
|
|
236
|
+
# behalf, so the recording call happens one frame down inside pygeomatic.ui
|
|
237
|
+
# — the same situation as an operator dunder. Without this hop
|
|
238
|
+
# `r = gm.ui.slider(1, 5)` would fall back to an auto id (`num0`) and the
|
|
239
|
+
# widget could not address its own node.
|
|
240
|
+
return module == "pygeomatic.ui" and name in _UI_FRAME_NAMES
|
|
241
|
+
|
|
242
|
+
|
|
205
243
|
def _hop_dunder_frames(frame: FrameType) -> Optional[FrameType]:
|
|
206
|
-
"""Skip
|
|
244
|
+
"""Skip pygeomatic-internal frames so `c = a + b` and `r = gm.ui.slider(...)`
|
|
245
|
+
both read the user frame."""
|
|
207
246
|
hops = 0
|
|
208
|
-
while (
|
|
209
|
-
frame is not None
|
|
210
|
-
and hops < 4
|
|
211
|
-
and (frame.f_globals.get("__name__") or "") == "pygeomatic.nodes"
|
|
212
|
-
and frame.f_code.co_name in _DUNDER_FRAME_NAMES
|
|
213
|
-
):
|
|
247
|
+
while frame is not None and hops < 4 and _is_hoppable(frame):
|
|
214
248
|
frame = frame.f_back
|
|
215
249
|
hops += 1
|
|
216
250
|
return frame
|
|
@@ -254,12 +288,6 @@ def infer_out_names(frame, store: Store) -> list[str]:
|
|
|
254
288
|
# p1/num3/... are the engine's internal auto-name space;
|
|
255
289
|
# claiming one silently clobbers auxiliary nodes on replay.
|
|
256
290
|
continue
|
|
257
|
-
if candidate in store.nodes and not _article_replay.get():
|
|
258
|
-
# Taken (loop reuse, or a system default like T/F) — an
|
|
259
|
-
# inferred name never reassigns; only an explicit out= may.
|
|
260
|
-
# Articles are the exception: `s1 = gm.scalar(1)` there means
|
|
261
|
-
# what the DSL line `s1 = \scalar 1` means — last-write-wins.
|
|
262
|
-
continue
|
|
263
291
|
candidates.append(candidate)
|
|
264
292
|
return candidates
|
|
265
293
|
except Exception:
|