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.
Files changed (61) hide show
  1. {pygeomatic-0.2.2 → pygeomatic-0.4}/.gitignore +2 -0
  2. {pygeomatic-0.2.2 → pygeomatic-0.4}/PKG-INFO +1 -1
  3. {pygeomatic-0.2.2 → pygeomatic-0.4}/pyproject.toml +1 -1
  4. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/__init__.py +8 -2
  5. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/article.py +146 -2
  6. pygeomatic-0.4/src/pygeomatic/cond.py +246 -0
  7. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/autograd_functions.py +3 -1
  8. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/curve_functions.py +5 -2
  9. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/inference.py +46 -18
  10. pygeomatic-0.4/src/pygeomatic/katex_commands.json +1045 -0
  11. pygeomatic-0.4/src/pygeomatic/latex_lint.py +111 -0
  12. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/nodes.py +31 -0
  13. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/palette.py +10 -8
  14. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/prompting.py +11 -1
  15. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/store.py +6 -0
  16. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/tex.py +56 -6
  17. pygeomatic-0.4/src/pygeomatic/ui.py +339 -0
  18. {pygeomatic-0.2.2 → pygeomatic-0.4}/LICENSE +0 -0
  19. {pygeomatic-0.2.2 → pygeomatic-0.4}/README.md +0 -0
  20. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/coercions.json +0 -0
  21. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/coercions.py +0 -0
  22. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/emit.py +0 -0
  23. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/extensions.py +0 -0
  24. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/__init__.py +0 -0
  25. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/helpers.py +0 -0
  26. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/__init__.py +0 -0
  27. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/annotation_functions.py +0 -0
  28. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/array.py +0 -0
  29. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/basic_figures.py +0 -0
  30. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/boolean_functions.py +0 -0
  31. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/complex_functions.py +0 -0
  32. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/intersections.py +0 -0
  33. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/ode_functions.py +0 -0
  34. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/planar_geometry.py +0 -0
  35. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/polygons.py +0 -0
  36. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/property_functions.py +0 -0
  37. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/rotation_functions.py +0 -0
  38. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/scalar_functions.py +0 -0
  39. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/special_functions.py +0 -0
  40. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/tangent_functions.py +0 -0
  41. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/tensor_functions.py +0 -0
  42. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/implementations/translation_functions.py +0 -0
  43. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/__init__.py +0 -0
  44. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/abs.py +0 -0
  45. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/add.py +0 -0
  46. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/create_overload.py +0 -0
  47. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/div.py +0 -0
  48. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/exp.py +0 -0
  49. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/log.py +0 -0
  50. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/mul.py +0 -0
  51. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/neg.py +0 -0
  52. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/pow.py +0 -0
  53. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/sqrt.py +0 -0
  54. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/functions/overloads/sub.py +0 -0
  55. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/generate.py +0 -0
  56. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/macros.json +0 -0
  57. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/macros.py +0 -0
  58. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/parse.py +0 -0
  59. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/registry.py +0 -0
  60. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/runner.py +0 -0
  61. {pygeomatic-0.2.2 → pygeomatic-0.4}/src/pygeomatic/system_nodes.py +0 -0
@@ -216,3 +216,5 @@ __marimo__/
216
216
 
217
217
  # Streamlit
218
218
  .streamlit/secrets.toml
219
+ examples/agent-output.md
220
+ .claude/settings.local.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeomatic
3
- Version: 0.2.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pygeomatic"
3
- version = "0.2.2"
3
+ version = "0.4"
4
4
  description = "Python mirror of the geomatic DSL function library; records calls for deterministic DSL emission"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- partial,
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
- lines = fence_lines[fence_order[nth_fence]]
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
@@ -44,7 +44,9 @@ def backprop(node):
44
44
  params=[P("target", "Scalar"), P("param", "Any")],
45
45
  category=CATEGORY,
46
46
  )
47
- def partial(target, param_node):
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).
@@ -75,8 +75,11 @@ def clear_trail(trail_id):
75
75
  category=CATEGORY,
76
76
  is_async=True,
77
77
  )
78
- def plot(x, y):
79
- """Record-only: plotting traces the reactive graph in the engine."""
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, engine-auto-shaped (`p1`, `num3`, ...), or already
24
- taken in the store (loops, system defaults; article mode instead reassigns —
25
- last-write-wins, see article.py). Python underscores translate to
26
- DSL dashes (`fwd_traj` → `fwd-traj`). An explicit `out=` always wins; calls
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, _article_replay
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 GNode operator-dunder frames so `c = a + b` reads the user frame."""
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: