diff --git a/app/docs/criteria_graphs/physical_quantity_matches_mermaid.svg b/app/docs/criteria_graphs/physical_quantity_matches_mermaid.svg
index 8aa51bf..98d6775 100644
--- a/app/docs/criteria_graphs/physical_quantity_matches_mermaid.svg
+++ b/app/docs/criteria_graphs/physical_quantity_matches_mermaid.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/docs/criteria_graphs/physical_quantity_order_mermaid.svg b/app/docs/criteria_graphs/physical_quantity_order_mermaid.svg
index 467951f..5a60f36 100644
--- a/app/docs/criteria_graphs/physical_quantity_order_mermaid.svg
+++ b/app/docs/criteria_graphs/physical_quantity_order_mermaid.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/docs/criteria_graphs/symbolic_equality_mermaid.svg b/app/docs/criteria_graphs/symbolic_equality_mermaid.svg
index e6bc50a..3abefe8 100644
--- a/app/docs/criteria_graphs/symbolic_equality_mermaid.svg
+++ b/app/docs/criteria_graphs/symbolic_equality_mermaid.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/docs/criteria_graphs/symbolic_equality_of_equations_mermaid.svg b/app/docs/criteria_graphs/symbolic_equality_of_equations_mermaid.svg
index e9c8b5f..0b9acaa 100644
--- a/app/docs/criteria_graphs/symbolic_equality_of_equations_mermaid.svg
+++ b/app/docs/criteria_graphs/symbolic_equality_of_equations_mermaid.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/docs/criteria_graphs/symbolic_equality_of_sets_mermaid.svg b/app/docs/criteria_graphs/symbolic_equality_of_sets_mermaid.svg
index 545f872..a341538 100644
--- a/app/docs/criteria_graphs/symbolic_equality_of_sets_mermaid.svg
+++ b/app/docs/criteria_graphs/symbolic_equality_of_sets_mermaid.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/docs/criteria_graphs/symbolic_other_mermaid.svg b/app/docs/criteria_graphs/symbolic_other_mermaid.svg
index 7743796..da291d2 100644
--- a/app/docs/criteria_graphs/symbolic_other_mermaid.svg
+++ b/app/docs/criteria_graphs/symbolic_other_mermaid.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/docs/criteria_graphs/symbolic_where_mermaid.svg b/app/docs/criteria_graphs/symbolic_where_mermaid.svg
index 32715f3..8b6d8ee 100644
--- a/app/docs/criteria_graphs/symbolic_where_mermaid.svg
+++ b/app/docs/criteria_graphs/symbolic_where_mermaid.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/docs/criteria_graphs/symbolic_written_as_mermaid.svg b/app/docs/criteria_graphs/symbolic_written_as_mermaid.svg
index f8e06d3..f869019 100644
--- a/app/docs/criteria_graphs/symbolic_written_as_mermaid.svg
+++ b/app/docs/criteria_graphs/symbolic_written_as_mermaid.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/scripts/generate_process_animation.py b/scripts/generate_process_animation.py
new file mode 100644
index 0000000..016bbb7
--- /dev/null
+++ b/scripts/generate_process_animation.py
@@ -0,0 +1,1813 @@
+"""Generates `build/process_animation.html`: an interactive page that animates the process graphs of
+`scripts/generate_process_graphs.py`.
+
+Each example (those of the static figures, and `EXTRA_EXAMPLES`) is run through `evaluation_function` while recording
+every call down to `MAX_DEPTH` calls below `_evaluate`, with its arguments and return value or exception, the forms the
+response takes, the parameters, the criteria and the evaluations each criteria graph runs. The calls graph is laid out
+with graphviz once for each depth, with and without the criteria graphs and the internal (dunder) calls, and the page
+grows it in the order the functions were called. A side panel shows each step's details, and these can be switched on
+and off on the page.
+
+The page is a build output and is not committed. Run from the repository root with
+`python -m scripts.generate_process_animation` (it needs graphviz, i.e. `dot`), then open the page in a browser.
+"""
+from __future__ import annotations
+
+import base64
+import gzip
+import html
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+import pydot
+
+from scripts.generate_criteria_graphs import _node_label, add_criteria_graph
+from scripts.generate_process_graphs import (
+ EXAMPLES,
+ FIRST_STAGE,
+ Recording,
+ all_stages,
+ calls_to_dot,
+ record,
+ shown_parent,
+ stage_description,
+)
+
+OUTPUT_FILE = Path(__file__).resolve().parent.parent / "build" / "process_animation.html"
+
+# Examples shown only in the animation, for the parts of the criteria chain the static figures' examples don't reach:
+# a response that is not correct, more than one criterion, and a criterion without a template of its own.
+EXTRA_EXAMPLES: list[tuple[str, str, str, dict[str, Any]]] = [
+ ("process_symbolic_incorrect", "x + 2", "x + 1", {}),
+ ("process_symbolic_syntactical_comparison", "1 + x", "x + 1", {"syntactical_comparison": True}),
+ ("process_symbolic_default_template", "2*x + 1", "x + 1", {"criteria": "response contains x"}),
+]
+
+# The deepest calls below `_evaluate` recorded, and the deepest the page can show.
+MAX_DEPTH = 5
+
+# The depth the page starts at: the depth of the static calls figures.
+START_DEPTH = 2
+
+# How many calls of each function keep their arguments and return value, for the list of calls in the panel.
+INVOCATION_VALUES = 20
+
+CRITERION_STYLE = {"shape": "note", "style": "filled", "fillcolor": "#FFF8E1", "color": "#8D6E63"}
+CRITERION_EDGE_STYLE = {"color": "#8D6E63", "fontcolor": "#5D4037", "fontname": "Helvetica", "fontsize": "10"}
+
+
+def example_title(name: str) -> str:
+ """The name shown on the page for the example `name`, e.g. "Physical quantity" for `process_physical_quantity`."""
+ return name.removeprefix("process_").replace("_", " ").capitalize()
+
+
+def stage_identifier(stage: str) -> str:
+ """The graphviz identifier `add_stage_nodes` gives the node of `stage`."""
+ return f"stage_{all_stages().index(stage)}"
+
+
+def build_steps(recording: Recording) -> tuple[list[dict[str, Any]], dict[str, dict[str, list[Any]]], list[dict[str, Any]]]:
+ """The steps of the animation, the index of the event of each call of each function, and the forms the response
+ took, from the events in `recording`.
+
+ Each step is a stage being entered, a function called for the first time from its caller (a node of the calls
+ graph appearing), the criteria being parsed, a template building a criteria graph, a criteria graph evaluation, or
+ the result. Later calls of the same function only raise its count, so they are not steps of their own.
+
+ The invocations of each call are the index of the event of each, and the values of the first `INVOCATION_VALUES`.
+ """
+ steps: list[dict[str, Any]] = [{"kind": "stage", "stage": FIRST_STAGE, "event": -1, "depth": 0}]
+ invocations: dict[str, dict[str, list[Any]]] = {}
+ forms: list[dict[str, Any]] = []
+ calls = {call.identifier: call for call in recording.calls.values()}
+ # The values of each call still running, and its step, or None for a call that is not the first of its function.
+ running: list[tuple[dict[str, Any], dict[str, Any] | None]] = []
+ evaluation_step: dict[str, Any] = {}
+ for (index, event) in enumerate(recording.events):
+ kind = event["kind"]
+ if kind == "stage":
+ steps.append({"kind": "stage", "stage": event["stage"], "event": index, "depth": 0})
+ elif kind == "call":
+ call_invocations = invocations.setdefault(event["call"], {"events": [], "values": []})
+ call_invocations["events"].append(index)
+ values = {"event": index, "arguments": event["arguments"], "returned": "", "raised": False}
+ # Only the first calls keep their values, as some functions are called over a thousand times
+ if len(call_invocations["values"]) < INVOCATION_VALUES:
+ call_invocations["values"].append(values)
+ if len(call_invocations["events"]) == 1:
+ step = {
+ "kind": "call",
+ "call": event["call"],
+ "stage": event["stage"],
+ "depth": calls[event["call"]].depth,
+ "event": index,
+ "arguments": event["arguments"],
+ "returned": "",
+ "raised": False,
+ }
+ steps.append(step)
+ running.append((values, step))
+ else:
+ running.append((values, None))
+ elif kind == "return":
+ (values, step) = running.pop()
+ outcome = {"returned": event["value"], "raised": event["raised"]}
+ if "full" in event:
+ outcome["returnedFull"] = event["full"]
+ if "exception" in event:
+ outcome["exception"] = event["exception"]
+ values.update(outcome)
+ if step is not None:
+ step.update(outcome)
+ elif kind == "criteria":
+ steps.append({
+ "kind": "criteria",
+ "depth": 0,
+ "event": index,
+ "criteria": [criterion["identifier"] for criterion in event["criteria"]],
+ })
+ elif kind == "template":
+ steps.append({"kind": "template", "depth": 0, "event": index, "criterion": event["criterion"]})
+ elif kind == "evaluate":
+ evaluation_step = {
+ "kind": "evaluate",
+ "criterion": event["criterion"],
+ "node": event["node"],
+ "depth": 0,
+ "event": index,
+ "reached": [],
+ }
+ steps.append(evaluation_step)
+ elif kind == "evaluated":
+ evaluation_step["reached"] = event["reached"]
+ elif kind == "form":
+ forms.append({"event": index, "call": event["call"], "value": event["value"], "type": event["type"]})
+ elif kind == "parameters" or kind == "feedback":
+ # Shown in the panel rather than as steps (see `parameters_data` and `feedback_data`)
+ continue
+ else:
+ raise ValueError(f"Unknown event kind {kind!r}.")
+ steps.append({"kind": "result", "event": len(recording.events), "depth": 0})
+ for (index, step) in enumerate(steps):
+ step["index"] = index
+ return (steps, invocations, forms)
+
+
+def criteria_chain(recording: Recording, params: dict[str, Any]) -> dict[str, Any]:
+ """How the example's criteria graphs came about: where the criteria string came from, the string before and after
+ preprocessing, and for each criterion (by its identifier) its parse, the template that built its graph and the
+ index of its graph's cluster.
+
+ Empty when the evaluation stopped before parsing the criteria.
+ """
+ criteria_event = next((event for event in recording.events if event["kind"] == "criteria"), None)
+ if criteria_event is None:
+ return {}
+ if "criteria" in params:
+ source = 'Given in params["criteria"].'
+ else:
+ context = "physical_quantity" if params.get("physical_quantity", False) is True else "symbolic"
+ source = f"Not given in params, so the default criteria of the {context} context."
+ if params.get("syntactical_comparison", False) is True:
+ source += ' syntactical_comparison adds "response written as answer".'
+ indices = {criterion: index for (index, criterion) in enumerate(recording.criteria_graphs)}
+ criteria = {}
+ for criterion in criteria_event["criteria"]:
+ criteria[criterion["identifier"]] = {
+ "index": indices.get(criterion["identifier"]),
+ "label": criterion["label"],
+ "tree": criterion["tree"],
+ }
+ for event in recording.events:
+ if event["kind"] == "template":
+ criteria[event["criterion"]].update({
+ "template": event["template"],
+ "templateLabel": event["label"],
+ "call": event["call"],
+ "evaluations": event["evaluations"],
+ })
+ return {
+ "source": source,
+ "original": criteria_event["original"],
+ "preprocessed": criteria_event["preprocessed"],
+ "criteria": criteria,
+ }
+
+
+def visible_call(recording: Recording, identifier: str, depth: int, hide_internals: bool) -> str:
+ """The identifier of the call `identifier`, or of its nearest caller, that is drawn down to `depth`, with or
+ without the internal calls.
+ """
+ calls = {call.identifier: call for call in recording.calls.values()}
+ call = calls[identifier]
+ while call.parent is not None and (call.depth > depth or (hide_internals and call.internal)):
+ call = call.parent
+ return call.identifier
+
+
+def calls_layout(
+ recording: Recording,
+ params: dict[str, Any],
+ result: dict[str, Any],
+ chain: dict[str, Any],
+ depth: int,
+ with_criteria: bool,
+ hide_internals: bool,
+) -> tuple[pydot.Dot, dict[str, dict[str, str]]]:
+ """The calls graph down to `depth`, with the graphviz identifier of each criteria graph node by criterion and node
+ label.
+
+ With `with_criteria`, each criterion gets a node in the `parse_criteria` stage, with an edge to the template call
+ its parse label chose, and each criteria graph is drawn with an edge from that template call, which built it, and
+ from the `generate_feedback` stage, which ran it. Only the edge from the stage places the graph, so it is drawn
+ next to the stage rather than past the template call.
+
+ With `hide_internals`, the internal calls are left out (see `calls_to_dot`).
+ """
+ dot = calls_to_dot(recording, params, result, depth, hide_internals)
+ identifiers: dict[str, dict[str, str]] = {}
+ # Without criteria, i.e. when the evaluation stopped before parsing them, there is nothing to add
+ if not with_criteria or not chain:
+ return (dot, identifiers)
+ dot.set("compound", "true")
+ stages = all_stages()
+ parse_cluster = dot.get_subgraph(f"cluster_stage_cluster_{stages.index('parse_criteria')}")[0]
+ parser_call = next(call.identifier for call in recording.calls.values() if call.qualname == "create_criteria_dict")
+ for (index, (criterion, graph)) in enumerate(recording.criteria_graphs.items()):
+ details = chain["criteria"][criterion]
+ template_call = visible_call(recording, details["call"], depth, hide_internals)
+ criterion_node = f"criterion_{index}"
+ parse_cluster.add_node(pydot.Node(
+ criterion_node, label=_node_label(criterion, f"parse label: {details['label']}"), **CRITERION_STYLE
+ ))
+ dot.add_edge(pydot.Edge(parser_call, criterion_node, **CRITERION_EDGE_STYLE))
+ choice = details["label"] if details["templateLabel"] is not None else "default"
+ dot.add_edge(pydot.Edge(criterion_node, template_call, label=html.escape(choice), **CRITERION_EDGE_STYLE))
+ title = f"Criteria graph: {criterion} (built by {details['template']})"
+ cluster = pydot.Cluster(f"criteria_{index}", label=html.escape(title), color="#9E9E9E")
+ identifiers[criterion] = add_criteria_graph(cluster, graph, prefix=f"criteria_{index}_")
+ dot.add_subgraph(cluster)
+ for start in graph.starting_evaluations(criterion + "_TRUE"):
+ dot.add_edge(pydot.Edge(
+ template_call,
+ identifiers[criterion][start],
+ label="builds",
+ style="dashed",
+ lhead=cluster.get_name(),
+ constraint="false",
+ **CRITERION_EDGE_STYLE,
+ ))
+ dot.add_edge(pydot.Edge(
+ stage_identifier("generate_feedback"),
+ identifiers[criterion][start],
+ label="runs",
+ style="dashed",
+ lhead=cluster.get_name(),
+ fontname="Helvetica",
+ fontsize="10",
+ ))
+ return (dot, identifiers)
+
+
+def svg_markup(dot: pydot.Dot) -> str:
+ """`dot` drawn as an SVG element, gzipped and base64 encoded (the page unpacks it).
+
+ The SVG is left without the XML declaration and doctype in front of it, or the comments graphviz writes before each
+ node and edge (their titles hold the same names). The 20 layouts of an example are mostly the same markup, so
+ gzipping them makes the page about a tenth of the size.
+ """
+ svg = dot.create(format="svg").decode("utf-8")
+ svg = re.sub(r"\n?", "", svg[svg.index("