Spaces:
Running
Fix review findings and bump version to 0.11.0
Browse filesSecurity
- 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 +10 -1
- app.py +132 -201
- assets/interactive_graph.bundle.js +0 -0
- autodiff_visualizer.py +89 -93
- backward_rules.py +23 -0
- docs/USER_GUIDE.md +9 -6
- frontend/src/graph_widget.ts +103 -10
- frontend/src/index.ts +13 -6
- frontend/src/math_view.ts +6 -1
- frontend/src/types.ts +0 -5
- input_parser.py +4 -11
- interactive_renderer.py +15 -7
- limits.py +51 -0
- math_expression.py +1 -1
- requirements-dev.txt +2 -0
- tensor_engine.py +66 -93
- tests/test_numeric_policy.py +110 -0
- tests/test_url_loading.py +23 -3
|
@@ -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 |
-
|
| 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 |
|
|
@@ -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.
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 221 |
-
|
| 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 |
-
|
| 241 |
-
with
|
| 242 |
-
|
| 243 |
-
|
| 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 |
-
|
| 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 |
-
|
| 303 |
-
|
| 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 |
-
|
| 352 |
-
|
| 353 |
-
|
| 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 |
-
|
| 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 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 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 |
-
|
| 922 |
-
|
| 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 |
-
|
| 933 |
-
|
| 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 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
),
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 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(),) *
|
| 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"}
|
| 981 |
target_tab = "tab_diagram"
|
| 982 |
else:
|
| 983 |
-
|
|
|
|
| 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 |
-
|
| 1043 |
-
|
| 1044 |
-
eff_mode,
|
| 1045 |
-
eff_notation,
|
| 1046 |
-
eff_precision,
|
| 1047 |
-
eff_sci,
|
| 1048 |
-
)
|
| 1049 |
else:
|
| 1050 |
-
|
| 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)
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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 |
-
|
|
|
|
| 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(
|
| 55 |
-
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 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 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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.
|
|
@@ -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
|
| 179 |
-
| **Both** | `exp`, `log`, `
|
|
|
|
|
|
|
|
|
|
| 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 |
---
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 969 |
|
| 970 |
-
|
| 971 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
/**
|
|
@@ -1,8 +1,13 @@
|
|
|
|
|
| 1 |
import { GraphPayload } from './types';
|
| 2 |
import { InteractiveGraphWidget } from './graph_widget';
|
| 3 |
import { INTERACTIVE_GRAPH_CSS } from './styles';
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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:
|
|
|
|
|
|
|
| 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>`;
|
|
@@ -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;
|
|
@@ -2,10 +2,9 @@ import ast
|
|
| 2 |
import re
|
| 3 |
import numpy as np
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 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(
|
| 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:
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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>'
|
|
@@ -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)
|
|
@@ -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,
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest
|
|
@@ -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 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 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 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
| 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(
|
| 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("
|
|
|
|
| 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
|
| 605 |
-
node.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):
|
|
@@ -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)
|
|
@@ -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) ==
|
| 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 "<img src=x onerror=alert(1)>" in diagram_html
|