Simon Clematide commited on
Commit
397fac4
·
1 Parent(s): 63abd61

Fix review findings and bump version to 0.11.0

Browse files

Security
- Escape error messages in the static diagram view (reflected XSS via
?vals=... with autorun on page load).
- KaTeX trust disabled; escape ids in HTML attributes; textContent for
mount errors; guard against "</script" when inlining the bundle.

Correctness
- abs() differentiable in the scalar engine (real-valued slot symbols).
- x**c at x=0 with 0<c<1 no longer reports derivative 0 (now +inf).
- Shared IEEE/PyTorch numeric policy for both engines: infinite limits
are shown, undefined values (NaN, non-real) raise readable errors
naming the quantity.
- Interactive payload is strict JSON (inf/nan encoded as strings).

Features
- Tensor engine supports sin, cos, tan, sqrt, sinh, cosh, asin, acos,
atan, abs, ln (element-wise); engine dispatch derived from the
tensor-only function set; booleans rejected in both engines.
- URL loading defaults to and autoruns the Interactive tab.
- Keyboard/screen-reader support in the interactive graph; remount when
the payload changes; KaTeX CSS version follows the bundled KaTeX.
- Queue concurrency limit (AUTODIFF_CONCURRENCY_LIMIT, default 16) so
slow mermaid.ink calls do not serialize all users.

Maintenance
- limits.py centralizes limits and the bounded AST validator; drop
deprecated ast.Num handling; app.py cleanup (HfFolder shim, dead code,
shared mermaid.ink fetch helper, API endpoints in a loop).
- renderGraph split into phase methods; unused payload fields removed.
- New tests/test_numeric_policy.py, URL tests, requirements-dev.txt;
docs updated.

README.md CHANGED
@@ -60,6 +60,15 @@ python -m pip install -r requirements.txt
60
  python app.py
61
  ```
62
 
 
 
 
 
 
 
 
 
 
63
  The example tables include toy calculus, regression, classification, hidden layers, attention, pooling, and regularization. Each example loads an explanation and defaults to Forward & Backward. Editing the expression replaces its explanation with an AST-based description.
64
 
65
  ## Vector and matrix examples
@@ -77,7 +86,7 @@ Use scalar, vector, or rectangular matrix input assignments, for example `W=[[1,
77
 
78
  ## Rendering and exports
79
 
80
- Preview, SVG, PNG, and PDF use Mermaid Ink with compressed payloads. Diagram content is sent to that service. Section-title positions use a preliminary SVG measurement and are shared across preview and export; if that request fails, title spacing is retained. Other Mermaid editors may not apply custom title placement.
81
 
82
  ## Python and Notebook API
83
 
 
60
  python app.py
61
  ```
62
 
63
+ Run the tests with:
64
+
65
+ ```sh
66
+ python -m pip install -r requirements-dev.txt
67
+ python -m pytest
68
+ ```
69
+
70
+ The interactive graph is a TypeScript bundle committed as `assets/interactive_graph.bundle.js`. After changing `frontend/src`, rebuild it with `cd frontend && npm ci && npm run build`.
71
+
72
  The example tables include toy calculus, regression, classification, hidden layers, attention, pooling, and regularization. Each example loads an explanation and defaults to Forward & Backward. Editing the expression replaces its explanation with an AST-based description.
73
 
74
  ## Vector and matrix examples
 
86
 
87
  ## Rendering and exports
88
 
89
+ The static diagram preview and its SVG, PNG, and PDF exports use the external [Mermaid Ink](https://mermaid.ink) service with compressed payloads, so diagram content (including your expression and values) is sent to that service and these features need network access. The interactive graph, the walkthrough, and the Mermaid/TikZ source exports work offline. Set `AUTODIFF_MERMAID_TIMEOUT` (seconds, default 15) to bound each request. Up to `AUTODIFF_CONCURRENCY_LIMIT` requests (default 16) are processed in parallel, so slow Mermaid Ink responses do not block other users. Section-title positions use a preliminary SVG measurement and are shared across preview and export; if that request fails, title spacing is retained. Other Mermaid editors may not apply custom title placement.
90
 
91
  ## Python and Notebook API
92
 
app.py CHANGED
@@ -1,12 +1,3 @@
1
- import huggingface_hub
2
- if not hasattr(huggingface_hub, 'HfFolder'):
3
- class HfFolder:
4
- @classmethod
5
- def get_token(cls): return None
6
- @classmethod
7
- def save_token(cls, token): pass
8
- huggingface_hub.HfFolder = HfFolder
9
-
10
  import os
11
  import re
12
  import time
@@ -20,9 +11,9 @@ import html as html_lib
20
  from pathlib import Path
21
  import gradio as gr
22
  import sympy as sp
23
- from autodiff_visualizer import ComputationGraph, safe_parse_expr
24
 
25
- __version__ = "0.10.0"
26
  __version_date__ = "2026-11-04"
27
  __status__ = "Beta"
28
 
@@ -38,7 +29,6 @@ if _ex_start != -1 and _ex_end != -1:
38
  else:
39
  VISUAL_LANGUAGE_HELP = VISUAL_LANGUAGE_RAW
40
  EXAMPLES_MD = ""
41
- VISUAL_LANGUAGE = VISUAL_LANGUAGE_HELP
42
 
43
  TUTORIAL_EXERCISES = [
44
  ['(x * x) * y + (y + 2)', 'x=3, y=4', 'Exercise 1: Multi-Path Gradient Accumulation'],
@@ -118,7 +108,7 @@ def prune_cache(max_files=60, max_age_seconds=3600):
118
 
119
 
120
  from input_parser import parse_variable_assignments
121
- from tensor_engine import TensorComputationGraph
122
  from diagram_layout import with_title_layout, encode_mermaid
123
  from formula_explanation import explain_formula
124
  import numpy as np
@@ -129,13 +119,31 @@ from interactive_renderer import render_interactive_html
129
  from number_format import NumberFormatConfig
130
 
131
  _INTERACTIVE_BUNDLE_PATH = Path(__file__).with_name("assets") / "interactive_graph.bundle.js"
 
 
 
132
  INTERACTIVE_GRAPH_SCRIPT = (
133
- "<script>\n" + _INTERACTIVE_BUNDLE_PATH.read_text(encoding="utf8") + "\n</script>"
 
 
 
134
  if _INTERACTIVE_BUNDLE_PATH.exists()
135
  else ""
136
  )
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  def build_model_from_inputs(expr_str, values_str, mode):
140
  """Builds ComputationModel using identical logic for diagram and trace renderers."""
141
  if not expr_str or not expr_str.strip():
@@ -145,10 +153,7 @@ def build_model_from_inputs(expr_str, values_str, mode):
145
  forward_only = (mode == "Forward Only")
146
  parsed_feed = parse_variable_assignments(values_str)
147
 
148
- has_tensor_input = any(isinstance(v, np.ndarray) and v.ndim >= 1 for v in parsed_feed.values())
149
- has_tensor_syntax = bool(re.search(r'(@|\bdot\b|\bsum\b|\bmean\b|\bsigmoid\b|\brelu\b|\bmax\b|\bmin\b|\bsoftmax\b|\bpick\b|\[)', cleaned_expr))
150
-
151
- if has_tensor_input or has_tensor_syntax:
152
  cg = TensorComputationGraph()
153
  cg.build_from_ast(cleaned_expr)
154
  cg.evaluate(parsed_feed, forward_only=forward_only)
@@ -164,7 +169,6 @@ def build_model_from_inputs(expr_str, values_str, mode):
164
  def export_tikz_from_inputs(expr_str, values_str, mode, precision=2):
165
  """Export a compact, editable slide diagram from the current inputs."""
166
  from tikz_renderer import render_tikz_document
167
- import tempfile
168
  try:
169
  model = build_model_from_inputs(expr_str, values_str, mode)
170
  source = render_tikz_document(model, precision=precision)
@@ -217,32 +221,22 @@ def generate_diagram(expr_str, values_str, mode, notation="∂(...) / ∂x [Math
217
  raw_mermaid_code = mermaid_code
218
  mermaid_code = with_title_layout(raw_mermaid_code)
219
  try:
220
- layout_payload = encode_mermaid(mermaid_code)
221
- request = urllib.request.Request(
222
- f"https://mermaid.ink/svg/{layout_payload}",
223
- headers={"User-Agent": "Mozilla/5.0"},
224
- )
225
- with urllib.request.urlopen(request, timeout=15) as response:
226
- mermaid_code = with_title_layout(raw_mermaid_code, response.read())
227
  except Exception as error:
228
  print("Warning: Could not position section titles; retaining title spacing:", error)
229
 
230
  # Encode for mermaid.ink
231
  base64_string = encode_mermaid(mermaid_code)
232
-
233
- png_url = f"https://mermaid.ink/img/{base64_string}?type=png"
234
  b64_png = None
235
 
236
- headers = {"User-Agent": "Mozilla/5.0"}
237
-
238
  # Only fetch PNG needed for responsive HTML display
239
  try:
240
- req_png = urllib.request.Request(png_url, headers=headers)
241
- with urllib.request.urlopen(req_png, timeout=15) as resp:
242
- png_data = resp.read()
243
- with open(png_path, "wb") as f:
244
- f.write(png_data)
245
- b64_png = base64.b64encode(png_data).decode("ascii")
246
  except Exception as e:
247
  print("Warning: Could not fetch PNG preview:", e)
248
 
@@ -263,95 +257,84 @@ def generate_diagram(expr_str, values_str, mode, notation="∂(...) / ∂x [Math
263
  return html, mermaid_code
264
 
265
  except Exception as e:
266
- error_html = f"<div style='color: #e53e3e; background: #fff5f5; padding: 15px; border-radius: 8px; border: 1px solid #feb2b2;'><b>Error:</b> {str(e)}</div>"
267
  return error_html, ""
268
 
269
 
270
 
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  def export_file(mermaid_code, fmt):
273
  """Generates the requested export format on demand with content hashing and cache."""
274
  if not mermaid_code or not mermaid_code.strip():
275
  return None, gr.update(visible=False)
276
-
277
  prune_cache()
278
- graphbytes = mermaid_code.encode("utf8")
279
- b64 = encode_mermaid(mermaid_code)
280
- code_hash = hashlib.md5(graphbytes).hexdigest()[:12]
281
  out_path = os.path.join(CACHE_DIR, f"autodiff_{code_hash}.{fmt}")
282
-
283
  if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
284
  return out_path, gr.update(value=out_path, visible=True)
285
-
286
- headers = {"User-Agent": "Mozilla/5.0"}
287
-
288
- def safe_read(resp):
289
- chunks = []
290
- while True:
291
- try:
292
- c = resp.read(65536)
293
- if not c:
294
- break
295
- chunks.append(c)
296
- except Exception as re_err:
297
- if hasattr(re_err, 'partial') and re_err.partial:
298
- chunks.append(re_err.partial)
299
- break
300
- return b"".join(chunks)
301
 
302
- if fmt == "pdf":
303
- url = f"https://mermaid.ink/pdf/{b64}"
304
- try:
305
- req = urllib.request.Request(url, headers=headers)
306
- with urllib.request.urlopen(req, timeout=15) as resp:
307
- data = safe_read(resp)
308
- if data:
309
- with open(out_path, "wb") as f:
310
- f.write(data)
311
- return out_path, gr.update(value=out_path, visible=True)
312
- except Exception as e:
313
- print("Warning: Native PDF export failed, trying Pillow fallback:", e)
314
- png_path, _ = export_file(mermaid_code, "png")
315
- if png_path and os.path.exists(png_path):
316
- try:
317
- from PIL import Image
318
- img = Image.open(png_path).convert("RGB")
319
- img.save(out_path, "PDF", resolution=300)
320
- return out_path, gr.update(value=out_path, visible=True)
321
- except Exception as pe:
322
- print("Pillow PDF conversion failed:", pe)
323
- elif fmt == "png":
324
- url = f"https://mermaid.ink/img/{b64}?type=png"
325
- try:
326
- req = urllib.request.Request(url, headers=headers)
327
- with urllib.request.urlopen(req, timeout=15) as resp:
328
- data = safe_read(resp)
329
- if data:
330
- with open(out_path, "wb") as f:
331
- f.write(data)
332
- return out_path, gr.update(value=out_path, visible=True)
333
- except Exception as e:
334
- print("Warning: PNG export failed:", e)
335
- elif fmt == "svg":
336
- url = f"https://mermaid.ink/svg/{b64}"
337
- try:
338
- req = urllib.request.Request(url, headers=headers)
339
- with urllib.request.urlopen(req, timeout=15) as resp:
340
- data = safe_read(resp)
341
- if data:
342
- with open(out_path, "wb") as f:
343
- f.write(data)
344
- return out_path, gr.update(value=out_path, visible=True)
345
- except Exception as e:
346
- print("Warning: SVG export failed:", e)
347
- elif fmt == "mmd":
348
- try:
349
  with open(out_path, "w", encoding="utf-8") as f:
350
  f.write(mermaid_code)
351
- return out_path, gr.update(value=out_path, visible=True)
352
- except Exception as e:
353
- print("Warning: Mermaid export failed:", e)
354
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  return None, gr.update(visible=False)
356
 
357
 
@@ -375,29 +358,6 @@ def api_generate_mermaid(expr_str: str, values_str: str, mode: str = "Forward &
375
 
376
 
377
 
378
- def build_visual_grammar_html():
379
- """Use the actual renderer for help samples, keeping labels in sync."""
380
- from diagram_renderer import quantity_label, step_label, backward_edge_label
381
- from teaching_examples import multiplication_example
382
- model = multiplication_example()
383
- q = next(q for q in model.quantities if q.name == "n1")
384
- op = next(op for op in model.operations if op.quantity is q)
385
- step = next(step for step in model.steps if step.source_quantity is q)
386
- intro = next(e for e in model.backward_edges if e.quantity and e.quantity.name == "x" and e.kind.name == "ADJOINT_INTRODUCTION")
387
- samples = [
388
- ("Quantity stores two results", quantity_label(q, op, "mathbook"),
389
- "Above the divider: primal value. Below: accumulated adjoint."),
390
- ("Backward step applies a rule", step_label(step, "mathbook"),
391
- "Above: local rule for this operand occurrence. Below: its numerical chain-rule application."),
392
- ("Arrows carry information", "12 → Σ ← 12<br>Σ → " + backward_edge_label(intro, model, "mathbook"),
393
- "Green arrows carry primal numbers. Purple arrows carry named adjoints or numerical contributions. Σ adds contributions before introducing the adjoint."),
394
- ]
395
- return '<div style="display:flex;flex-wrap:wrap;gap:16px">' + "".join(
396
- f'<div style="flex:1;min-width:250px;padding:16px;border:1px solid #aaa;border-radius:12px">'
397
- f'<strong>{title}</strong><div style="text-align:center;padding:16px;background:#f4eef6;color:#42254d">{label}</div><p>{description}</p></div>'
398
- for title, label, description in samples) + '</div>'
399
-
400
-
401
  APP_STYLE = """
402
  /* ==========================================================================
403
  Problem editor & Formula explanation
@@ -896,60 +856,30 @@ with gr.Blocks(title="Autodiff Visualizer") as demo:
896
  # -------------------------------------------------------------------------
897
  # Public API Endpoints for Jupyter Notebooks, external scripts, & clients
898
  # -------------------------------------------------------------------------
899
- api_btn_png = gr.Button(visible=False)
900
- api_file_png = gr.File(visible=False)
901
- api_btn_png.click(
902
- fn=lambda expr, vals, mode="Forward & Backward", notation="∂(...) / ∂x [Mathbook]", precision=2, allow_scientific=True: (
903
- api_generate_file(expr, vals, mode, notation, "png", precision=precision, allow_scientific=allow_scientific)
904
- ),
905
- inputs=[expr_input, vals_input, mode_input, notation_input, precision_input, scientific_input],
906
- outputs=api_file_png,
907
- api_name="generate_png",
908
- )
909
 
910
- api_btn_svg = gr.Button(visible=False)
911
- api_file_svg = gr.File(visible=False)
912
- api_btn_svg.click(
913
- fn=lambda expr, vals, mode="Forward & Backward", notation="∂(...) / ∂x [Mathbook]", precision=2, allow_scientific=True: (
914
- api_generate_file(expr, vals, mode, notation, "svg", precision=precision, allow_scientific=allow_scientific)
915
- ),
916
- inputs=[expr_input, vals_input, mode_input, notation_input, precision_input, scientific_input],
917
- outputs=api_file_svg,
918
- api_name="generate_svg",
919
- )
920
 
921
- api_btn_pdf = gr.Button(visible=False)
922
- api_file_pdf = gr.File(visible=False)
923
- api_btn_pdf.click(
924
- fn=lambda expr, vals, mode="Forward & Backward", notation="∂(...) / ∂x [Mathbook]", precision=2, allow_scientific=True: (
925
- api_generate_file(expr, vals, mode, notation, "pdf", precision=precision, allow_scientific=allow_scientific)
926
- ),
927
- inputs=[expr_input, vals_input, mode_input, notation_input, precision_input, scientific_input],
928
- outputs=api_file_pdf,
929
- api_name="generate_pdf",
930
- )
931
 
932
- api_btn_mermaid = gr.Button(visible=False)
933
- api_code_mermaid = gr.Code(visible=False)
934
- api_btn_mermaid.click(
935
- fn=lambda expr, vals, mode="Forward & Backward", notation="∂(...) / ∂x [Mathbook]", precision=2, allow_scientific=True: (
936
- api_generate_mermaid(expr, vals, mode, notation, precision=precision, allow_scientific=allow_scientific)
937
- ),
938
- inputs=[expr_input, vals_input, mode_input, notation_input, precision_input, scientific_input],
939
- outputs=api_code_mermaid,
940
- api_name="generate_mermaid",
941
- )
942
 
943
- api_btn_walkthrough = gr.Button(visible=False)
944
- api_html_walkthrough = gr.HTML(visible=False)
945
- api_btn_walkthrough.click(
946
- fn=lambda expr, vals, mode="Forward & Backward", notation="∂(...) / ∂x [Mathbook]", precision=2, allow_scientific=True: (
947
- generate_trace(expr, vals, mode, notation, precision=precision, allow_scientific=allow_scientific)
948
- ),
949
- inputs=[expr_input, vals_input, mode_input, notation_input, precision_input, scientific_input],
950
- outputs=api_html_walkthrough,
951
- api_name="generate_walkthrough",
952
- )
 
953
 
954
  # -------------------------------------------------------------------------
955
  # URL Query Parameter Loading (e.g. ?expr=...&vals=...&tab=...)
@@ -957,7 +887,7 @@ with gr.Blocks(title="Autodiff Visualizer") as demo:
957
  def load_from_request(request: gr.Request):
958
  params = request.query_params if request else {}
959
  if not params:
960
- return (gr.skip(),) * 11
961
 
962
  expr_param = (params.get("expr") or params.get("formula") or "").strip()
963
  vals_param = (params.get("vals") or params.get("values") or "").strip()
@@ -977,10 +907,11 @@ with gr.Blocks(title="Autodiff Visualizer") as demo:
977
  target_tab = "tab_tutorial"
978
  elif tab_param in {"help", "guide", "user_guide", "tab_help"}:
979
  target_tab = "tab_help"
980
- elif tab_param in {"diagram", "visual", "tab_diagram"} or expr_param:
981
  target_tab = "tab_diagram"
982
  else:
983
- target_tab = "tab_diagram"
 
984
 
985
  # Expressions and values
986
  expr_up = expr_param if expr_param else gr.skip()
@@ -1029,6 +960,7 @@ with gr.Blocks(title="Autodiff Visualizer") as demo:
1029
  diagram_html_up = gr.skip()
1030
  mermaid_code_up = gr.skip()
1031
  trace_html_up = gr.skip()
 
1032
 
1033
  if do_autorun and expr_param:
1034
  eff_vals = vals_param if vals_param else ""
@@ -1037,24 +969,13 @@ with gr.Blocks(title="Autodiff Visualizer") as demo:
1037
  eff_precision = precision_val
1038
  eff_sci = sci_up if sci_up is not gr.skip() else True
1039
 
 
1040
  if target_tab == "tab_trace":
1041
- trace_html_up = on_generate_trace(
1042
- eff_expr := expr_param,
1043
- eff_vals,
1044
- eff_mode,
1045
- eff_notation,
1046
- eff_precision,
1047
- eff_sci,
1048
- )
1049
  else:
1050
- diagram_html_up, mermaid_code_up = on_generate_diagram(
1051
- eff_expr := expr_param,
1052
- eff_vals,
1053
- eff_mode,
1054
- eff_notation,
1055
- eff_precision,
1056
- eff_sci,
1057
- )
1058
 
1059
  return (
1060
  gr.Tabs(selected=target_tab),
@@ -1068,6 +989,7 @@ with gr.Blocks(title="Autodiff Visualizer") as demo:
1068
  diagram_html_up,
1069
  mermaid_code_up,
1070
  trace_html_up,
 
1071
  )
1072
 
1073
  load_outputs = [
@@ -1082,9 +1004,18 @@ with gr.Blocks(title="Autodiff Visualizer") as demo:
1082
  diagram_output,
1083
  code_output,
1084
  trace_output,
 
1085
  ]
1086
  demo.load(load_from_request, inputs=None, outputs=load_outputs, api_name=False)
1087
 
1088
 
 
 
 
 
 
 
 
 
1089
  if __name__ == "__main__":
1090
  demo.launch(head=APP_HEAD, css=APP_STYLE)
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import re
3
  import time
 
11
  from pathlib import Path
12
  import gradio as gr
13
  import sympy as sp
14
+ from autodiff_visualizer import ComputationGraph, safe_parse_expr, ALLOWED_FUNCS as SCALAR_ALLOWED_FUNCS
15
 
16
+ __version__ = "0.11.0"
17
  __version_date__ = "2026-11-04"
18
  __status__ = "Beta"
19
 
 
29
  else:
30
  VISUAL_LANGUAGE_HELP = VISUAL_LANGUAGE_RAW
31
  EXAMPLES_MD = ""
 
32
 
33
  TUTORIAL_EXERCISES = [
34
  ['(x * x) * y + (y + 2)', 'x=3, y=4', 'Exercise 1: Multi-Path Gradient Accumulation'],
 
108
 
109
 
110
  from input_parser import parse_variable_assignments
111
+ from tensor_engine import TensorComputationGraph, TENSOR_ALLOWED_FUNCS
112
  from diagram_layout import with_title_layout, encode_mermaid
113
  from formula_explanation import explain_formula
114
  import numpy as np
 
119
  from number_format import NumberFormatConfig
120
 
121
  _INTERACTIVE_BUNDLE_PATH = Path(__file__).with_name("assets") / "interactive_graph.bundle.js"
122
+ if not _INTERACTIVE_BUNDLE_PATH.exists():
123
+ print(f"Warning: {_INTERACTIVE_BUNDLE_PATH} is missing; run `npm run build` in frontend/. "
124
+ "The interactive graph tab will stay empty.")
125
  INTERACTIVE_GRAPH_SCRIPT = (
126
+ # A literal "</script" would close the inline tag early; "<\/script" is
127
+ # equivalent inside JS strings, template literals and regexes.
128
+ "<script>\n" + re.sub(r"</(script)", r"<\\/\1", _INTERACTIVE_BUNDLE_PATH.read_text(encoding="utf8"),
129
+ flags=re.IGNORECASE) + "\n</script>"
130
  if _INTERACTIVE_BUNDLE_PATH.exists()
131
  else ""
132
  )
133
 
134
 
135
+ # Functions only the tensor engine provides. Everything the scalar engine
136
+ # supports (except variable exponents such as x**y) works in both engines.
137
+ _TENSOR_ONLY_FUNCS = sorted(TENSOR_ALLOWED_FUNCS - set(SCALAR_ALLOWED_FUNCS))
138
+ _TENSOR_SYNTAX_RE = re.compile(r"@|\[|\b(?:" + "|".join(_TENSOR_ONLY_FUNCS) + r")\s*\(")
139
+
140
+
141
+ def uses_tensor_engine(expr_str, feed):
142
+ """Scalar SymPy engine unless the inputs or the syntax require tensors."""
143
+ has_tensor_input = any(isinstance(v, np.ndarray) and v.ndim >= 1 for v in feed.values())
144
+ return has_tensor_input or bool(_TENSOR_SYNTAX_RE.search(expr_str))
145
+
146
+
147
  def build_model_from_inputs(expr_str, values_str, mode):
148
  """Builds ComputationModel using identical logic for diagram and trace renderers."""
149
  if not expr_str or not expr_str.strip():
 
153
  forward_only = (mode == "Forward Only")
154
  parsed_feed = parse_variable_assignments(values_str)
155
 
156
+ if uses_tensor_engine(cleaned_expr, parsed_feed):
 
 
 
157
  cg = TensorComputationGraph()
158
  cg.build_from_ast(cleaned_expr)
159
  cg.evaluate(parsed_feed, forward_only=forward_only)
 
169
  def export_tikz_from_inputs(expr_str, values_str, mode, precision=2):
170
  """Export a compact, editable slide diagram from the current inputs."""
171
  from tikz_renderer import render_tikz_document
 
172
  try:
173
  model = build_model_from_inputs(expr_str, values_str, mode)
174
  source = render_tikz_document(model, precision=precision)
 
221
  raw_mermaid_code = mermaid_code
222
  mermaid_code = with_title_layout(raw_mermaid_code)
223
  try:
224
+ layout_svg = fetch_mermaid_ink("svg", encode_mermaid(mermaid_code))
225
+ mermaid_code = with_title_layout(raw_mermaid_code, layout_svg)
 
 
 
 
 
226
  except Exception as error:
227
  print("Warning: Could not position section titles; retaining title spacing:", error)
228
 
229
  # Encode for mermaid.ink
230
  base64_string = encode_mermaid(mermaid_code)
231
+ png_url = MERMAID_INK_URLS["png"].format(payload=base64_string)
 
232
  b64_png = None
233
 
 
 
234
  # Only fetch PNG needed for responsive HTML display
235
  try:
236
+ png_data = fetch_mermaid_ink("png", base64_string)
237
+ with open(png_path, "wb") as f:
238
+ f.write(png_data)
239
+ b64_png = base64.b64encode(png_data).decode("ascii")
 
 
240
  except Exception as e:
241
  print("Warning: Could not fetch PNG preview:", e)
242
 
 
257
  return html, mermaid_code
258
 
259
  except Exception as e:
260
+ error_html = f"<div style='color: #e53e3e; background: #fff5f5; padding: 15px; border-radius: 8px; border: 1px solid #feb2b2;'><b>Error:</b> {html_lib.escape(str(e))}</div>"
261
  return error_html, ""
262
 
263
 
264
 
265
 
266
+ MERMAID_INK_URLS = {
267
+ "svg": "https://mermaid.ink/svg/{payload}",
268
+ "png": "https://mermaid.ink/img/{payload}?type=png",
269
+ "pdf": "https://mermaid.ink/pdf/{payload}",
270
+ }
271
+ MERMAID_INK_TIMEOUT = float(os.environ.get("AUTODIFF_MERMAID_TIMEOUT", "15"))
272
+
273
+
274
+ def fetch_mermaid_ink(kind, payload):
275
+ """Render encoded Mermaid source with the external mermaid.ink service.
276
+
277
+ The static diagram and its PNG/SVG/PDF exports depend on this service (the
278
+ interactive graph, walkthrough and TikZ export do not). Raises on failure.
279
+ """
280
+ request = urllib.request.Request(
281
+ MERMAID_INK_URLS[kind].format(payload=payload), headers={"User-Agent": "Mozilla/5.0"}
282
+ )
283
+ with urllib.request.urlopen(request, timeout=MERMAID_INK_TIMEOUT) as response:
284
+ chunks = []
285
+ while True:
286
+ try:
287
+ chunk = response.read(65536)
288
+ except Exception as error: # e.g. IncompleteRead: keep what arrived
289
+ if getattr(error, "partial", None):
290
+ chunks.append(error.partial)
291
+ break
292
+ if not chunk:
293
+ break
294
+ chunks.append(chunk)
295
+ data = b"".join(chunks)
296
+ if not data:
297
+ raise OSError(f"mermaid.ink returned an empty {kind} response")
298
+ return data
299
+
300
+
301
  def export_file(mermaid_code, fmt):
302
  """Generates the requested export format on demand with content hashing and cache."""
303
  if not mermaid_code or not mermaid_code.strip():
304
  return None, gr.update(visible=False)
305
+
306
  prune_cache()
307
+ code_hash = hashlib.md5(mermaid_code.encode("utf8")).hexdigest()[:12]
 
 
308
  out_path = os.path.join(CACHE_DIR, f"autodiff_{code_hash}.{fmt}")
309
+
310
  if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
311
  return out_path, gr.update(value=out_path, visible=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
+ try:
314
+ if fmt == "mmd":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  with open(out_path, "w", encoding="utf-8") as f:
316
  f.write(mermaid_code)
317
+ elif fmt in MERMAID_INK_URLS:
318
+ data = fetch_mermaid_ink(fmt, encode_mermaid(mermaid_code))
319
+ with open(out_path, "wb") as f:
320
+ f.write(data)
321
+ else:
322
+ raise ValueError(f"Unsupported export format: {fmt}")
323
+ return out_path, gr.update(value=out_path, visible=True)
324
+ except Exception as e:
325
+ print(f"Warning: {fmt.upper()} export failed:", e)
326
+
327
+ if fmt == "pdf":
328
+ # Fallback: rasterise the PNG export into a PDF.
329
+ png_path, _ = export_file(mermaid_code, "png")
330
+ if png_path and os.path.exists(png_path):
331
+ try:
332
+ from PIL import Image
333
+ Image.open(png_path).convert("RGB").save(out_path, "PDF", resolution=300)
334
+ return out_path, gr.update(value=out_path, visible=True)
335
+ except Exception as pe:
336
+ print("Pillow PDF conversion failed:", pe)
337
+
338
  return None, gr.update(visible=False)
339
 
340
 
 
358
 
359
 
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  APP_STYLE = """
362
  /* ==========================================================================
363
  Problem editor & Formula explanation
 
856
  # -------------------------------------------------------------------------
857
  # Public API Endpoints for Jupyter Notebooks, external scripts, & clients
858
  # -------------------------------------------------------------------------
859
+ api_inputs = [expr_input, vals_input, mode_input, notation_input, precision_input, scientific_input]
 
 
 
 
 
 
 
 
 
860
 
861
+ def _api_file_endpoint(fmt):
862
+ def endpoint(expr, vals, mode="Forward & Backward", notation="∂(...) / ∂x [Mathbook]", precision=2, allow_scientific=True):
863
+ return api_generate_file(expr, vals, mode, notation, fmt, precision=precision, allow_scientific=allow_scientific)
864
+ return endpoint
 
 
 
 
 
 
865
 
866
+ def _api_mermaid_endpoint(expr, vals, mode="Forward & Backward", notation="∂(...) / ∂x [Mathbook]", precision=2, allow_scientific=True):
867
+ return api_generate_mermaid(expr, vals, mode, notation, precision=precision, allow_scientific=allow_scientific)
 
 
 
 
 
 
 
 
868
 
869
+ def _api_walkthrough_endpoint(expr, vals, mode="Forward & Backward", notation="∂(...) / ∂x [Mathbook]", precision=2, allow_scientific=True):
870
+ return generate_trace(expr, vals, mode, notation, precision=precision, allow_scientific=allow_scientific)
 
 
 
 
 
 
 
 
871
 
872
+ api_endpoints = [
873
+ ("generate_png", _api_file_endpoint("png"), gr.File),
874
+ ("generate_svg", _api_file_endpoint("svg"), gr.File),
875
+ ("generate_pdf", _api_file_endpoint("pdf"), gr.File),
876
+ ("generate_mermaid", _api_mermaid_endpoint, gr.Code),
877
+ ("generate_walkthrough", _api_walkthrough_endpoint, gr.HTML),
878
+ ]
879
+ for api_name, endpoint, output_cls in api_endpoints:
880
+ gr.Button(visible=False).click(
881
+ fn=endpoint, inputs=api_inputs, outputs=output_cls(visible=False), api_name=api_name,
882
+ )
883
 
884
  # -------------------------------------------------------------------------
885
  # URL Query Parameter Loading (e.g. ?expr=...&vals=...&tab=...)
 
887
  def load_from_request(request: gr.Request):
888
  params = request.query_params if request else {}
889
  if not params:
890
+ return (gr.skip(),) * 12
891
 
892
  expr_param = (params.get("expr") or params.get("formula") or "").strip()
893
  vals_param = (params.get("vals") or params.get("values") or "").strip()
 
907
  target_tab = "tab_tutorial"
908
  elif tab_param in {"help", "guide", "user_guide", "tab_help"}:
909
  target_tab = "tab_help"
910
+ elif tab_param in {"diagram", "static", "mermaid", "visual", "tab_diagram"}:
911
  target_tab = "tab_diagram"
912
  else:
913
+ # Default matches the app's initial tab.
914
+ target_tab = "tab_interactive"
915
 
916
  # Expressions and values
917
  expr_up = expr_param if expr_param else gr.skip()
 
960
  diagram_html_up = gr.skip()
961
  mermaid_code_up = gr.skip()
962
  trace_html_up = gr.skip()
963
+ interactive_html_up = gr.skip()
964
 
965
  if do_autorun and expr_param:
966
  eff_vals = vals_param if vals_param else ""
 
969
  eff_precision = precision_val
970
  eff_sci = sci_up if sci_up is not gr.skip() else True
971
 
972
+ run_args = (expr_param, eff_vals, eff_mode, eff_notation, eff_precision, eff_sci)
973
  if target_tab == "tab_trace":
974
+ trace_html_up = on_generate_trace(*run_args)
975
+ elif target_tab == "tab_diagram":
976
+ diagram_html_up, mermaid_code_up = on_generate_diagram(*run_args)
 
 
 
 
 
977
  else:
978
+ interactive_html_up = on_generate_interactive(*run_args)
 
 
 
 
 
 
 
979
 
980
  return (
981
  gr.Tabs(selected=target_tab),
 
989
  diagram_html_up,
990
  mermaid_code_up,
991
  trace_html_up,
992
+ interactive_html_up,
993
  )
994
 
995
  load_outputs = [
 
1004
  diagram_output,
1005
  code_output,
1006
  trace_output,
1007
+ interactive_output,
1008
  ]
1009
  demo.load(load_from_request, inputs=None, outputs=load_outputs, api_name=False)
1010
 
1011
 
1012
+ # Gradio runs each event one request at a time by default. Static diagrams wait
1013
+ # on mermaid.ink (network I/O, up to two requests per click), so a classroom of
1014
+ # simultaneous clicks would otherwise queue behind each other. Handlers run in
1015
+ # worker threads; the engines keep no shared mutable state between requests.
1016
+ CONCURRENCY_LIMIT = int(os.environ.get("AUTODIFF_CONCURRENCY_LIMIT", "16"))
1017
+ demo.queue(default_concurrency_limit=CONCURRENCY_LIMIT)
1018
+
1019
+
1020
  if __name__ == "__main__":
1021
  demo.launch(head=APP_HEAD, css=APP_STYLE)
assets/interactive_graph.bundle.js CHANGED
The diff for this file is too large to render. See raw diff
 
autodiff_visualizer.py CHANGED
@@ -1,8 +1,14 @@
1
  import ast
2
  import math
3
  import re
 
4
  import sympy as sp
5
  from number_format import format_num, format_numeric_literals
 
 
 
 
 
6
 
7
  # ---------------------------------------------------------------------------
8
  # 1. Safe AST-Based Mathematical Parser (No eval, No sympify)
@@ -23,7 +29,8 @@ class nograd(sp.Function):
23
 
24
 
25
  # sp.lambdify has no built-in translation for our custom nograd Function.
26
- LAMBDIFY_MODULES = [{"nograd": lambda v: v}, "math"]
 
27
 
28
  ALLOWED_FUNCS = {
29
  "sin": sp.sin,
@@ -44,59 +51,10 @@ ALLOWED_FUNCS = {
44
  "nograd": nograd,
45
  }
46
 
47
- MAX_EXPR_LEN = 200
48
- MAX_AST_NODES = 250
49
- MAX_AST_DEPTH = 35
50
- MAX_NUMERIC_MAGNITUDE = 1e9
51
- MAX_EXPONENT_MAGNITUDE = 50
52
 
53
 
54
- class ASTValidator(ast.NodeVisitor):
55
- def __init__(self):
56
- self.node_count = 0
57
- self.max_depth = 0
58
- self.current_depth = 0
59
-
60
- def visit(self, node):
61
- self.node_count += 1
62
- if self.node_count > MAX_AST_NODES:
63
- raise ValueError(f"Expression exceeds complexity limit ({MAX_AST_NODES} nodes).")
64
-
65
- self.current_depth += 1
66
- if self.current_depth > self.max_depth:
67
- self.max_depth = self.current_depth
68
- if self.max_depth > MAX_AST_DEPTH:
69
- raise ValueError(f"Expression nesting too deep (max depth {MAX_AST_DEPTH}).")
70
-
71
- res = super().visit(node)
72
- self.current_depth -= 1
73
- return res
74
-
75
- def generic_visit(self, node):
76
- # Disallow any unsupported AST node types
77
- allowed_types = (
78
- ast.Expression,
79
- ast.BinOp,
80
- ast.UnaryOp,
81
- ast.Add,
82
- ast.Sub,
83
- ast.Mult,
84
- ast.Div,
85
- ast.Pow,
86
- ast.UAdd,
87
- ast.USub,
88
- ast.Name,
89
- ast.Constant,
90
- ast.Call,
91
- ast.Load,
92
- )
93
- # Compatibility with Python < 3.8
94
- if hasattr(ast, "Num"):
95
- allowed_types = allowed_types + (ast.Num,)
96
-
97
- if not isinstance(node, allowed_types):
98
- raise ValueError(f"Disallowed construct: {type(node).__name__}")
99
- super().generic_visit(node)
100
 
101
 
102
  def ast_to_sympy(node):
@@ -104,19 +62,16 @@ def ast_to_sympy(node):
104
  return ast_to_sympy(node.body)
105
 
106
  if isinstance(node, ast.Constant):
 
 
107
  if not isinstance(node.value, (int, float)):
108
  raise ValueError(f"Only numeric constants are allowed, got: {type(node.value).__name__}")
109
  if abs(node.value) > MAX_NUMERIC_MAGNITUDE:
110
  raise ValueError(f"Numeric constant {node.value} exceeds maximum allowed magnitude ({MAX_NUMERIC_MAGNITUDE}).")
111
  return sp.Number(node.value)
112
 
113
- if hasattr(ast, "Num") and isinstance(node, ast.Num):
114
- if abs(node.n) > MAX_NUMERIC_MAGNITUDE:
115
- raise ValueError(f"Numeric constant {node.n} exceeds maximum allowed magnitude.")
116
- return sp.Number(node.n)
117
-
118
  if isinstance(node, ast.Name):
119
- if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", node.id):
120
  raise ValueError(f"Invalid variable identifier: {node.id}")
121
  return sp.Symbol(node.id)
122
 
@@ -214,6 +169,38 @@ def format_mathbook_expr(expr):
214
  # 3. Graph Nodes & Computation Graph Engine
215
  # ---------------------------------------------------------------------------
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  class Node:
218
  def __init__(self, uid, name, expr, is_input=False, is_constant=False):
219
  self.uid = uid # Opaque unique graph identifier (e.g. inp_0, op_1)
@@ -309,7 +296,10 @@ class ComputationGraph:
309
  node.args = child_nodes
310
  self.expr_to_node[expr] = node
311
 
312
- dummy_symbols = [sp.Symbol(f"_u{i}") for i in range(len(node.args))]
 
 
 
313
  if isinstance(expr, sp.Add):
314
  dummy_op = sp.Add(*dummy_symbols)
315
  elif isinstance(expr, sp.Mul):
@@ -405,33 +395,47 @@ class ComputationGraph:
405
  self.nodes.append(node)
406
  return node
407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  def evaluate(self, feed_dict, forward_only=False):
409
  # 1. Forward pass
410
  for node in self.nodes:
411
  if node.is_input:
412
  if node.expr in feed_dict:
413
- node.val = float(feed_dict[node.expr])
414
  elif node.is_constant:
415
- node.val = float(node.expr)
416
  else:
417
  raise ValueError(f"Missing numerical value for variable '{node.name}'")
418
  else:
419
- if isinstance(node.expr, sp.Add):
420
- node.val = float(sum(arg.val for arg in node.args))
421
- elif isinstance(node.expr, sp.Mul):
422
- prod = 1.0
423
- for arg in node.args:
424
- prod *= arg.val
425
- node.val = float(prod)
426
- elif isinstance(node.expr, sp.Pow):
427
- base_val = node.args[0].val
428
- exp_val = node.args[1].val
429
- if base_val < 0 and abs(exp_val - round(exp_val)) < 1e-9:
430
- node.val = float(base_val ** int(round(exp_val)))
431
- else:
432
- node.val = float(base_val ** exp_val)
433
- else:
434
- node.val = float(node.forward_fn(*(arg.val for arg in node.args)))
435
 
436
  # If forward only mode, bypass local derivative evaluation and backpropagation
437
  if forward_only:
@@ -441,21 +445,10 @@ class ComputationGraph:
441
  for node in self.nodes:
442
  if not node.is_input:
443
  for loc in node.local_derivatives:
444
- if loc.get('is_pow_constant_exp', False):
445
- base_val = node.args[0].val
446
- c = loc['exp_val']
447
- if base_val == 0.0 and c < 1:
448
- loc['local_val'] = 0.0
449
- elif base_val < 0 and abs((c - 1) - round(c - 1)) < 1e-9:
450
- loc['local_val'] = float(c * (base_val ** int(round(c - 1))))
451
- else:
452
- loc['local_val'] = float(c * (base_val ** (c - 1)))
453
- elif loc.get('is_pow_constant_base', False):
454
- c = loc['base_val']
455
- exp_val = node.args[1].val
456
- loc['local_val'] = float((c ** exp_val) * math.log(c))
457
- else:
458
- loc['local_val'] = float(loc['numeric_fn'](*(arg.val for arg in node.args)))
459
 
460
  # 3. Backward pass (Accumulate adjoints & record edge contributions)
461
  for node in self.nodes:
@@ -477,7 +470,10 @@ class ComputationGraph:
477
  continue # hard stop: no backward step, no adjoint edge
478
  for loc in node.local_derivatives:
479
  child = loc['arg_node']
480
- contrib = node.grad * loc['local_val']
 
 
 
481
  loc['grad_contrib'] = contrib
482
  child.grad += contrib
483
  child.live = True
 
1
  import ast
2
  import math
3
  import re
4
+ import numpy as np
5
  import sympy as sp
6
  from number_format import format_num, format_numeric_literals
7
+ from limits import (
8
+ BoundedASTValidator, MAX_AST_DEPTH, MAX_AST_NODES, MAX_EXPR_LEN, MAX_NUMERIC_MAGNITUDE,
9
+ MAX_EXPONENT_MAGNITUDE,
10
+ VARIABLE_NAME_PATTERN,
11
+ )
12
 
13
  # ---------------------------------------------------------------------------
14
  # 1. Safe AST-Based Mathematical Parser (No eval, No sympify)
 
29
 
30
 
31
  # sp.lambdify has no built-in translation for our custom nograd Function.
32
+ # NumPy (rather than math) gives IEEE semantics: log(0) = -inf, log(-1) = nan.
33
+ LAMBDIFY_MODULES = [{"nograd": lambda v: v}, "numpy"]
34
 
35
  ALLOWED_FUNCS = {
36
  "sin": sp.sin,
 
51
  "nograd": nograd,
52
  }
53
 
 
 
 
 
 
54
 
55
 
56
+ class ASTValidator(BoundedASTValidator):
57
+ """Scalar grammar: arithmetic, names, numbers and allowlisted calls."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
 
60
  def ast_to_sympy(node):
 
62
  return ast_to_sympy(node.body)
63
 
64
  if isinstance(node, ast.Constant):
65
+ if isinstance(node.value, bool):
66
+ raise ValueError("Boolean constants are not allowed.")
67
  if not isinstance(node.value, (int, float)):
68
  raise ValueError(f"Only numeric constants are allowed, got: {type(node.value).__name__}")
69
  if abs(node.value) > MAX_NUMERIC_MAGNITUDE:
70
  raise ValueError(f"Numeric constant {node.value} exceeds maximum allowed magnitude ({MAX_NUMERIC_MAGNITUDE}).")
71
  return sp.Number(node.value)
72
 
 
 
 
 
 
73
  if isinstance(node, ast.Name):
74
+ if not re.match(VARIABLE_NAME_PATTERN, node.id):
75
  raise ValueError(f"Invalid variable identifier: {node.id}")
76
  return sp.Symbol(node.id)
77
 
 
169
  # 3. Graph Nodes & Computation Graph Engine
170
  # ---------------------------------------------------------------------------
171
 
172
+ def _real_power(base, exponent):
173
+ """Real-valued power; integer exponents of negative bases stay real."""
174
+ if base < 0 and abs(exponent - round(exponent)) < 1e-9:
175
+ return base ** int(round(exponent))
176
+ return base ** exponent
177
+
178
+
179
+ def _checked_number(compute, description):
180
+ """Evaluate one numeric step under the policy shared with the tensor engine.
181
+
182
+ Arithmetic follows IEEE/NumPy (and PyTorch) conventions: infinite values
183
+ such as log(0) = -inf or d/dx sqrt(x) at 0 = +inf are legitimate limits and
184
+ are shown. NaN or non-real results mean the quantity is undefined; they
185
+ are reported as an error naming the offending quantity.
186
+ """
187
+ try:
188
+ with np.errstate(all="ignore"):
189
+ value = compute()
190
+ except ZeroDivisionError:
191
+ raise ValueError(f"{description} is undefined: 0/0 or a negative power of 0.") from None
192
+ except (OverflowError, ValueError, TypeError) as exc:
193
+ raise ValueError(f"{description} cannot be evaluated for these inputs ({exc}).") from None
194
+ if isinstance(value, complex) or np.iscomplexobj(value):
195
+ raise ValueError(f"{description} is not a real number (e.g. a fractional power of a negative number).")
196
+ value = np.float64(value)
197
+ if np.isnan(value):
198
+ raise ValueError(
199
+ f"{description} is undefined (e.g. log or a fractional power of a negative number, 0/0, or inf - inf)."
200
+ )
201
+ return value
202
+
203
+
204
  class Node:
205
  def __init__(self, uid, name, expr, is_input=False, is_constant=False):
206
  self.uid = uid # Opaque unique graph identifier (e.g. inp_0, op_1)
 
296
  node.args = child_nodes
297
  self.expr_to_node[expr] = node
298
 
299
+ # Inputs are real numbers. Declaring the slots real lets SymPy produce
300
+ # closed-form derivatives (e.g. d|u|/du = sign(u)) instead of
301
+ # unevaluable Derivative(re(u)) terms.
302
+ dummy_symbols = [sp.Symbol(f"_u{i}", real=True) for i in range(len(node.args))]
303
  if isinstance(expr, sp.Add):
304
  dummy_op = sp.Add(*dummy_symbols)
305
  elif isinstance(expr, sp.Mul):
 
395
  self.nodes.append(node)
396
  return node
397
 
398
+ def _forward_value(self, node):
399
+ if isinstance(node.expr, sp.Add):
400
+ return sum(arg.val for arg in node.args)
401
+ if isinstance(node.expr, sp.Mul):
402
+ prod = 1.0
403
+ for arg in node.args:
404
+ prod *= arg.val
405
+ return prod
406
+ if isinstance(node.expr, sp.Pow):
407
+ return _real_power(node.args[0].val, node.args[1].val)
408
+ return node.forward_fn(*(arg.val for arg in node.args))
409
+
410
+ @staticmethod
411
+ def _local_derivative_value(node, loc):
412
+ if loc.get('is_pow_constant_exp', False):
413
+ c = loc['exp_val']
414
+ if c == 0:
415
+ return 0.0 # d/dx x**0 = 0 everywhere, including x = 0
416
+ # For 0 < c < 1 at x = 0 this raises ZeroDivisionError: the
417
+ # derivative is unbounded, which must not be reported as 0.
418
+ return c * _real_power(node.args[0].val, c - 1)
419
+ if loc.get('is_pow_constant_base', False):
420
+ c = loc['base_val']
421
+ return _real_power(np.float64(c), node.args[1].val) * np.log(np.float64(c))
422
+ return loc['numeric_fn'](*(arg.val for arg in node.args))
423
+
424
  def evaluate(self, feed_dict, forward_only=False):
425
  # 1. Forward pass
426
  for node in self.nodes:
427
  if node.is_input:
428
  if node.expr in feed_dict:
429
+ node.val = np.float64(feed_dict[node.expr])
430
  elif node.is_constant:
431
+ node.val = np.float64(float(node.expr))
432
  else:
433
  raise ValueError(f"Missing numerical value for variable '{node.name}'")
434
  else:
435
+ node.val = _checked_number(
436
+ lambda: self._forward_value(node),
437
+ f"Value of {node.name} = {format_expr(node.expr)}",
438
+ )
 
 
 
 
 
 
 
 
 
 
 
 
439
 
440
  # If forward only mode, bypass local derivative evaluation and backpropagation
441
  if forward_only:
 
445
  for node in self.nodes:
446
  if not node.is_input:
447
  for loc in node.local_derivatives:
448
+ loc['local_val'] = _checked_number(
449
+ lambda: self._local_derivative_value(node, loc),
450
+ f"Local derivative of {node.name} with respect to {loc['arg_node'].name}",
451
+ )
 
 
 
 
 
 
 
 
 
 
 
452
 
453
  # 3. Backward pass (Accumulate adjoints & record edge contributions)
454
  for node in self.nodes:
 
470
  continue # hard stop: no backward step, no adjoint edge
471
  for loc in node.local_derivatives:
472
  child = loc['arg_node']
473
+ contrib = _checked_number(
474
+ lambda: node.grad * loc['local_val'],
475
+ f"Gradient contribution from {node.name} to {child.name}",
476
+ )
477
  loc['grad_contrib'] = contrib
478
  child.grad += contrib
479
  child.live = True
backward_rules.py CHANGED
@@ -37,6 +37,25 @@ def scalar_rule(operation, occurrence, derivative, slot_derivative=None, slots=(
37
  derivative, node('mul', adjoint(operation.quantity), derivative), bindings)
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def tensor_rule(operation, occurrence):
41
  """Construct one local pullback. Its expression is also its implementation."""
42
  from scalar_rules import operand_slots
@@ -140,6 +159,10 @@ def tensor_rule(operation, occurrence):
140
  factor = y
141
  elif function == 'log':
142
  factor = node('div', literal(1), x)
 
 
 
 
143
  if factor is None:
144
  raise ValueError(f'Missing backward rule for {kind}/{function}')
145
  # A zero exponent is constant even if the incoming adjoint is nonfinite.
 
37
  derivative, node('mul', adjoint(operation.quantity), derivative), bindings)
38
 
39
 
40
+ def _inverse_sqrt_one_minus_square(x):
41
+ return node('div', literal(1), node('pow', node('sub', literal(1), node('pow', x, literal(2))), literal(0.5)))
42
+
43
+
44
+ # Local derivative factors f'(x) for elementwise functions; y = f(x).
45
+ _ELEMENTWISE_FACTORS = {
46
+ 'sin': lambda x, y, prod: node('cos', x),
47
+ 'cos': lambda x, y, prod: node('neg', node('sin', x)),
48
+ 'tan': lambda x, y, prod: node('add', literal(1), node('pow', y, literal(2))),
49
+ 'sqrt': lambda x, y, prod: node('div', literal(1), node('mul', literal(2), y)),
50
+ 'sinh': lambda x, y, prod: node('cosh', x),
51
+ 'cosh': lambda x, y, prod: node('sinh', x),
52
+ 'asin': lambda x, y, prod: _inverse_sqrt_one_minus_square(x),
53
+ 'acos': lambda x, y, prod: node('neg', _inverse_sqrt_one_minus_square(x)),
54
+ 'atan': lambda x, y, prod: node('div', literal(1), node('add', literal(1), node('pow', x, literal(2)))),
55
+ 'Abs': lambda x, y, prod: node('sign', x),
56
+ }
57
+
58
+
59
  def tensor_rule(operation, occurrence):
60
  """Construct one local pullback. Its expression is also its implementation."""
61
  from scalar_rules import operand_slots
 
159
  factor = y
160
  elif function == 'log':
161
  factor = node('div', literal(1), x)
162
+ elif function in _ELEMENTWISE_FACTORS:
163
+ factor = _ELEMENTWISE_FACTORS[function](x, y, 'elementwise' if child.shape else 'mul')
164
+ if function == 'Abs':
165
+ notes = ('sign(x) is -1, 0 or 1; at zero the chosen derivative is 0.',)
166
  if factor is None:
167
  raise ValueError(f'Missing backward rule for {kind}/{function}')
168
  # A zero exponent is constant even if the incoming adjoint is nonfinite.
docs/USER_GUIDE.md CHANGED
@@ -175,13 +175,12 @@ The visualizer includes both a **scalar calculus engine** and a **vector/matrix
175
  | Engine | Operators / Functions | Notes & Syntax |
176
  | :--- | :--- | :--- |
177
  | **Both** | `+`, `-`, `*`, `/`, unary `-` | Standard arithmetic; array operations are element-wise. |
178
- | **Both** | `**` | Powers ($x^y$ for scalars, integer/float powers like `v**2` for vectors). |
179
- | **Both** | `exp`, `log`, `tanh` | Natural exponential, natural logarithm, hyperbolic tangent. |
 
 
 
180
  | **Both** | `nograd(x)` | Stops backward gradient propagation through $x$. |
181
- | **Scalar only** | `sin`, `cos`, `tan` | Trigonometric functions (radians). |
182
- | **Scalar only** | `asin`, `acos`, `atan` | Inverse trigonometric functions. |
183
- | **Scalar only** | `sinh`, `cosh` | Hyperbolic sine and cosine. |
184
- | **Scalar only** | `sqrt`, `ln`, `abs` | Square root, natural log, absolute value. |
185
  | **Tensor only** | `@` | Matrix multiplication, matrix-vector product, vector dot product. |
186
  | **Tensor only** | `dot(u, v)` | Dot product of two vectors of equal length. |
187
  | **Tensor only** | `sum(x)`, `mean(x)`, `max(x)`, `min(x)` | Full reduction over all entries to a scalar. |
@@ -195,6 +194,10 @@ Enter comma-separated assignments in the **Input Values** box:
195
  * **Vectors**: `v = [1.0, -2.0, 3.0], b = 0.5`
196
  * **Matrices**: `W = [[1.0, 2.0], [3.0, 4.0]], x = [0.5, -1.0]`
197
 
 
 
 
 
198
  > **Scalar Output Requirement**: The overall formula must evaluate to a single scalar (e.g. via `sum(...)`, `mean(...)`, `dot(...)`, indexing `v[0]`, or scalar operations), because reverse-mode autodiff propagates sensitivities from a scalar root.
199
 
200
  ---
 
175
  | Engine | Operators / Functions | Notes & Syntax |
176
  | :--- | :--- | :--- |
177
  | **Both** | `+`, `-`, `*`, `/`, unary `-` | Standard arithmetic; array operations are element-wise. |
178
+ | **Both** | `**` | Powers. $x^y$ with a variable exponent requires all inputs to be scalars; vectors and matrices need a constant exponent such as `v**2`. |
179
+ | **Both** | `exp`, `log`/`ln`, `sqrt` | Natural exponential, natural logarithm, square root (element-wise on arrays). |
180
+ | **Both** | `sin`, `cos`, `tan`, `asin`, `acos`, `atan` | Trigonometric and inverse trigonometric functions (radians, element-wise). |
181
+ | **Both** | `sinh`, `cosh`, `tanh` | Hyperbolic functions (element-wise). |
182
+ | **Both** | `abs` | Absolute value; its derivative is `sign(x)`, with 0 chosen at $x = 0$. |
183
  | **Both** | `nograd(x)` | Stops backward gradient propagation through $x$. |
 
 
 
 
184
  | **Tensor only** | `@` | Matrix multiplication, matrix-vector product, vector dot product. |
185
  | **Tensor only** | `dot(u, v)` | Dot product of two vectors of equal length. |
186
  | **Tensor only** | `sum(x)`, `mean(x)`, `max(x)`, `min(x)` | Full reduction over all entries to a scalar. |
 
194
  * **Vectors**: `v = [1.0, -2.0, 3.0], b = 0.5`
195
  * **Matrices**: `W = [[1.0, 2.0], [3.0, 4.0]], x = [0.5, -1.0]`
196
 
197
+ ### Undefined and infinite values
198
+
199
+ Both engines follow IEEE/PyTorch conventions. Infinite limits are shown as $\infty$: for example $\log 0 = -\infty$, the derivative of $\sqrt{x}$ at $0$ is $+\infty$, and $1/0 = \infty$. Values that are undefined, such as $\log(-1)$, $\sqrt{-4}$, $0/0$ or $\infty - \infty$, stop the computation with an error message naming the affected quantity.
200
+
201
  > **Scalar Output Requirement**: The overall formula must evaluate to a single scalar (e.g. via `sum(...)`, `mean(...)`, `dot(...)`, indexing `v[0]`, or scalar operations), because reverse-mode autodiff propagates sensitivities from a scalar root.
202
 
203
  ---
frontend/src/graph_widget.ts CHANGED
@@ -77,6 +77,8 @@ export class InteractiveGraphWidget {
77
  private stepsByUid: Map<string, StepData> = new Map();
78
  private stepsBySourceUid: Map<string, StepData[]> = new Map();
79
 
 
 
80
  constructor(rootEl: HTMLElement, payload: GraphPayload) {
81
  this.rootEl = rootEl;
82
  this.payload = payload;
@@ -156,6 +158,7 @@ export class InteractiveGraphWidget {
156
  const htmlLayer = document.createElement('div');
157
  htmlLayer.className = 'dvi-html-layer';
158
  this.htmlLayerEl = htmlLayer;
 
159
  viewport.appendChild(htmlLayer);
160
 
161
  const inspector = document.createElement('div');
@@ -297,6 +300,23 @@ export class InteractiveGraphWidget {
297
  const nodes: LayoutNodeInfo[] = [];
298
  const edges: LayoutEdgeInfo[] = [];
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  // 1. Create DOM elements for Quantities
301
  for (const q of this.payload.quantities) {
302
  const el = document.createElement('div');
@@ -329,12 +349,12 @@ export class InteractiveGraphWidget {
329
  if (this.editingNodeUid === q.uid) {
330
  primalHtml = `
331
  <div class="dvi-q-primal">
332
- <input type="text" class="dvi-rename-input" value="${escapeHtml(qName)}" data-rename-uid="${q.uid}" />
333
  </div>
334
  `;
335
  } else if (this.graphMode === 'compact') {
336
  // In compact mode, show only the node name (no rename function; hover/click opens tensor inspector)
337
- const hoverAttrs = `data-inspect-node="${q.uid}"`;
338
  primalHtml = `
339
  <div class="dvi-q-primal dvi-tensor-trigger" ${hoverAttrs} title="${
340
  q.operation ? 'Click to highlight paths' : 'Input node'
@@ -343,7 +363,7 @@ export class InteractiveGraphWidget {
343
  </div>
344
  `;
345
  } else {
346
- const hoverAttrs = `data-inspect-node="${q.uid}"`;
347
  const valHtml = math.renderValueHtml(q.primal, hoverAttrs);
348
  let lhsAndFormulaTex = qNameTex;
349
  if (q.operation) {
@@ -354,7 +374,7 @@ export class InteractiveGraphWidget {
354
  <div class="dvi-q-primal">
355
  <span>${renderKatex(`${lhsAndFormulaTex} =`, false)}</span>
356
  ${valHtml}
357
- <button type="button" class="dvi-rename-btn" data-trigger-rename="${q.uid}" title="Rename variable (${escapeHtml(qName)})">✎</button>
358
  </div>
359
  `;
360
  }
@@ -372,14 +392,14 @@ export class InteractiveGraphWidget {
372
  this.payload.notation === 'mathbook'
373
  ? `\\overline{${qNameTex}} =`
374
  : `${qNameTex}_{\\mathrm{bar}} =`;
375
- const hoverAttrs = `data-inspect-node="${q.uid}"`;
376
  const adjValHtml = math.renderValueHtml(q.adjoint, hoverAttrs);
377
 
378
  const outSteps = this.stepsBySourceUid.get(q.uid) || [];
379
 
380
  adjointHtml = `
381
  <div class="dvi-q-divider"></div>
382
- <div class="dvi-q-adjoint" data-toggle-source-steps="${q.uid}" title="${
383
  outSteps.length > 0 ? 'Click to toggle backward step details' : ''
384
  }">
385
  <span>${renderKatex(adjTex, false)}</span>
@@ -402,7 +422,10 @@ export class InteractiveGraphWidget {
402
  quantity: q,
403
  });
404
  }
 
405
 
 
 
406
  // 2. Output Boundary Node
407
  if (this.payload.outputStateUid) {
408
  const outEl = document.createElement('div');
@@ -419,9 +442,11 @@ export class InteractiveGraphWidget {
419
  region: 'output',
420
  });
421
  }
 
422
 
 
 
423
  // 3. Backward Step Nodes & Sigma Accumulations
424
- const shownStepUids = new Set<string>();
425
  if (!this.payload.forwardOnly) {
426
  for (const step of this.payload.steps) {
427
  if (!this.isStepShownAsNode(step.uid)) continue;
@@ -534,7 +559,10 @@ export class InteractiveGraphWidget {
534
  });
535
  }
536
  }
 
537
 
 
 
538
  // 4. Build Edges & Edge Labels
539
  // Forward Edges
540
  this.payload.forwardEdges.forEach((fe, idx) => {
@@ -785,7 +813,10 @@ export class InteractiveGraphWidget {
785
  this.htmlLayerEl.appendChild(lbl);
786
  edge.labelEl = lbl;
787
  }
 
788
 
 
 
789
  // 5. Measure actual DOM dimensions before running Dagre layout
790
  const prevTransform = this.htmlLayerEl.style.transform;
791
  this.htmlLayerEl.style.transform = 'translate(0px, 0px) scale(1)';
@@ -964,12 +995,74 @@ export class InteractiveGraphWidget {
964
  item.edge.labelEl.style.top = `${item.y}px`;
965
  }
966
  }
 
967
 
968
- this.applyFocusHighlighting();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
969
 
970
- if (autoFit) {
971
- requestAnimationFrame(() => this.fitToViewport(false));
 
 
 
 
 
 
 
 
972
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
973
  }
974
 
975
  /**
 
77
  private stepsByUid: Map<string, StepData> = new Map();
78
  private stepsBySourceUid: Map<string, StepData[]> = new Map();
79
 
80
+ private pendingFocusKey: string | null = null;
81
+
82
  constructor(rootEl: HTMLElement, payload: GraphPayload) {
83
  this.rootEl = rootEl;
84
  this.payload = payload;
 
158
  const htmlLayer = document.createElement('div');
159
  htmlLayer.className = 'dvi-html-layer';
160
  this.htmlLayerEl = htmlLayer;
161
+ this.installKeyboardSupport();
162
  viewport.appendChild(htmlLayer);
163
 
164
  const inspector = document.createElement('div');
 
300
  const nodes: LayoutNodeInfo[] = [];
301
  const edges: LayoutEdgeInfo[] = [];
302
 
303
+ this.buildQuantityNodes(math, nodes);
304
+ this.buildOutputNode(nodes);
305
+ const shownStepUids = new Set<string>();
306
+ this.buildStepAndSigmaNodes(math, nodes, shownStepUids);
307
+ this.buildEdges(math, nodes, edges, shownStepUids);
308
+ this.layoutAndDraw(nodes, edges);
309
+
310
+ this.applyFocusHighlighting();
311
+ this.applyAccessibility();
312
+
313
+ if (autoFit) {
314
+ requestAnimationFrame(() => this.fitToViewport(false));
315
+ }
316
+ }
317
+
318
+ /** Phase 1: DOM elements for quantity (and constant) nodes. */
319
+ private buildQuantityNodes(math: ReturnType<InteractiveGraphWidget["getMathRenderer"]>, nodes: LayoutNodeInfo[]): void {
320
  // 1. Create DOM elements for Quantities
321
  for (const q of this.payload.quantities) {
322
  const el = document.createElement('div');
 
349
  if (this.editingNodeUid === q.uid) {
350
  primalHtml = `
351
  <div class="dvi-q-primal">
352
+ <input type="text" class="dvi-rename-input" value="${escapeHtml(qName)}" data-rename-uid="${escapeHtml(q.uid)}" />
353
  </div>
354
  `;
355
  } else if (this.graphMode === 'compact') {
356
  // In compact mode, show only the node name (no rename function; hover/click opens tensor inspector)
357
+ const hoverAttrs = `data-inspect-node="${escapeHtml(q.uid)}"`;
358
  primalHtml = `
359
  <div class="dvi-q-primal dvi-tensor-trigger" ${hoverAttrs} title="${
360
  q.operation ? 'Click to highlight paths' : 'Input node'
 
363
  </div>
364
  `;
365
  } else {
366
+ const hoverAttrs = `data-inspect-node="${escapeHtml(q.uid)}"`;
367
  const valHtml = math.renderValueHtml(q.primal, hoverAttrs);
368
  let lhsAndFormulaTex = qNameTex;
369
  if (q.operation) {
 
374
  <div class="dvi-q-primal">
375
  <span>${renderKatex(`${lhsAndFormulaTex} =`, false)}</span>
376
  ${valHtml}
377
+ <button type="button" class="dvi-rename-btn" data-trigger-rename="${escapeHtml(q.uid)}" title="Rename variable (${escapeHtml(qName)})">✎</button>
378
  </div>
379
  `;
380
  }
 
392
  this.payload.notation === 'mathbook'
393
  ? `\\overline{${qNameTex}} =`
394
  : `${qNameTex}_{\\mathrm{bar}} =`;
395
+ const hoverAttrs = `data-inspect-node="${escapeHtml(q.uid)}"`;
396
  const adjValHtml = math.renderValueHtml(q.adjoint, hoverAttrs);
397
 
398
  const outSteps = this.stepsBySourceUid.get(q.uid) || [];
399
 
400
  adjointHtml = `
401
  <div class="dvi-q-divider"></div>
402
+ <div class="dvi-q-adjoint" data-toggle-source-steps="${escapeHtml(q.uid)}" title="${
403
  outSteps.length > 0 ? 'Click to toggle backward step details' : ''
404
  }">
405
  <span>${renderKatex(adjTex, false)}</span>
 
422
  quantity: q,
423
  });
424
  }
425
+ }
426
 
427
+ /** Phase 2: the empty output boundary circle. */
428
+ private buildOutputNode(nodes: LayoutNodeInfo[]): void {
429
  // 2. Output Boundary Node
430
  if (this.payload.outputStateUid) {
431
  const outEl = document.createElement('div');
 
442
  region: 'output',
443
  });
444
  }
445
+ }
446
 
447
+ /** Phase 3: backward step trapezoids and Σ accumulation nodes. */
448
+ private buildStepAndSigmaNodes(math: ReturnType<InteractiveGraphWidget["getMathRenderer"]>, nodes: LayoutNodeInfo[], shownStepUids: Set<string>): void {
449
  // 3. Backward Step Nodes & Sigma Accumulations
 
450
  if (!this.payload.forwardOnly) {
451
  for (const step of this.payload.steps) {
452
  if (!this.isStepShownAsNode(step.uid)) continue;
 
559
  });
560
  }
561
  }
562
+ }
563
 
564
+ /** Phase 4: forward/backward edges and their labels. */
565
+ private buildEdges(math: ReturnType<InteractiveGraphWidget["getMathRenderer"]>, nodes: LayoutNodeInfo[], edges: LayoutEdgeInfo[], shownStepUids: Set<string>): void {
566
  // 4. Build Edges & Edge Labels
567
  // Forward Edges
568
  this.payload.forwardEdges.forEach((fe, idx) => {
 
813
  this.htmlLayerEl.appendChild(lbl);
814
  edge.labelEl = lbl;
815
  }
816
+ }
817
 
818
+ /** Phases 5-9: measure, run the Dagre layout, draw bands, position nodes and curved edges. */
819
+ private layoutAndDraw(nodes: LayoutNodeInfo[], edges: LayoutEdgeInfo[]): void {
820
  // 5. Measure actual DOM dimensions before running Dagre layout
821
  const prevTransform = this.htmlLayerEl.style.transform;
822
  this.htmlLayerEl.style.transform = 'translate(0px, 0px) scale(1)';
 
995
  item.edge.labelEl.style.top = `${item.y}px`;
996
  }
997
  }
998
+ }
999
 
1000
+ /**
1001
+ * Keyboard and screen-reader support. Interactive targets become focusable
1002
+ * buttons: Enter/Space activates (click), focus shows the inspector (hover),
1003
+ * F2 renames a quantity. Because every render rebuilds the DOM, focus is
1004
+ * restored to the element with the same stable key afterwards.
1005
+ */
1006
+ private static readonly A11Y_TARGETS: Array<[string, (el: HTMLElement) => string]> = [
1007
+ ['.dvi-node-quantity', () => 'Quantity: activate to highlight connections, F2 to rename'],
1008
+ ['.dvi-node-step', () => 'Backward step: activate to toggle its derivation'],
1009
+ ['.dvi-node-sigma', () => 'Sum of gradient contributions'],
1010
+ ['.dvi-q-adjoint[data-toggle-source-steps]', () => 'Adjoint: activate to toggle outgoing backward steps'],
1011
+ ['.dvi-tensor-trigger', () => 'Inspect tensor values'],
1012
+ ['.dvi-edge-label-expandable, .dvi-edge-label[style*="cursor"]', () => 'Edge value: activate to expand or inspect'],
1013
+ ];
1014
+
1015
+ private a11yKey(el: HTMLElement): string | null {
1016
+ const host = el.closest<HTMLElement>('[data-node-id], [data-edge-id]');
1017
+ if (!host) return null;
1018
+ const id = host.getAttribute('data-node-id') ?? `edge:${host.getAttribute('data-edge-id')}`;
1019
+ return el === host ? id : `${id}|${el.className}`;
1020
+ }
1021
 
1022
+ private applyAccessibility(): void {
1023
+ const layer = this.htmlLayerEl;
1024
+ for (const [selector, label] of InteractiveGraphWidget.A11Y_TARGETS) {
1025
+ layer.querySelectorAll<HTMLElement>(selector).forEach((el) => {
1026
+ el.tabIndex = 0;
1027
+ el.setAttribute('role', 'button');
1028
+ const q = this.quantitiesByUid.get(el.getAttribute('data-node-id') || '');
1029
+ const prefix = q && el.classList.contains('dvi-node-quantity') ? `${this.getDisplayName(q)}. ` : '';
1030
+ el.setAttribute('aria-label', prefix + label(el));
1031
+ });
1032
  }
1033
+ layer.querySelectorAll<HTMLElement>('[data-trigger-rename]').forEach((btn) => {
1034
+ btn.setAttribute('aria-label', btn.title || 'Rename variable');
1035
+ });
1036
+ if (this.pendingFocusKey) {
1037
+ const key = this.pendingFocusKey;
1038
+ this.pendingFocusKey = null;
1039
+ const target = Array.from(layer.querySelectorAll<HTMLElement>('[tabindex="0"]'))
1040
+ .find((el) => this.a11yKey(el) === key);
1041
+ target?.focus({ preventScroll: true });
1042
+ }
1043
+ }
1044
+
1045
+ private installKeyboardSupport(): void {
1046
+ const layer = this.htmlLayerEl;
1047
+ const isTarget = (t: EventTarget | null): t is HTMLElement =>
1048
+ t instanceof HTMLElement && t.getAttribute('role') === 'button' && layer.contains(t);
1049
+ layer.addEventListener('keydown', (ev) => {
1050
+ if (!isTarget(ev.target)) return;
1051
+ if (ev.key === 'Enter' || ev.key === ' ') {
1052
+ ev.preventDefault();
1053
+ this.pendingFocusKey = this.a11yKey(ev.target);
1054
+ ev.target.click();
1055
+ } else if (ev.key === 'F2' && ev.target.classList.contains('dvi-node-quantity')) {
1056
+ ev.preventDefault();
1057
+ ev.target.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
1058
+ }
1059
+ });
1060
+ layer.addEventListener('focusin', (ev) => {
1061
+ if (isTarget(ev.target)) ev.target.dispatchEvent(new MouseEvent('mouseenter'));
1062
+ });
1063
+ layer.addEventListener('focusout', (ev) => {
1064
+ if (isTarget(ev.target)) ev.target.dispatchEvent(new MouseEvent('mouseleave'));
1065
+ });
1066
  }
1067
 
1068
  /**
frontend/src/index.ts CHANGED
@@ -1,8 +1,13 @@
 
1
  import { GraphPayload } from './types';
2
  import { InteractiveGraphWidget } from './graph_widget';
3
  import { INTERACTIVE_GRAPH_CSS } from './styles';
4
 
5
- const KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
 
 
 
 
6
 
7
  function ensureStylesInjected(): void {
8
  if (!document.getElementById('dvi-katex-css')) {
@@ -25,19 +30,21 @@ function ensureStylesInjected(): void {
25
 
26
  function mountAllInteractiveGraphs(): void {
27
  ensureStylesInjected();
28
- const containers = document.querySelectorAll<HTMLElement>(
29
- '.dv-interactive-root[data-graph]:not([data-dvi-mounted="true"])'
30
- );
31
  containers.forEach((el) => {
32
  const raw = el.getAttribute('data-graph');
33
- if (!raw) return;
 
34
  el.setAttribute('data-dvi-mounted', 'true');
35
  try {
36
  const payload = JSON.parse(raw) as GraphPayload;
37
  new InteractiveGraphWidget(el, payload);
38
  } catch (err) {
39
  console.error('Failed to mount InteractiveGraphWidget:', err);
40
- el.innerHTML = `<div style="color:#b91c1c;padding:16px;">Failed to initialize interactive graph: ${String(err)}</div>`;
 
 
 
41
  }
42
  });
43
  }
 
1
+ import katex from 'katex';
2
  import { GraphPayload } from './types';
3
  import { InteractiveGraphWidget } from './graph_widget';
4
  import { INTERACTIVE_GRAPH_CSS } from './styles';
5
 
6
+ // Stylesheet version always matches the bundled KaTeX JavaScript.
7
+ const KATEX_CSS_URL = `https://cdn.jsdelivr.net/npm/katex@${katex.version}/dist/katex.min.css`;
8
+
9
+ // Payload each container was last mounted with; a changed payload remounts.
10
+ const mountedPayloads = new WeakMap<HTMLElement, string>();
11
 
12
  function ensureStylesInjected(): void {
13
  if (!document.getElementById('dvi-katex-css')) {
 
30
 
31
  function mountAllInteractiveGraphs(): void {
32
  ensureStylesInjected();
33
+ const containers = document.querySelectorAll<HTMLElement>('.dv-interactive-root[data-graph]');
 
 
34
  containers.forEach((el) => {
35
  const raw = el.getAttribute('data-graph');
36
+ if (!raw || mountedPayloads.get(el) === raw) return;
37
+ mountedPayloads.set(el, raw);
38
  el.setAttribute('data-dvi-mounted', 'true');
39
  try {
40
  const payload = JSON.parse(raw) as GraphPayload;
41
  new InteractiveGraphWidget(el, payload);
42
  } catch (err) {
43
  console.error('Failed to mount InteractiveGraphWidget:', err);
44
+ const message = document.createElement('div');
45
+ message.style.cssText = 'color:#b91c1c;padding:16px;';
46
+ message.textContent = `Failed to initialize interactive graph: ${String(err)}`;
47
+ el.replaceChildren(message);
48
  }
49
  });
50
  }
frontend/src/math_view.ts CHANGED
@@ -53,6 +53,9 @@ export function formatLatexNum(
53
  cfg: FormatConfig = { precision: 2, allowScientific: true }
54
  ): string {
55
  const s = formatNum(value, cfg);
 
 
 
56
  if (s.includes('e')) {
57
  const [m, e] = s.split('e');
58
  return `${m}\\times 10^{${parseInt(e, 10)}}`;
@@ -186,7 +189,9 @@ export function renderKatex(tex: string, displayMode = false): string {
186
  throwOnError: false,
187
  displayMode,
188
  strict: false,
189
- trust: true,
 
 
190
  });
191
  } catch {
192
  return `<code>${escapeHtml(tex)}</code>`;
 
53
  cfg: FormatConfig = { precision: 2, allowScientific: true }
54
  ): string {
55
  const s = formatNum(value, cfg);
56
+ if (s === 'inf' || s === 'Infinity') return '\\infty';
57
+ if (s === '-inf' || s === '-Infinity') return '-\\infty';
58
+ if (s === 'nan' || s === 'NaN') return '\\mathrm{NaN}';
59
  if (s.includes('e')) {
60
  const [m, e] = s.split('e');
61
  return `${m}\\times 10^{${parseInt(e, 10)}}`;
 
189
  throwOnError: false,
190
  displayMode,
191
  strict: false,
192
+ // Never trust: \href, \url, \html* and \includegraphics stay disabled.
193
+ trust: false,
194
+ maxExpand: 1000,
195
  });
196
  } catch {
197
  return `<code>${escapeHtml(tex)}</code>`;
frontend/src/types.ts CHANGED
@@ -4,7 +4,6 @@ export interface SerializedValue {
4
  data: any;
5
  min?: number;
6
  max?: number;
7
- norm?: number;
8
  }
9
 
10
  export interface ExprData {
@@ -35,7 +34,6 @@ export interface ExplanationData {
35
 
36
  export interface OperationData {
37
  kind: string;
38
- function: string | null;
39
  operands: string[];
40
  formula: ExprData;
41
  stopExplanation: ExplanationData | null;
@@ -61,7 +59,6 @@ export interface StepData {
61
  incomingAdjoint: SerializedValue;
62
  localValue: SerializedValue;
63
  contribution: SerializedValue;
64
- isScalarRule: boolean;
65
  explanation: ExplanationData | null;
66
  }
67
 
@@ -76,7 +73,6 @@ export interface ForwardEdgeData {
76
  source: string;
77
  destination: string;
78
  value: SerializedValue;
79
- operandOccurrence: number | null;
80
  }
81
 
82
  export interface BackwardEdgeData {
@@ -99,7 +95,6 @@ export interface GraphPayload {
99
  outputName: string;
100
  formula: string;
101
  forwardOnly: boolean;
102
- rootUid: string;
103
  outputStateUid: string | null;
104
  notation: 'mathbook' | 'code';
105
  formatConfig: FormatConfig;
 
4
  data: any;
5
  min?: number;
6
  max?: number;
 
7
  }
8
 
9
  export interface ExprData {
 
34
 
35
  export interface OperationData {
36
  kind: string;
 
37
  operands: string[];
38
  formula: ExprData;
39
  stopExplanation: ExplanationData | null;
 
59
  incomingAdjoint: SerializedValue;
60
  localValue: SerializedValue;
61
  contribution: SerializedValue;
 
62
  explanation: ExplanationData | null;
63
  }
64
 
 
73
  source: string;
74
  destination: string;
75
  value: SerializedValue;
 
76
  }
77
 
78
  export interface BackwardEdgeData {
 
95
  outputName: string;
96
  formula: string;
97
  forwardOnly: boolean;
 
98
  outputStateUid: string | null;
99
  notation: 'mathbook' | 'code';
100
  formatConfig: FormatConfig;
input_parser.py CHANGED
@@ -2,10 +2,9 @@ import ast
2
  import re
3
  import numpy as np
4
 
5
- MAX_INPUT_TEXT_LEN = 1000
6
- MAX_TOTAL_ELEMENTS = 50000
7
- MAX_ARRAY_RANK = 2
8
- MAX_NUMERIC_MAGNITUDE = 1e9
9
 
10
  def split_top_level_assignments(text):
11
  """
@@ -55,12 +54,6 @@ def _validate_and_convert_literal(node):
55
  raise ValueError(f"Numeric literal exceeds maximum allowed magnitude ({MAX_NUMERIC_MAGNITUDE}).")
56
  return val
57
 
58
- if hasattr(ast, "Num") and isinstance(node, ast.Num):
59
- val = float(node.n)
60
- if abs(val) > MAX_NUMERIC_MAGNITUDE:
61
- raise ValueError(f"Numeric literal exceeds maximum allowed magnitude.")
62
- return val
63
-
64
  if isinstance(node, ast.UnaryOp):
65
  if isinstance(node.op, ast.UAdd):
66
  return +_validate_and_convert_literal(node.operand)
@@ -143,7 +136,7 @@ def parse_variable_assignments(text):
143
  var_name = var_part.strip()
144
  val_str = val_part.strip()
145
 
146
- if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", var_name):
147
  raise ValueError(f"Invalid variable identifier: '{var_name}'")
148
 
149
  if var_name in feed_dict:
 
2
  import re
3
  import numpy as np
4
 
5
+ from limits import (
6
+ MAX_ARRAY_RANK, MAX_INPUT_TEXT_LEN, MAX_NUMERIC_MAGNITUDE, MAX_TOTAL_ELEMENTS, VARIABLE_NAME_PATTERN,
7
+ )
 
8
 
9
  def split_top_level_assignments(text):
10
  """
 
54
  raise ValueError(f"Numeric literal exceeds maximum allowed magnitude ({MAX_NUMERIC_MAGNITUDE}).")
55
  return val
56
 
 
 
 
 
 
 
57
  if isinstance(node, ast.UnaryOp):
58
  if isinstance(node.op, ast.UAdd):
59
  return +_validate_and_convert_literal(node.operand)
 
136
  var_name = var_part.strip()
137
  val_str = val_part.strip()
138
 
139
+ if not re.match(VARIABLE_NAME_PATTERN, var_name):
140
  raise ValueError(f"Invalid variable identifier: '{var_name}'")
141
 
142
  if var_name in feed_dict:
interactive_renderer.py CHANGED
@@ -8,6 +8,8 @@ backward derivations on demand.
8
  """
9
  from __future__ import annotations
10
 
 
 
11
  import html
12
  import json
13
  from typing import Any
@@ -41,14 +43,12 @@ def _serialize_array(val: Any) -> dict[str, Any]:
41
  flat_finite = arr[np.isfinite(arr)]
42
  min_val = float(np.min(flat_finite)) if flat_finite.size > 0 else 0.0
43
  max_val = float(np.max(flat_finite)) if flat_finite.size > 0 else 0.0
44
- norm_val = float(np.linalg.norm(flat_finite)) if flat_finite.size > 0 else 0.0
45
  return {
46
  "kind": "tensor",
47
  "shape": list(arr.shape),
48
  "data": arr.tolist(),
49
  "min": min_val,
50
  "max": max_val,
51
- "norm": norm_val,
52
  }
53
 
54
 
@@ -97,7 +97,6 @@ def serialize_computation_model(
97
  if op is not None:
98
  q_dict["operation"] = {
99
  "kind": op.kind,
100
- "function": op.function,
101
  "operands": [operand.uid for operand in op.operands],
102
  "formula": op.formula.to_data(),
103
  "stopExplanation": (
@@ -125,7 +124,6 @@ def serialize_computation_model(
125
  "incomingAdjoint": _serialize_array(step.incoming_adjoint),
126
  "localValue": _serialize_array(step.local_value),
127
  "contribution": _serialize_array(step.contribution),
128
- "isScalarRule": step.rule.__class__.__name__ == "ScalarLocalRule",
129
  "explanation": {
130
  "contribution": expl.contribution.to_data(),
131
  "bindings": [_serialize_equation(b) for b in expl.bindings],
@@ -157,7 +155,6 @@ def serialize_computation_model(
157
  "source": edge.source,
158
  "destination": edge.destination,
159
  "value": _serialize_array(edge.value),
160
- "operandOccurrence": edge.operand_occurrence,
161
  })
162
 
163
  backward_edges_data = []
@@ -182,7 +179,6 @@ def serialize_computation_model(
182
  "outputName": model.output_name,
183
  "formula": model.formula,
184
  "forwardOnly": model.forward_only,
185
- "rootUid": model.root.uid,
186
  "outputStateUid": model.output.uid if model.output is not None else None,
187
  "notation": notation,
188
  "formatConfig": {
@@ -197,6 +193,16 @@ def serialize_computation_model(
197
  }
198
 
199
 
 
 
 
 
 
 
 
 
 
 
200
  def render_interactive_html(
201
  model: ComputationModel,
202
  notation: str = "mathbook",
@@ -204,5 +210,7 @@ def render_interactive_html(
204
  ) -> str:
205
  """Return the HTML container carrying the serialized ComputationModel JSON."""
206
  payload = serialize_computation_model(model, notation=notation, format_config=format_config)
207
- json_attr = html.escape(json.dumps(payload, separators=(",", ":")), quote=True)
 
 
208
  return f'<div class="dv-interactive-root" data-graph="{json_attr}"></div>'
 
8
  """
9
  from __future__ import annotations
10
 
11
+ import math
12
+
13
  import html
14
  import json
15
  from typing import Any
 
43
  flat_finite = arr[np.isfinite(arr)]
44
  min_val = float(np.min(flat_finite)) if flat_finite.size > 0 else 0.0
45
  max_val = float(np.max(flat_finite)) if flat_finite.size > 0 else 0.0
 
46
  return {
47
  "kind": "tensor",
48
  "shape": list(arr.shape),
49
  "data": arr.tolist(),
50
  "min": min_val,
51
  "max": max_val,
 
52
  }
53
 
54
 
 
97
  if op is not None:
98
  q_dict["operation"] = {
99
  "kind": op.kind,
 
100
  "operands": [operand.uid for operand in op.operands],
101
  "formula": op.formula.to_data(),
102
  "stopExplanation": (
 
124
  "incomingAdjoint": _serialize_array(step.incoming_adjoint),
125
  "localValue": _serialize_array(step.local_value),
126
  "contribution": _serialize_array(step.contribution),
 
127
  "explanation": {
128
  "contribution": expl.contribution.to_data(),
129
  "bindings": [_serialize_equation(b) for b in expl.bindings],
 
155
  "source": edge.source,
156
  "destination": edge.destination,
157
  "value": _serialize_array(edge.value),
 
158
  })
159
 
160
  backward_edges_data = []
 
179
  "outputName": model.output_name,
180
  "formula": model.formula,
181
  "forwardOnly": model.forward_only,
 
182
  "outputStateUid": model.output.uid if model.output is not None else None,
183
  "notation": notation,
184
  "formatConfig": {
 
193
  }
194
 
195
 
196
+ def _json_safe(value):
197
+ if isinstance(value, float) and not math.isfinite(value):
198
+ return str(value) # "inf", "-inf" or "nan"
199
+ if isinstance(value, dict):
200
+ return {k: _json_safe(v) for k, v in value.items()}
201
+ if isinstance(value, (list, tuple)):
202
+ return [_json_safe(v) for v in value]
203
+ return value
204
+
205
+
206
  def render_interactive_html(
207
  model: ComputationModel,
208
  notation: str = "mathbook",
 
210
  ) -> str:
211
  """Return the HTML container carrying the serialized ComputationModel JSON."""
212
  payload = serialize_computation_model(model, notation=notation, format_config=format_config)
213
+ # JSON has no Infinity/NaN literals (JSON.parse rejects them): encode as strings.
214
+ json_text = json.dumps(_json_safe(payload), separators=(",", ":"), allow_nan=False)
215
+ json_attr = html.escape(json_text, quote=True)
216
  return f'<div class="dv-interactive-root" data-graph="{json_attr}"></div>'
limits.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Input limits and the bounded AST validator shared by both engines and the value parser."""
2
+ import ast
3
+
4
+ MAX_EXPR_LEN = 200
5
+ MAX_AST_NODES = 250
6
+ MAX_AST_DEPTH = 35
7
+ MAX_NUMERIC_MAGNITUDE = 1e9
8
+ MAX_EXPONENT_MAGNITUDE = 50
9
+
10
+ MAX_INPUT_TEXT_LEN = 1000
11
+ MAX_TOTAL_ELEMENTS = 50000
12
+ MAX_ARRAY_RANK = 2
13
+
14
+ VARIABLE_NAME_PATTERN = r"^[a-zA-Z][a-zA-Z0-9_]*$"
15
+
16
+ SCALAR_AST_TYPES = (
17
+ ast.Expression, ast.BinOp, ast.UnaryOp,
18
+ ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.UAdd, ast.USub,
19
+ ast.Name, ast.Constant, ast.Call, ast.Load,
20
+ )
21
+
22
+
23
+ class BoundedASTValidator(ast.NodeVisitor):
24
+ """Reject disallowed syntax and bound expression size and nesting depth."""
25
+
26
+ allowed_types = SCALAR_AST_TYPES
27
+
28
+ def __init__(self):
29
+ self.node_count = 0
30
+ self.current_depth = 0
31
+
32
+ def visit(self, node):
33
+ self.node_count += 1
34
+ if self.node_count > MAX_AST_NODES:
35
+ raise ValueError(f"Expression exceeds complexity limit ({MAX_AST_NODES} nodes).")
36
+ self.current_depth += 1
37
+ if self.current_depth > MAX_AST_DEPTH:
38
+ raise ValueError(f"Expression nesting too deep (max depth {MAX_AST_DEPTH}).")
39
+ result = super().visit(node)
40
+ self.current_depth -= 1
41
+ return result
42
+
43
+ def generic_visit(self, node):
44
+ if not isinstance(node, self.allowed_types):
45
+ raise ValueError(f"Disallowed construct: {type(node).__name__}")
46
+ super().generic_visit(node)
47
+
48
+ def visit_Call(self, node):
49
+ if node.keywords:
50
+ raise ValueError("Keyword arguments are not allowed in function calls.")
51
+ self.generic_visit(node)
math_expression.py CHANGED
@@ -85,7 +85,7 @@ class Expr:
85
  'relu': lambda a: np.maximum(a, 0),
86
  'softmax': lambda a: np.exp(a - np.max(a)) / np.sum(np.exp(a - np.max(a))),
87
  'nograd': lambda a: a,
88
- 'sin': np.sin, 'cos': np.cos, 'tan': np.tan,
89
  'asin': np.arcsin, 'acos': np.arccos, 'atan': np.arctan,
90
  'sinh': np.sinh, 'cosh': np.cosh, 'tanh': np.tanh,
91
  'exp': np.exp, 'log': np.log, 'Abs': np.abs, 'sign': np.sign,
 
85
  'relu': lambda a: np.maximum(a, 0),
86
  'softmax': lambda a: np.exp(a - np.max(a)) / np.sum(np.exp(a - np.max(a))),
87
  'nograd': lambda a: a,
88
+ 'sin': np.sin, 'cos': np.cos, 'tan': np.tan, 'sqrt': np.sqrt,
89
  'asin': np.arcsin, 'acos': np.arccos, 'atan': np.arctan,
90
  'sinh': np.sinh, 'cosh': np.cosh, 'tanh': np.tanh,
91
  'exp': np.exp, 'log': np.log, 'Abs': np.abs, 'sign': np.sign,
requirements-dev.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -r requirements.txt
2
+ pytest
tensor_engine.py CHANGED
@@ -2,6 +2,13 @@ import ast
2
  import re
3
  import numpy as np
4
  from number_format import format_num as _scalar_format_num
 
 
 
 
 
 
 
5
 
6
 
7
  def format_num(val, sig_figs=4, config=None, precision=None, allow_scientific=None):
@@ -10,16 +17,6 @@ def format_num(val, sig_figs=4, config=None, precision=None, allow_scientific=No
10
  return _scalar_format_num(val, sig_figs=sig_figs, config=config, precision=precision, allow_scientific=allow_scientific)
11
 
12
 
13
- MAX_ARRAY_RANK = 2
14
- MAX_TOTAL_ELEMENTS = 50000
15
- MAX_PREVIEW_ELEMENTS = 4
16
-
17
- MAX_EXPR_LEN = 200
18
- MAX_AST_NODES = 250
19
- MAX_AST_DEPTH = 35
20
- MAX_NUMERIC_MAGNITUDE = 1e9
21
- MAX_EXPONENT_MAGNITUDE = 50
22
-
23
  TENSOR_ALLOWED_FUNCS = {
24
  "sigmoid",
25
  "relu",
@@ -34,61 +31,38 @@ TENSOR_ALLOWED_FUNCS = {
34
  "softmax",
35
  "pick",
36
  "nograd",
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }
38
 
 
 
39
 
40
- class TensorASTValidator(ast.NodeVisitor):
41
- def __init__(self):
42
- self.node_count = 0
43
- self.max_depth = 0
44
- self.current_depth = 0
45
-
46
- def visit(self, node):
47
- self.node_count += 1
48
- if self.node_count > MAX_AST_NODES:
49
- raise ValueError(f"Expression exceeds complexity limit ({MAX_AST_NODES} nodes).")
50
-
51
- self.current_depth += 1
52
- if self.current_depth > self.max_depth:
53
- self.max_depth = self.current_depth
54
- if self.max_depth > MAX_AST_DEPTH:
55
- raise ValueError(f"Expression nesting too deep (max depth {MAX_AST_DEPTH}).")
56
-
57
- res = super().visit(node)
58
- self.current_depth -= 1
59
- return res
60
-
61
- def generic_visit(self, node):
62
- allowed_types = (
63
- ast.Expression,
64
- ast.BinOp,
65
- ast.UnaryOp,
66
- ast.Add,
67
- ast.Sub,
68
- ast.Mult,
69
- ast.Div,
70
- ast.Pow,
71
- ast.MatMult,
72
- ast.UAdd,
73
- ast.USub,
74
- ast.Name,
75
- ast.Constant,
76
- ast.Call,
77
- ast.Load,
78
- ast.List,
79
- ast.Subscript,
80
- )
81
- if hasattr(ast, "Num"):
82
- allowed_types = allowed_types + (ast.Num,)
83
 
84
- if not isinstance(node, allowed_types):
85
- raise ValueError(f"Disallowed construct: {type(node).__name__}")
86
- super().generic_visit(node)
87
 
88
- def visit_Call(self, node):
89
- if node.keywords:
90
- raise ValueError("Keyword arguments are not allowed in function calls.")
91
- self.generic_visit(node)
 
92
 
93
  from tensor_formatter import format_tensor_value, format_tensor_shape, DEFAULT_TENSOR_ELEMENT_BUDGET
94
 
@@ -107,6 +81,30 @@ def format_tensor_badge(val, sig_figs=4, config=None):
107
  # Edge labels show value without trailing shape metadata to stay compact
108
  return format_tensor_value(val, element_budget=DEFAULT_TENSOR_ELEMENT_BUDGET, include_shape_if_truncated=False, config=config)
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
 
112
  class TensorNode:
@@ -191,8 +189,6 @@ class TensorComputationGraph:
191
  if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
192
  return None
193
  return float(node.value)
194
- if hasattr(ast, "Num") and isinstance(node, ast.Num):
195
- return float(node.n)
196
  if isinstance(node, ast.UnaryOp):
197
  val = self._extract_constant_scalar(node.operand)
198
  if val is None:
@@ -242,22 +238,6 @@ class TensorComputationGraph:
242
  self.nodes.append(c_node)
243
  return c_node
244
 
245
- if hasattr(ast, "Num") and isinstance(node, ast.Num):
246
- val = float(node.n)
247
- if abs(val) > MAX_NUMERIC_MAGNITUDE:
248
- raise ValueError(f"Numeric constant {val} exceeds maximum allowed magnitude.")
249
- self.cst_counter += 1
250
- c_node = TensorNode(
251
- uid=f"cst_{self.cst_counter}_{str(val).replace('-', 'neg_').replace('.', '_')}",
252
- name=format_num(val),
253
- op_type="const",
254
- is_input=True,
255
- is_constant=True,
256
- const_val=val,
257
- )
258
- self.nodes.append(c_node)
259
- return c_node
260
-
261
  if isinstance(node, ast.List):
262
  from input_parser import _validate_and_convert_literal
263
  arr_val = np.array(_validate_and_convert_literal(node), dtype=np.float64)
@@ -275,7 +255,7 @@ class TensorComputationGraph:
275
 
276
  if isinstance(node, ast.Name):
277
  var_name = node.id
278
- if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", var_name):
279
  raise ValueError(f"Invalid variable identifier: {var_name}")
280
  if var_name in self.name_to_input:
281
  return self.name_to_input[var_name]
@@ -315,7 +295,8 @@ class TensorComputationGraph:
315
  # Check for signed numeric constant exponent
316
  exp_val = self._extract_constant_scalar(node.right)
317
  if exp_val is None:
318
- raise ValueError("Tensor power currently requires a finite scalar constant exponent.")
 
319
  if abs(exp_val) > MAX_EXPONENT_MAGNITUDE:
320
  raise ValueError(f"Exponent magnitude {exp_val} exceeds limit of {MAX_EXPONENT_MAGNITUDE}.")
321
 
@@ -411,7 +392,7 @@ class TensorComputationGraph:
411
  op_type="func",
412
  is_input=False,
413
  )
414
- op_node.func_name = func_name
415
  op_node.args = [child_node]
416
  self.nodes.append(op_node)
417
  return op_node
@@ -442,6 +423,7 @@ class TensorComputationGraph:
442
  else:
443
  # Evaluate intermediate operation
444
  self._evaluate_forward_op(node)
 
445
 
446
  # Enforce scalar root restriction in both modes
447
  if self.root.shape != ():
@@ -601,18 +583,8 @@ class TensorComputationGraph:
601
  if isinstance(node.val, np.ndarray) and node.val.ndim == 0:
602
  node.val = np.float64(node.val)
603
  node.shape = child.shape
604
- elif f == "tanh":
605
- node.val = np.tanh(child.val)
606
- if isinstance(node.val, np.ndarray) and node.val.ndim == 0:
607
- node.val = np.float64(node.val)
608
- node.shape = child.shape
609
- elif f == "exp":
610
- node.val = np.exp(child.val)
611
- if isinstance(node.val, np.ndarray) and node.val.ndim == 0:
612
- node.val = np.float64(node.val)
613
- node.shape = child.shape
614
- elif f == "log":
615
- node.val = np.log(child.val)
616
  if isinstance(node.val, np.ndarray) and node.val.ndim == 0:
617
  node.val = np.float64(node.val)
618
  node.shape = child.shape
@@ -654,6 +626,7 @@ class TensorComputationGraph:
654
  contribution = rule.application.evaluate(values)
655
  if child.shape == ():
656
  contribution = float(contribution)
 
657
  record = {'arg_node': child, 'operand_occurrence': occurrence,
658
  'rule': rule, 'contrib_val': contribution}
659
  if isinstance(rule, ScalarLocalRule):
 
2
  import re
3
  import numpy as np
4
  from number_format import format_num as _scalar_format_num
5
+ from limits import (
6
+ BoundedASTValidator, SCALAR_AST_TYPES, MAX_AST_DEPTH, MAX_AST_NODES, MAX_ARRAY_RANK, MAX_TOTAL_ELEMENTS, MAX_EXPR_LEN,
7
+ MAX_NUMERIC_MAGNITUDE, MAX_EXPONENT_MAGNITUDE, VARIABLE_NAME_PATTERN,
8
+ )
9
+
10
+ MAX_PREVIEW_ELEMENTS = 4
11
+
12
 
13
 
14
  def format_num(val, sig_figs=4, config=None, precision=None, allow_scientific=None):
 
17
  return _scalar_format_num(val, sig_figs=sig_figs, config=config, precision=precision, allow_scientific=allow_scientific)
18
 
19
 
 
 
 
 
 
 
 
 
 
 
20
  TENSOR_ALLOWED_FUNCS = {
21
  "sigmoid",
22
  "relu",
 
31
  "softmax",
32
  "pick",
33
  "nograd",
34
+ # Elementwise functions shared with the scalar engine.
35
+ "sin",
36
+ "cos",
37
+ "tan",
38
+ "sqrt",
39
+ "sinh",
40
+ "cosh",
41
+ "asin",
42
+ "acos",
43
+ "atan",
44
+ "abs",
45
+ "Abs",
46
+ "ln",
47
  }
48
 
49
+ # Aliases normalised to one canonical function name (as in the scalar engine).
50
+ TENSOR_FUNC_ALIASES = {"abs": "Abs", "ln": "log"}
51
 
52
+ ELEMENTWISE_NUMPY = {
53
+ "tanh": np.tanh, "exp": np.exp, "log": np.log,
54
+ "sin": np.sin, "cos": np.cos, "tan": np.tan, "sqrt": np.sqrt,
55
+ "sinh": np.sinh, "cosh": np.cosh,
56
+ "asin": np.arcsin, "acos": np.arccos, "atan": np.arctan,
57
+ "Abs": np.abs,
58
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
 
 
 
60
 
61
+ class TensorASTValidator(BoundedASTValidator):
62
+ """Tensor grammar: the scalar grammar plus @, list literals and indexing."""
63
+
64
+ allowed_types = SCALAR_AST_TYPES + (ast.MatMult, ast.List, ast.Subscript)
65
+
66
 
67
  from tensor_formatter import format_tensor_value, format_tensor_shape, DEFAULT_TENSOR_ELEMENT_BUDGET
68
 
 
81
  # Edge labels show value without trailing shape metadata to stay compact
82
  return format_tensor_value(val, element_budget=DEFAULT_TENSOR_ELEMENT_BUDGET, include_shape_if_truncated=False, config=config)
83
 
84
+ _OP_SYMBOLS = {"neg": "negation", "add": "+", "sub": "-", "mul": "*", "div": "/",
85
+ "pow": "**", "matmul": "@", "dot": "dot"}
86
+
87
+
88
+ def _op_label(node):
89
+ return node.func_name if node.op_type == "func" else _OP_SYMBOLS.get(node.op_type, node.op_type)
90
+
91
+
92
+ def _require_defined(value, description):
93
+ """Shared numeric policy with the scalar engine (IEEE/PyTorch convention).
94
+
95
+ Infinite values (e.g. log(0) = -inf, d/dx sqrt(x) at 0 = +inf) are
96
+ legitimate limits and are shown. NaN means the quantity is undefined
97
+ (e.g. log(-1), sqrt(-1), inf - inf) and is reported instead of displayed.
98
+ """
99
+ arr = np.asarray(value, dtype=np.float64)
100
+ nan_mask = np.isnan(arr)
101
+ if not nan_mask.any():
102
+ return
103
+ where = "" if arr.ndim == 0 else f" in {int(nan_mask.sum())} of {arr.size} entries"
104
+ raise ValueError(
105
+ f"{description} is undefined{where} (e.g. log or a fractional power of a negative "
106
+ "number, 0/0, or inf - inf)."
107
+ )
108
 
109
 
110
  class TensorNode:
 
189
  if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
190
  return None
191
  return float(node.value)
 
 
192
  if isinstance(node, ast.UnaryOp):
193
  val = self._extract_constant_scalar(node.operand)
194
  if val is None:
 
238
  self.nodes.append(c_node)
239
  return c_node
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  if isinstance(node, ast.List):
242
  from input_parser import _validate_and_convert_literal
243
  arr_val = np.array(_validate_and_convert_literal(node), dtype=np.float64)
 
255
 
256
  if isinstance(node, ast.Name):
257
  var_name = node.id
258
+ if not re.match(VARIABLE_NAME_PATTERN, var_name):
259
  raise ValueError(f"Invalid variable identifier: {var_name}")
260
  if var_name in self.name_to_input:
261
  return self.name_to_input[var_name]
 
295
  # Check for signed numeric constant exponent
296
  exp_val = self._extract_constant_scalar(node.right)
297
  if exp_val is None:
298
+ raise ValueError("Vector/matrix expressions require a constant exponent (e.g. v**2). "
299
+ "Variable exponents such as x**y are only supported when every input is a scalar.")
300
  if abs(exp_val) > MAX_EXPONENT_MAGNITUDE:
301
  raise ValueError(f"Exponent magnitude {exp_val} exceeds limit of {MAX_EXPONENT_MAGNITUDE}.")
302
 
 
392
  op_type="func",
393
  is_input=False,
394
  )
395
+ op_node.func_name = TENSOR_FUNC_ALIASES.get(func_name, func_name)
396
  op_node.args = [child_node]
397
  self.nodes.append(op_node)
398
  return op_node
 
423
  else:
424
  # Evaluate intermediate operation
425
  self._evaluate_forward_op(node)
426
+ _require_defined(node.val, f"Value of {node.name} ({_op_label(node)})")
427
 
428
  # Enforce scalar root restriction in both modes
429
  if self.root.shape != ():
 
583
  if isinstance(node.val, np.ndarray) and node.val.ndim == 0:
584
  node.val = np.float64(node.val)
585
  node.shape = child.shape
586
+ elif f in ELEMENTWISE_NUMPY:
587
+ node.val = ELEMENTWISE_NUMPY[f](child.val)
 
 
 
 
 
 
 
 
 
 
588
  if isinstance(node.val, np.ndarray) and node.val.ndim == 0:
589
  node.val = np.float64(node.val)
590
  node.shape = child.shape
 
626
  contribution = rule.application.evaluate(values)
627
  if child.shape == ():
628
  contribution = float(contribution)
629
+ _require_defined(contribution, f"Gradient contribution from {node.name} ({_op_label(node)}) to {child.name}")
630
  record = {'arg_node': child, 'operand_occurrence': occurrence,
631
  'rule': rule, 'contrib_val': contribution}
632
  if isinstance(rule, ScalarLocalRule):
tests/test_numeric_policy.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests: abs, edge-point derivatives, shared numeric policy, engine dispatch."""
2
+ import numpy as np
3
+ import pytest
4
+
5
+ from app import build_model_from_inputs, generate_interactive_diagram, generate_trace, uses_tensor_engine
6
+ from tensor_engine import TensorComputationGraph
7
+
8
+ MODE = "Forward & Backward"
9
+
10
+
11
+ def adjoint_of(model, name):
12
+ return next(q.adjoint_value for q in model.quantities if q.name == name)
13
+
14
+
15
+ @pytest.mark.parametrize("value, expected", [(-2.0, 4.0), (0.0, 0.0), (3.0, 6.0)])
16
+ def test_abs_is_differentiable_in_scalar_engine(value, expected):
17
+ model = build_model_from_inputs("abs(x) * x", f"x={value}", MODE)
18
+ assert adjoint_of(model, "x") == pytest.approx(expected)
19
+
20
+
21
+ def test_fractional_power_at_zero_has_infinite_not_zero_derivative():
22
+ model = build_model_from_inputs("x**0.5", "x=0", MODE)
23
+ assert np.isposinf(adjoint_of(model, "x"))
24
+
25
+
26
+ def test_zero_power_at_zero_has_zero_derivative():
27
+ model = build_model_from_inputs("x**0 + x", "x=0", MODE)
28
+ assert adjoint_of(model, "x") == pytest.approx(1.0)
29
+
30
+
31
+ @pytest.mark.parametrize("expr, values", [
32
+ ("x**0.5", "x=-4"),
33
+ ("log(x)", "x=-1"),
34
+ ("x/y", "x=0, y=0"),
35
+ ("(-2)**x", "x=2"),
36
+ ("sum(x**0.5)", "x=[-1, 4]"),
37
+ ("sum(log(x))", "x=[-1, 1]"),
38
+ ])
39
+ def test_undefined_values_raise_readable_errors_in_both_engines(expr, values):
40
+ with pytest.raises(ValueError, match="undefined|not a real number"):
41
+ build_model_from_inputs(expr, values, MODE)
42
+
43
+
44
+ @pytest.mark.parametrize("scalar_expr, tensor_expr, values", [
45
+ ("log(x)", "sum(log(x))", ("x=0", "x=[0]")),
46
+ ("x**0.5", "sum(x**0.5)", ("x=0", "x=[0]")),
47
+ ("1/x", "sum(1/x)", ("x=0", "x=[0]")),
48
+ ])
49
+ def test_engines_agree_on_infinite_limits(scalar_expr, tensor_expr, values):
50
+ scalar = build_model_from_inputs(scalar_expr, values[0], MODE)
51
+ tensor = build_model_from_inputs(tensor_expr, values[1], MODE)
52
+ np.testing.assert_array_equal(np.ravel(adjoint_of(scalar, "x")), np.ravel(adjoint_of(tensor, "x")))
53
+
54
+
55
+ def test_scalar_overflow_is_reported_as_infinity_not_crash():
56
+ model = build_model_from_inputs("exp(exp(exp(x)))", "x=5", MODE)
57
+ assert np.isposinf(adjoint_of(model, "x"))
58
+
59
+
60
+ @pytest.mark.parametrize("func, point", [
61
+ ("sin", [0.3, -0.4]), ("cos", [0.3, -0.4]), ("tan", [0.3, -0.4]),
62
+ ("sqrt", [0.3, 0.7]), ("sinh", [0.3, -0.4]), ("cosh", [0.3, -0.4]),
63
+ ("asin", [0.3, -0.4]), ("acos", [0.3, -0.4]), ("atan", [0.3, -0.4]),
64
+ ("abs", [0.3, -0.4]), ("ln", [0.3, 0.7]),
65
+ ])
66
+ def test_tensor_elementwise_functions_match_finite_differences(func, point):
67
+ expr = f"sum({func}(x))"
68
+ x = np.array(point)
69
+ graph = TensorComputationGraph().build_from_ast(expr)
70
+ graph.evaluate({"x": x})
71
+ numeric = np.zeros_like(x)
72
+ for i in range(x.size):
73
+ step = np.zeros_like(x)
74
+ step[i] = 1e-6
75
+ plus = TensorComputationGraph().build_from_ast(expr)
76
+ plus.evaluate({"x": x + step}, forward_only=True)
77
+ minus = TensorComputationGraph().build_from_ast(expr)
78
+ minus.evaluate({"x": x - step}, forward_only=True)
79
+ numeric[i] = (plus.root.val - minus.root.val) / 2e-6
80
+ np.testing.assert_allclose(graph.name_to_input["x"].grad, numeric, atol=1e-5)
81
+ assert "Error" not in generate_trace(expr, f"x={point}", MODE)
82
+ assert "Error" not in generate_interactive_diagram(expr, f"x={point}", MODE)
83
+
84
+
85
+ def test_mixed_scalar_function_and_tensor_reduction():
86
+ model = build_model_from_inputs("sin(x) + sum(v)", "x=1, v=[1, 2]", MODE)
87
+ assert adjoint_of(model, "x") == pytest.approx(np.cos(1.0))
88
+
89
+
90
+ @pytest.mark.parametrize("expr, feed, expected", [
91
+ ("sin(x) * y", {"x": 1.0}, False),
92
+ ("maximum + x", {"x": 1.0}, False), # a variable named like a function
93
+ ("sum(v)", {"v": 1.0}, True),
94
+ ("v[0]", {}, True),
95
+ ("x", {"x": np.array([1.0])}, True),
96
+ ])
97
+ def test_engine_dispatch(expr, feed, expected):
98
+ assert uses_tensor_engine(expr, feed) is expected
99
+
100
+
101
+ def test_variable_exponent_error_explains_scalar_only_support():
102
+ with pytest.raises(ValueError, match="only supported when every input is a scalar"):
103
+ build_model_from_inputs("sum(v**y)", "v=[1, 2], y=2", MODE)
104
+
105
+
106
+ def test_boolean_constants_rejected_in_both_engines():
107
+ with pytest.raises(ValueError, match="Boolean"):
108
+ build_model_from_inputs("True * x", "x=1", MODE)
109
+ with pytest.raises(ValueError, match="Boolean"):
110
+ build_model_from_inputs("sum(True * v)", "v=[1]", MODE)
tests/test_url_loading.py CHANGED
@@ -29,7 +29,7 @@ def test_load_from_request_direct():
29
 
30
  # 1. Test empty request
31
  res_empty = load_fn(None)
32
- assert len(res_empty) == 11
33
  assert all(isinstance(x, gr.components.Component) or x == gr.skip() for x in res_empty)
34
 
35
  # 2. Test formula and values
@@ -42,7 +42,7 @@ def test_load_from_request_direct():
42
  "sci": "false",
43
  "autorun": "true",
44
  })
45
- tabs_up, expr_up, expl_up, vals_up, mode_up, not_up, prec_up, sci_up, diag_up, code_up, trace_up = load_fn(req)
46
 
47
  assert expr_up == "x * y + 3"
48
  assert vals_up == "x=2, y=5"
@@ -58,6 +58,26 @@ def test_load_from_request_direct():
58
  "tab": "trace",
59
  "autorun": "1",
60
  })
61
- tabs_up, expr_up, expl_up, vals_up, mode_up, not_up, prec_up, sci_up, diag_up, code_up, trace_up = load_fn(req_trace)
62
  assert getattr(tabs_up, "value", None) == "tab_trace" or getattr(tabs_up, "selected", None) == "tab_trace" or (isinstance(tabs_up, gr.Tabs) and tabs_up.selected == "tab_trace")
63
  assert "<" in trace_up or "sin" in trace_up
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  # 1. Test empty request
31
  res_empty = load_fn(None)
32
+ assert len(res_empty) == 12
33
  assert all(isinstance(x, gr.components.Component) or x == gr.skip() for x in res_empty)
34
 
35
  # 2. Test formula and values
 
42
  "sci": "false",
43
  "autorun": "true",
44
  })
45
+ tabs_up, expr_up, expl_up, vals_up, mode_up, not_up, prec_up, sci_up, diag_up, code_up, trace_up, inter_up = load_fn(req)
46
 
47
  assert expr_up == "x * y + 3"
48
  assert vals_up == "x=2, y=5"
 
58
  "tab": "trace",
59
  "autorun": "1",
60
  })
61
+ tabs_up, expr_up, expl_up, vals_up, mode_up, not_up, prec_up, sci_up, diag_up, code_up, trace_up, inter_up = load_fn(req_trace)
62
  assert getattr(tabs_up, "value", None) == "tab_trace" or getattr(tabs_up, "selected", None) == "tab_trace" or (isinstance(tabs_up, gr.Tabs) and tabs_up.selected == "tab_trace")
63
  assert "<" in trace_up or "sin" in trace_up
64
+
65
+
66
+ def _load_fn():
67
+ return next(item.fn for item in demo.fns.values()
68
+ if hasattr(item, "fn") and item.fn.__name__ == "load_from_request")
69
+
70
+
71
+ def test_load_defaults_to_interactive_tab_and_autoruns_it():
72
+ result = _load_fn()(DummyRequest({"expr": "x * y", "vals": "x=2, y=3"}))
73
+ assert result[0].selected == "tab_interactive"
74
+ assert result[-1] != gr.skip() and "Error" not in result[-1]
75
+
76
+
77
+ def test_url_values_cannot_inject_html_into_error_messages():
78
+ result = _load_fn()(DummyRequest({
79
+ "expr": "x", "vals": "x=<img src=x onerror=alert(1)>", "tab": "diagram",
80
+ }))
81
+ diagram_html = result[8]
82
+ assert "<img src=x" not in diagram_html
83
+ assert "&lt;img src=x onerror=alert(1)&gt;" in diagram_html