+ This tutorial describes C-RASP computations, their representation by transformers, and
+ the assumptions under which an idealized learner can identify a computation from shorter
+ inputs. Interactive examples illustrate the constructions; published experiments provide
+ a separate empirical comparison.
+
+ A transformer trained to recognize balanced parentheses on short strings may be
+ evaluated on longer strings. Understanding this setting requires separating what the
+ model can represent, what training identifies, and where the resulting computation
+ remains correct.
+
+
+ One possibility is that it has discovered a computation whose instructions do not
+ depend on input length. C-RASP makes such computations explicit: a fixed sequence of
+ operations acts on a vector with one entry per input token. Longer inputs produce
+ longer vectors, not longer programs.
+
+
+
+
Representability
+
Does a transformer exist that computes the target?
+
+
+
Learning
+
Does training find a solution on the observed inputs?
+
+
+
Length generalization
+
Does that solution also work beyond the training lengths?
+
+
+
+ These are different questions. A construction answers the first; it does not, on its
+ own, answer the other two. The following sections discuss these questions separately.
+
+
+
+
+
+
02 / A worked example
+
Recognizing balanced parentheses with C-RASP
+
+ The language Dyck-1 consists of correctly balanced strings using one kind of
+ parenthesis. For a nonempty input, two conditions characterize membership: no prefix
+ contains more closing than opening parentheses, and the complete input has equally
+ many of each. The first condition checks the order; the second checks that every
+ opening parenthesis is eventually closed.
+
+
+ For example, ()(()) satisfies both conditions. The string
+ ())(() has the same final counts, but its prefix ()) already
+ contains too many closes. A test that only compares the totals would miss this
+ violation.
+
+
From a sequence to vectors
+
+ A C-RASP assignment produces a vector with one entry per token position. A Boolean
+ vector records a yes/no answer at each position; a number vector records an integer.
+ Operations combine entries at the same position, or count how often a Boolean
+ predicate is true in the prefix. A prefix includes the current position.
+
+
+ The walkthrough starts with Opens, the number of opening parentheses seen
+ so far. Its values on ()(()) are 1, 1, 2, 3, 3, 3. The
+ program then constructs the closing counts, detects invalid prefixes, and combines the
+ two acceptance conditions. All seven assignments apply at every input length.
+
+
+ Follow the computation. Select Add next assignment to reveal
+ one row at a time. Each column corresponds to an input position. Select a token or
+ value to inspect the prefix used there. After reaching the final row, switch to the
+ counterexample and look for the first violation. Rows are revealed gradually only in
+ this walkthrough; the complete generated code is available below it.
+
+
+
+ Open the walkthrough in its own tab. Position labels in the activities start at 0. They use nonempty inputs; this choice
+ is a scope limit of the interface, not a claim that Dyck-1 excludes the empty word.
+
+
Why the final check works
+
+ Violation is true wherever Opens < Closes. Counting those
+ positions produces Violations; checking that it is zero gives
+ Safe. This retains the history of an invalid prefix even if later tokens
+ make the totals equal. Separately, Balanced compares the two counts at
+ the current position. The final assignment is Safe AND Balanced.
+
+
+ At the last position, these checks express exactly the two conditions above. The
+ argument does not depend on a maximum input length. This establishes what the program
+ computes; it does not establish that training a transformer will discover it.
+
+
+
+
+
03 / Construct a program
+
Experiment with C-RASP operations
+
+ The same operations can express tasks other than parenthesis recognition. In the
+ playground below, the initial program asks whether an input contains an
+ a. The first block marks positions containing a; the second
+ counts those marks; the third checks whether the count is greater than zero. The
+ decision is read at the last token.
+
+
+ Build without writing syntax. Click or drag an operation from the
+ palette to add a block. Choose its inputs from the menus and read the resulting vector
+ directly beneath it. Only earlier variables of a compatible type are offered. A count
+ consumes yes/no values and produces numbers; a comparison consumes numbers and
+ produces yes/no values. Values update immediately when you edit the program or input.
+
+
+ Start by changing the token test from a to b. Then try the
+ starter that requires both symbols: which extra checks are needed? Finally, compare it
+ with the starter asking whether an a occurs before a b. Test
+ a b and b a. Both have the same totals, but prefix
+ information distinguishes their order. This starter does not require adjacency.
+
+
+
+ Open the block playground in its own tab. The interface provides a subset of C-RASP: token tests, prefix counts, comparisons,
+ Boolean combinations, and addition. Its limits of 24 blocks and 16 tokens are
+ interface limits, not bounds on the language. The
+ text editor offers the syntax supported by the original
+ Python implementation.
+
+
What a well-formed program tells us
+
+ The block editor prevents syntax errors, incompatible inputs, and references to
+ variables that have not yet been defined. These checks do not ensure that a program
+ solves the intended task. A program that only checks equal totals is well-formed, but
+ it does not recognize Dyck-1. Changing inputs and inspecting vectors helps find
+ counterexamples; a correctness argument is still needed for all lengths.
+
+
+ Full C-RASP also includes operations such as subtraction, min/max, and conditional
+ selection. The important feature for this tutorial is that a fixed finite program
+ specifies a sequence of vector operations independently of input length. The next
+ section explains how transformer layers can represent such operations.
+
+
+
+
+
04 / Counting with attention
+
Representing counts with uniform attention
+ Construction intuition
+
+ The walkthrough used exact integer counts. To connect this to a transformer
+ construction, consider a head whose attention scores are zero at every visible
+ position. Softmax then assigns equal weight to every position. If the values encode a
+ Boolean predicate, the result is its frequency in the prefix.
+
+
+ Attention(P)i = C(i) / (i + 1)With a BOS token and i ordinary input tokens; P is false at BOS.
+
+
+ Counts at the same position share a positive denominator, so their ordering is
+ preserved. A BOS-only indicator has sum 1: averaging it supplies
+ 1/(i + 1), the encoded integer constant 1.
+
+
+ Reading the attention illustration. The sequence is fixed to
+ ()(()). Move the slider to change the number of ordinary tokens visible
+ to attention. The marked prefix also includes BOS, so each visible position receives
+ weight 1/(i + 1). The two fractions show the normalized opening-parenthesis count and
+ the normalized constant 1. This illustrates the arithmetic of the construction; it is
+ not attention measured in a trained model.
+
+
+
+ Inspect a uniform-attention prefixExact arithmetic illustration
+
+
+
+
+ Each box shows its value for “is an opening parenthesis” and its attention weight.
+ Muted boxes are outside the visible prefix.
+
+
+
+
+
+ BOS contributes zero to the opening-parenthesis count and one to the constant
+ indicator. Tokens outside the prefix receive no weight.
+
+
+
+
+
Precision and LayerNorm
+
+ Adjacent normalized counts differ by 1/(i + 1). Resolving them through length n
+ motivates O(log n) fractional precision for these values. This is a resolution
+ argument, not a proof that rounding every intermediate activation preserves a
+ complete simulation.
+
+
+ Yang and Chiang account for LayerNorm using paired coordinates with opposite signs
+ and additional comparison machinery. The full construction is more than simply
+ applying an unscaled comparison to an attention output.
+
+ C-RASP[Φ, Ψ] adds unary predicates on positions and binary relations that restrict
+ counting. Throughout this article, C-RASP[Pos] abbreviates the
+ paper’s C-RASP[periodic, local]: periodic unary predicates, and translation-invariant
+ local binary relations. Unrestricted prefix counting remains available separately.
+
+
+
+
Periodic
+
+ A pattern repeats after a fixed number of positions. An infinite sequence can have
+ a finite description.
+
+
+
+
Translation-invariant
+
Shifting both positions equally does not change their relation.
+
+
+
Local
+
The additional positional relation is false beyond a fixed distance.
+
+
+
+ Comparing positional relations. The boxes represent positions 0
+ through 11. The arrow marks the query position i; highlighted boxes are keys j
+ selected by the chosen relation within the causal prefix j ≤ i. Change the query
+ position to see how the selected keys move. The preceding-token relation satisfies
+ both restrictions. The other options illustrate what fails when locality or
+ translation invariance is removed; they are not all allowed relations in C-RASP[Pos].
+
+
+
+ Which positions are selected?Positional predicate
+
+
+
+
+
+
+ Query = the position doing the counting. Selected = a position that contributes if
+ its token predicate is true. Masked = a later position excluded by causal
+ attention.
+
+
+
+
+
+
+
+ A preceding-token relation is both local and translation-invariant. Even distance is
+ translation-invariant but not local. These restrictions concern the additional
+ positional interaction; content-based attention can still aggregate over an entire
+ prefix.
+
+
+
Where limit transformers enter
+
+ Learned absolute positional embeddings occupy a finite table. To analyze growing
+ context lengths, Huang et al. introduce limit transformers: fixed-width mathematical
+ models defined at arbitrary lengths, with positional vectors and additional
+ positional attention functions.
+
+
+ For the theorem, the vectors are periodic and the additional functions are
+ translation-invariant and local. Every C-RASP[Pos] program can be compiled into a
+ limit transformer with these properties. The converse expressive equivalence is not
+ established.
+
+
+
+
+
+
06 / From programs to learning
+
Identification under an idealized inference procedure
+ Theorem · Idealized inference
+
+ For context length n, imagine choosing a transformer that matches the target on
+ every input of length at most n/2, at every valid offset. Among admissible
+ exact fits Un, choose one with nearly the smallest complexity regularizer:
+
+
ℛ(Tn) ≤ infT ∈ Uₙ ℛ(T) + 1/n
+
+ An exact minimum need not be attained. The vanishing allowance makes the selection
+ rule well-defined whenever suitable fits exist. The hypothesis class also restricts
+ positional parameter products to be offset-independent; regularization alone does not
+ impose that condition.
+
+
+ The main equivalence
+
+ Under the paper’s assumptions, representability by a periodic, local limit
+ transformer is equivalent to eventual length generalization with uniformly bounded
+ regularizer values for any sequence selected by its inference procedure. Both
+ bounded complexity and eventual generalization matter.
+
+
+
+ Intuitively, bounded complexity leaves finitely many underlying candidate algorithms.
+ Every candidate that disagrees with the target has a finite counterexample. Increasing
+ the training length eventually exposes those counterexamples.
+
+
+
Read the assumptions before applying the theorem
+
+ This is not a guarantee for SGD on sampled training data. The learner fits all
+ shorter inputs, approximately minimizes a specific regularizer, and operates in a
+ restricted hypothesis class. The paper’s model uses length-scaled attention,
+ specified rounding of logits and exponentials, and ReLU/Heaviside activations. Its
+ initial analysis allows infinite-precision activations.
+
+
+ “Matching” is defined for the paper’s output functions, not merely equal accuracy on
+ a finite test set. A Boolean recognition formulation requires a compatible output
+ encoding. The theorem is about identified computations, not convergence of learned
+ matrices to unique weights.
+
+ Expressibility is a theoretical property. Generalization accuracy is an observation
+ about a particular training setup. The figures below reproduce results reported by
+ Huang et al. (2025). The separate gallery supports inspection of additional saved
+ evaluations.
+
+
+ Reading the published plots. Each panel compares accuracy across
+ three length bins. Use the buttons to switch between the overview and the version
+ labeling individual regular languages; click the figure to view it at full size. Solid
+ and dotted lines distinguish positional-embedding settings. The caption below explains
+ the training and model-selection ranges and the meaning of the colors.
+
+
+
+
+
+
+
+ Huang et al. (2025), Figure 1. Published experiment results; not new runs. Open the
+ image for full detail.
+
+
+
+ Source:
+ A Formal Framework for Understanding Length Generalization in Transformers, arXiv v2, CC BY 4.0. Original SVGs, unchanged. Training lengths are ≤50;
+ hyperparameters were selected using performance through 100, so [101,150] is the later
+ generalization evaluation. Green indicates a program was found; red includes both
+ proved nonexpressibility and cases where no program was found. Accuracy requires all
+ predicted positions in an example to be correct. These plots do not prove
+ learnability.
+
+
Additional saved evaluations
+
+ To inspect a run, import a JSON file and select it from the run menu. The plot shows
+ whole-sequence accuracy against input length, with the training range shaded and, when
+ recorded, the model-selection range marked separately. The table gives the underlying
+ correct and total counts. Each curve corresponds to one checkpoint and seed;
+ connecting lines do not represent additional measurements. No additional runs are
+ included in this edition.
+
+
+ Import a JSON file matching the
+ evaluation schema. Files are read locally in
+ your browser. No training or evaluation jobs are started. Each curve represents one
+ run and its recorded checkpoint.
+
+
+
+
+
+
+ No local experiment results supplied yet. Missing evaluations are unavailable—not
+ zero accuracy.
+
+
+
+
+
+
+
+
+
+
08 / What remains open
+
Scope and limitations
+
+ C-RASP supplies concrete algorithms, a route to transformer constructions, and a
+ sufficient condition for the idealized identification theorem. It does not tell us
+ that ordinary training will discover the intended algorithm, nor that a successful
+ finite evaluation establishes correctness at all lengths.
+
+
+ There is also no general computable training-length bound in terms of description size
+ for unrestricted C-RASP—even at counting depth two. Yang et al. (2026) identify a
+ positive fragment, C-RASP₊, for which worst-case optimal exponential bounds do exist.
+ Its restricted count comparisons and the corresponding fixed-precision transformer
+ model matter to that result.
+
+
+ The examples above expose the computation specified by a program. Determining whether
+ a trained transformer implements that computation requires additional evidence beyond
+ agreement on the displayed inputs.
+
+ The number of positions satisfying a predicate, including the current position.
+
+
BOS
+
+ A beginning-of-sequence token. Its indicator supplies a reference for normalized
+ constants.
+
+
APE / NoPE
+
Learned absolute positional embeddings / no explicit positional embeddings.
+
C-RASP[Pos]
+
+ This article’s abbreviation for C-RASP[periodic, local], not unrestricted
+ positional access.
+
+
ℛ
+
+ The framework’s complexity regularizer. A uniform bound is independent of context
+ length.
+
+
+
+
+
+
+
+
+
+
diff --git a/site/notebook.html b/site/notebook.html
new file mode 100644
index 0000000..096a553
--- /dev/null
+++ b/site/notebook.html
@@ -0,0 +1,113 @@
+
+
+
+
+
+ Five ways to explore C-RASP
+
+
+
+
+
+ Skip to notebook
+
+ C-RASP / LENGTH GENERALIZATIONBack to the tutorial
+
+
+
+
Notebook experiments · Five interaction designs
+
See a program take shape.
+
+ You do not need to know the syntax. A C-RASP program assigns a value to every token
+ position. Some values are numbers; others are yes/no answers. Try five ways of working
+ with those values below.
+
+
+ Start with the walkthrough, or try the block builder to construct a program. These are
+ alternative notebook designs for comparison, not five required lessons.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Values at each position
+
+
+
+
+
+
+
+ Preparing the local evaluator…
+
+
+
+
+
+ The code view translates your choices into syntax. You can complete every activity without
+ opening it.
+
+
+
+
+
What each design lets us test
+
+
01 · Walkthrough
+
+ Does revealing one assignment at a time make a vector computation easier to follow?
+
+
02 · Block builder
+
+ Can readers construct and modify well-formed programs by connecting typed operations?
+
+
03 · Repair a rule
+
Does a small counterexample explain why comparing final counts is insufficient?
+
04 · Token sandbox
+
+ Do immediate changes in prefix counts help connect a symbol sequence to its computation?
+
+
05 · Predict a value
+
Can readers infer a cell before seeing it, and explain their prediction?
+
+
+ These activities evaluate structured blocks directly in your browser, without a download
+ or server-side execution. The evaluator is cross-checked against the original
+ C-RASP Python interpreter
+ during development. This is a small typed subset, not all of C-RASP. It prevents malformed
+ expressions and forward references; a well-formed program can still compute the wrong
+ function. No transformer is trained here.
+
+
+ Position labels start at 0. Prefix counts include the current position. The learning
+ examples use nonempty inputs of at most 16 tokens; empty-word recognition is outside these
+ activities. Yes/no values are displayed as words rather than unexplained symbols.
+
+
+
+
+
diff --git a/site/runtime/bridge.py b/site/runtime/bridge.py
new file mode 100644
index 0000000..b117370
--- /dev/null
+++ b/site/runtime/bridge.py
@@ -0,0 +1,50 @@
+"""JSON adapter for the pinned coli-saar/c-rasp interpreter; not a replacement evaluator."""
+import json
+from antlr4 import InputStream, CommonTokenStream
+from antlr4.error.ErrorListener import ErrorListener
+from parser import CRaspLexer, CRaspParser, MyCraspVisitor
+
+class ParseError(ErrorListener):
+ def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
+ raise ValueError(f"Line {line}, column {column + 1}: {msg}")
+
+class JSONTrace:
+ def __init__(self):
+ self.rows = []
+ def accept_tokens(self, tokens):
+ self.tokens = list(tokens)
+ def accept(self, variable, value):
+ self.rows.append({"name": variable, "values": list(value)})
+ def accept_result(self, result):
+ self.result = result
+
+def run_trace(source, tokens):
+ if not 1 <= len(tokens) <= 128:
+ raise ValueError("Use between 1 and 128 tokens. Empty-input acceptance is not specified here.")
+ if len(source) > 12000:
+ raise ValueError("Program is limited to 12,000 characters.")
+ lexer = CRaspLexer(InputStream(source))
+ lexer.removeErrorListeners()
+ lexer.addErrorListener(ParseError())
+ stream = CommonTokenStream(lexer)
+ parser = CRaspParser(stream)
+ parser.removeErrorListeners()
+ parser.addErrorListener(ParseError())
+ tree = parser.program()
+ stream.fill()
+ if stream.LA(1) != -1:
+ raise ValueError("Unexpected trailing input in program.")
+ program = MyCraspVisitor().visit(tree)
+ if not program.statements:
+ raise ValueError("Add at least one assignment.")
+ if len(program.statements) > 64:
+ raise ValueError("Program is limited to 64 assignments.")
+ trace = JSONTrace()
+ try:
+ result = program.execute(tokens, tracing=trace)
+ except SystemExit as exc:
+ raise ValueError("The interpreter could not evaluate this program; check variable names and types.") from exc
+ if type(result) is not bool:
+ raise ValueError("A recognition program must end in a Boolean-valued assignment.")
+ return {"tokens": trace.tokens, "rows": trace.rows, "accepted": result,
+ "engine": "coli-saar/c-rasp", "index_base": 0}
diff --git a/site/runtime/python-worker.js b/site/runtime/python-worker.js
new file mode 100644
index 0000000..2727f75
--- /dev/null
+++ b/site/runtime/python-worker.js
@@ -0,0 +1,40 @@
+/* Runs the original Python interpreter in a disposable worker. */
+let ready;
+async function initialize() {
+ postMessage({ status: "Loading Python (first run downloads the runtime)…" });
+ importScripts("https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js");
+ const py = await loadPyodide();
+ await py.loadPackage("micropip");
+ await py.runPythonAsync(
+ "import micropip\nawait micropip.install('antlr4-python3-runtime==4.13.1')",
+ );
+ const manifest = await (await fetch("../data/interpreter.json")).json();
+ py.FS.mkdirTree("/crasp/antlr");
+ for (const path of manifest.files) {
+ const response = await fetch(`${manifest.raw_base}/${manifest.commit}/${path}`);
+ if (!response.ok)
+ throw new Error(`Could not load interpreter file ${path}: ${response.status}`);
+ py.FS.writeFile(`/crasp/${path}`, await response.text());
+ }
+ const response = await fetch("bridge.py");
+ if (!response.ok) throw new Error("Could not load interpreter adapter");
+ py.FS.writeFile("/crasp/bridge.py", await response.text());
+ await py.runPythonAsync(
+ "import sys\nsys.path.insert(0, '/crasp')\nfrom bridge import run_trace\nimport json",
+ );
+ return py;
+}
+onmessage = async ({ data }) => {
+ try {
+ ready ??= initialize();
+ const py = await ready;
+ postMessage({ status: "Running the Python interpreter…" });
+ py.globals.set("request_json", JSON.stringify(data));
+ const result = await py.runPythonAsync(
+ "request = json.loads(request_json)\njson.dumps(run_trace(request['source'], request['tokens']))",
+ );
+ postMessage({ result: JSON.parse(result) });
+ } catch (error) {
+ postMessage({ error: String(error.message || error) });
+ }
+};
diff --git a/site/text-editor.html b/site/text-editor.html
new file mode 100644
index 0000000..354e7e4
--- /dev/null
+++ b/site/text-editor.html
@@ -0,0 +1,157 @@
+
+
+
+
+
+ C-RASP text editor
+
+
+
+
+
+ C-RASP / LENGTH GENERALIZATIONBack to the block playground
+
+
+
+
02 / A program you can run
+
A C-RASP program for balanced parentheses
+
+
+ In Dyck-1, every opening parenthesis must eventually close, and no prefix may contain more
+ closes than opens. Checking only the totals would incorrectly accept
+ ())(.
+
+
+ The program below counts opens and closes, marks invalid prefixes, and accepts only when
+ no violation has occurred and the final counts agree. The symbol
+ # counts inclusively through the current token.
+
+
+ Using the example. The initial table is a saved execution of the
+ displayed program on ()(()). Each assignment produces one value per token.
+ Select an assignment name or use the arrows to highlight a row; select a count cell to
+ inspect its prefix. Edit the tokens or program and choose
+ Run Python interpreter to update the table. For comparison, ())( has
+ equal final counts but is rejected because an earlier prefix contains too many closes. The
+ other examples are available in the menu.
+
+
+
+ C-RASP notebookExecutable program · Python interpreter
+
+
+
+
+
+
+ Start with the balanced input, then compare it with an input that has the same totals
+ but an invalid prefix.
+
+
+
+
+
+
+
+
+
+
Reading the trace
+
+ Each row in the trace is a vector. The last value of the final Boolean row
+ determines acceptance.
+
+
+ Separate tokens with spaces. Parentheses can also be entered without spaces. This
+ tool evaluates 1–128 tokens; it does not define empty-word acceptance.
+
+
+ Uses the original
+ coli-saar/c-rasp interpreter, pinned at 1e821d7. It evaluates programs; it does not construct or
+ train transformer weights. Positions in this implementation start at 0.
+
+
+
+
+
+
+
+ Showing a saved trace generated by the pinned Python interpreter. Run to evaluate
+ edits.
+
+
Execution trace
+
+ Read left to right across input positions, and top to bottom through assignments. ✓
+ means true; · means false. Highlighting a row explains it below; it does not execute
+ the program again.
+
+
+
+
+
+
+
+
+ Interpreter values by token position
+
+
+
+
+
+ Select a count cell to inspect its contributing prefix.
+
+
+
+
+
The language behind the example
+
+ C-RASP has Boolean vectors and integer-valued vectors. Assignments are evaluated in
+ order. Available operations include token predicates, Boolean operations, prefix
+ counting, comparisons, addition, subtraction, min/max, and conditional selection.
+ Equality and strict inequalities can be expressed using ≤ and Boolean operations.
+
+
+ A fixed finite program specifies the same operations at every length. The browser
+ playground uses the supplied Python implementation, including its syntax and zero-based
+ positional predicates. The theoretical examples in the paper are often indexed from 1.
+
Version 2 · Experimental · Separate from main tutorial
+
C-RASP and length generalization
+
An interactive introduction to length generalization and C-RASP: length-independent programs that transformers can implement, and why that is not the same as learning to generalize.
+
Version 2 — drag-and-drop program editing · Does not replace the main tutorial
+
+
+
+
+
+
+
+
+
+
+ 01 / The question
+ Length generalization and C-RASP
+
+
+
+
A model can look accurate on short strings and fail when inputs get longer. Length generalization means: fit on lengths up to N, then stay correct on lengths beyond N.
+
A C-RASP program is a fixed list of assignments. Each assignment builds a vector with one entry per input position—either a yes/no (Boolean) or an integer (count). The number of lines does not depend on the input length: longer inputs only make those vectors longer. Yang and Chiang (2024) show such programs can be implemented by transformers; that is about what can be represented, not what training will find.
+
Keep three questions separate: representability, learning, and length generalization. Writing a C-RASP program answers only the first.
+
+
+
+
+
+
+
+
+ 02 / What is C-RASP
+ Programs, commands, and what “in C-RASP” means
+
+
+
+
C-RASP is a tiny programming language. A program is a fixed list of assignments. Each assignment builds a vector with one value per input position—either a yes/no (Boolean) or an integer (count). Longer inputs make those vectors longer; they do not add new lines to the program.
+
+
What “in C-RASP” means
+
Saying a task is in C-RASP (or C-RASP-expressible) means: there exists a C-RASP program that solves it, using only the commands below. That is a statement about representability—a transformer can implement that program. It is not a claim that training will find the program, or that a trained model will length-generalize.
+
+
The commands
+
Every line of a program is one of these operations (Yang & Chiang). In the tool, an input marker looks like "("; a prefix count looks like # "(".
+
+
+
Boolean vectors
+
+
Input marker — is this position token a?
+
Negation — not
+
Conjunction — and
+
Count comparison — e.g. ≤ between two counts
+
Constant — always true
+
+
+
+
Count vectors
+
+
Prefix count — # how often a Boolean holds so far
+
Conditional — if–then–else on counts
+
Add / subtract
+
Min / max
+
Constant — the integer 1
+
+
+
+
Shorthand such as ==, <, and 0 is derived from these primitives (e.g. equality via two ≤ checks).
+
+
How a program decides
+
Assignments run in order. Each line may use earlier vectors. For recognition, the program ends with a Boolean vector; the input is accepted exactly when that vector’s last position is true.
+
+
Example: Dyck-1
+
Correctly nested () needs two conditions: (1) no prefix has more closes than opens, and (2) the string ends balanced. Both matter—())( has equal totals but already closed too many in the middle. The program below writes those conditions with prefix counts, comparisons, and &&. Step through it on ( ).
+
+
+
+ The C-RASP program · running on ( )
+ Guided example
+
+
+
This box is the program. The highlighted line is the one introduced in the current step.
+
+
+
+
+
+
+
Each row is the vector produced by one program line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 03 / Try it yourself
+ Run the interpreter
+
+
+
+
Pick an example, set the input, then Run. The program view highlights the active line and names the C-RASP command it uses. Edit only if you want—use the pencil.
+
+
+
+ C-RASP notebook
+ Executable program · Python interpreter
+
+
+
+
+
+ Input tokens
+
+
+
+
+
+
+
+
Whitespace-separated tokens (parentheses may be typed without spaces). 1–128 tokens.
+
+
+
+ Program
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Three columns: pick a command, build your program, watch the view update as text. Drag left → middle (or click a command to append). Use ← → to undo/redo, restore for the initial program, then Commit to check it is well-formed and leave the builder.
+
+
+
Commands
+
Drag into Your program →
+
+
+
+
+
+
+
+
Also in C-RASP (Advanced text): +-min/max, if–then–else.
+
+
+ drag
+ →
+
+
+
Your program
+
Drop commands here
+
+
+
+
+ View
+
+
+
+
+
+
Commit checks syntax and that the interpreter can evaluate the program on the current input (last line must be Boolean).
+ Nothing has run yet. Press play to fill this area with the execution trace.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 04 / Related work
+ Papers if you want to dig in
+
+
+
+
These are the main references behind this page. Start with Yang & Chiang for C-RASP itself; Huang et al. for the length-generalization framework and experiments; Yang et al. (2026) for length-complexity bounds.
Andy Yang, Pascal Bergsträßer, Georg Zetzsche, David Chiang, and Anthony W. Lin (2026). Length Generalization Bounds for Transformers. Computability of length bounds for C-RASP and the positive fragment C-RASP₊.
Interpreter: coli-saar/c-rasp (ordinary C-RASP evaluation; this page does not train models).
+
+
+
+
+
+
+
+
diff --git a/site/version2/runtime/bridge.py b/site/version2/runtime/bridge.py
new file mode 100644
index 0000000..2bb47ac
--- /dev/null
+++ b/site/version2/runtime/bridge.py
@@ -0,0 +1,72 @@
+"""JSON adapter for the pinned coli-saar/c-rasp interpreter; not a replacement evaluator."""
+import json
+from antlr4 import InputStream, CommonTokenStream
+from antlr4.error.ErrorListener import ErrorListener
+from parser import CRaspLexer, CRaspParser, MyCraspVisitor
+
+class ParseError(ErrorListener):
+ def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
+ raise ValueError(f"Line {line}, column {column + 1}: {msg}")
+
+class JSONTrace:
+ def __init__(self):
+ self.rows = []
+ def accept_tokens(self, tokens):
+ self.tokens = list(tokens)
+ def accept(self, variable, value):
+ self.rows.append({"name": variable, "values": list(value)})
+ def accept_result(self, result):
+ self.result = result
+
+def parse_program(source):
+ if len(source) > 12000:
+ raise ValueError("Program is limited to 12,000 characters.")
+ lexer = CRaspLexer(InputStream(source))
+ lexer.removeErrorListeners()
+ lexer.addErrorListener(ParseError())
+ stream = CommonTokenStream(lexer)
+ parser = CRaspParser(stream)
+ parser.removeErrorListeners()
+ parser.addErrorListener(ParseError())
+ tree = parser.program()
+ stream.fill()
+ if stream.LA(1) != -1:
+ raise ValueError("Unexpected trailing input in program.")
+ program = MyCraspVisitor().visit(tree)
+ if not program.statements:
+ raise ValueError("Add at least one assignment.")
+ if len(program.statements) > 64:
+ raise ValueError("Program is limited to 64 assignments.")
+ return program
+
+def check_program(source, tokens=None):
+ """Parse (and optionally execute) to report whether the program is well-formed."""
+ program = parse_program(source)
+ out = {"ok": True, "statements": len(program.statements), "engine": "coli-saar/c-rasp"}
+ if tokens is None:
+ return out
+ if not 1 <= len(tokens) <= 128:
+ raise ValueError("Use between 1 and 128 tokens. Empty-input acceptance is not specified here.")
+ try:
+ result = program.execute(tokens, tracing=None)
+ except SystemExit as exc:
+ raise ValueError("The interpreter could not evaluate this program; check variable names and types.") from exc
+ if type(result) is not bool:
+ raise ValueError("A recognition program must end in a Boolean-valued assignment.")
+ out["accepted"] = result
+ out["ends_boolean"] = True
+ return out
+
+def run_trace(source, tokens):
+ if not 1 <= len(tokens) <= 128:
+ raise ValueError("Use between 1 and 128 tokens. Empty-input acceptance is not specified here.")
+ program = parse_program(source)
+ trace = JSONTrace()
+ try:
+ result = program.execute(tokens, tracing=trace)
+ except SystemExit as exc:
+ raise ValueError("The interpreter could not evaluate this program; check variable names and types.") from exc
+ if type(result) is not bool:
+ raise ValueError("A recognition program must end in a Boolean-valued assignment.")
+ return {"tokens": trace.tokens, "rows": trace.rows, "accepted": result,
+ "engine": "coli-saar/c-rasp", "index_base": 0}
diff --git a/site/version2/runtime/python-worker.js b/site/version2/runtime/python-worker.js
new file mode 100644
index 0000000..17db80d
--- /dev/null
+++ b/site/version2/runtime/python-worker.js
@@ -0,0 +1,41 @@
+/* Runs the original Python interpreter in a disposable worker. */
+let ready;
+async function initialize() {
+ postMessage({status: 'Loading Python (first run downloads the runtime)…'});
+ importScripts('https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js');
+ const py = await loadPyodide();
+ await py.loadPackage('micropip');
+ await py.runPythonAsync("import micropip\nawait micropip.install('antlr4-python3-runtime==4.13.1')");
+ const manifest = await (await fetch('../data/interpreter.json')).json();
+ py.FS.mkdirTree('/crasp/antlr');
+ for (const path of manifest.files) {
+ const response = await fetch(`${manifest.raw_base}/${manifest.commit}/${path}`);
+ if (!response.ok) throw new Error(`Could not load interpreter file ${path}: ${response.status}`);
+ py.FS.writeFile(`/crasp/${path}`, await response.text());
+ }
+ const response = await fetch('bridge.py');
+ if (!response.ok) throw new Error('Could not load interpreter adapter');
+ py.FS.writeFile('/crasp/bridge.py', await response.text());
+ await py.runPythonAsync("import sys\nsys.path.insert(0, '/crasp')\nfrom bridge import run_trace, check_program\nimport json");
+ return py;
+}
+onmessage = async ({data}) => {
+ try {
+ ready ??= initialize();
+ const py = await ready;
+ py.globals.set('request_json', JSON.stringify(data));
+ if (data.mode === 'check') {
+ postMessage({status: 'Checking whether the program is well-formed…'});
+ const result = await py.runPythonAsync(
+ "request = json.loads(request_json)\njson.dumps(check_program(request['source'], request.get('tokens')))"
+ );
+ postMessage({check: JSON.parse(result)});
+ return;
+ }
+ postMessage({status: 'Running the Python interpreter…'});
+ const result = await py.runPythonAsync("request = json.loads(request_json)\njson.dumps(run_trace(request['source'], request['tokens']))");
+ postMessage({result: JSON.parse(result)});
+ } catch (error) {
+ postMessage({error: String(error.message || error)});
+ }
+};
diff --git a/tests/data.test.mjs b/tests/data.test.mjs
new file mode 100644
index 0000000..760aab5
--- /dev/null
+++ b/tests/data.test.mjs
@@ -0,0 +1,49 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { tokenize, validateEvaluations } from "../site/assets/lib.js";
+const fixture = () => ({
+ schema_version: 1,
+ runs: [
+ {
+ id: "test-only",
+ task: "test fixture",
+ model: "fixture",
+ positional_encoding: "none",
+ seed: 0,
+ checkpoint: "fixture",
+ evaluated_at: "2026-10-19T00:00:00Z",
+ source: "test only",
+ train_max_length: 10,
+ metric: "sequence_accuracy",
+ points: [{ length: 20, correct: 0, total: 5 }],
+ },
+ ],
+});
+test("tokenization preserves multi-character tokens and handles compact parentheses", () => {
+ assert.deepEqual(tokenize(" a word "), ["a", "word"]);
+ assert.deepEqual(tokenize("()"), ["(", ")"]);
+ assert.throws(() => tokenize(""));
+ assert.throws(() => tokenize("a ".repeat(129)));
+});
+test("zero observed correct is distinct from missing counts", () => {
+ assert.equal(validateEvaluations(fixture()).runs[0].points[0].correct, 0);
+ for (const v of [null, undefined, -1, 6, NaN]) {
+ const f = fixture();
+ f.runs[0].points[0].correct = v;
+ assert.throws(() => validateEvaluations(f));
+ }
+});
+test("rejects duplicate lengths and incompatible metrics", () => {
+ const f = fixture();
+ f.runs[0].points.push({ ...f.runs[0].points[0] });
+ assert.throws(() => validateEvaluations(f));
+ const g = fixture();
+ g.runs[0].metric = "token_accuracy";
+ assert.throws(() => validateEvaluations(g));
+});
+test("empty gallery is valid and selection range cannot precede training", () => {
+ assert.doesNotThrow(() => validateEvaluations({ schema_version: 1, runs: [] }));
+ const f = fixture();
+ f.runs[0].selection_max_length = 5;
+ assert.throws(() => validateEvaluations(f));
+});
diff --git a/tests/notebook.test.mjs b/tests/notebook.test.mjs
new file mode 100644
index 0000000..1196790
--- /dev/null
+++ b/tests/notebook.test.mjs
@@ -0,0 +1,68 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ DYCK,
+ CONTAINS,
+ BOTH_SYMBOLS,
+ A_BEFORE_B,
+ compile,
+ validateProgram,
+ moveBlock,
+} from "../site/assets/notebook-model.js";
+import { evaluateBlocks } from "../site/assets/notebook-engine.js";
+
+test("block editor rejects undefined and incorrectly typed inputs", () => {
+ assert.throws(() => compile([{ id: "Count", kind: "count", input: "Missing" }]), /earlier/);
+ assert.throws(
+ () =>
+ compile([
+ { id: "N", kind: "tokenCount", token: "a" },
+ { id: "Bad", kind: "count", input: "N" },
+ ]),
+ /earlier/,
+ );
+ assert.throws(() => compile(CONTAINS, "A2"), /yes\/no/);
+ assert.throws(
+ () => compile([{ id: "X", kind: "token", token: '"; arbitrary' }]),
+ /supported token/,
+ );
+ assert.throws(() => compile([{ id: "Result", kind: "token", token: "a" }]), /names/);
+});
+test("dependency-violating reorder is rejected without mutating the program", () => {
+ const before = structuredClone(DYCK);
+ assert.throws(() => moveBlock(DYCK, 3, 0), /earlier/);
+ assert.deepEqual(DYCK, before);
+ const moved = moveBlock(DYCK, 1, 0);
+ assert.equal(moved[0].id, "Closes");
+ assert.doesNotThrow(() => validateProgram(moved));
+});
+test("generated output is separate from exploratory number vectors", () => {
+ assert.equal(compile([]), "Result = (0 == 0)");
+ assert.match(compile(CONTAINS, "A3"), /Result = A3$/);
+ assert.match(compile(DYCK, "Accept"), /Accept = Safe && Balanced/);
+});
+
+test("prefix counting is inclusive and respects causality", () => {
+ const trace = evaluateBlocks(DYCK, [..."()(())"], "Accept");
+ assert.deepEqual(trace.rows[0].values, [1, 1, 2, 3, 3, 3]);
+ assert.equal(trace.accepted, true);
+ const changed = evaluateBlocks(DYCK, [..."()(()("], "Accept");
+ for (let i = 0; i < trace.rows.length; i++)
+ assert.deepEqual(trace.rows[i].values.slice(0, 5), changed.rows[i].values.slice(0, 5));
+ assert.equal(changed.accepted, false);
+ assert.equal(evaluateBlocks(DYCK, [..."())(()"], "Accept").accepted, false);
+});
+test("empty and unsupported inputs are rejected", () => {
+ assert.throws(() => evaluateBlocks(DYCK, [], "Accept"), /1–16/);
+ assert.throws(() => evaluateBlocks(DYCK, ["x"], "Accept"), /1–16/);
+ assert.throws(() => evaluateBlocks(DYCK, Array(17).fill("("), "Accept"), /1–16/);
+});
+
+test("general starters separate presence from order", () => {
+ for (const input of ["ab", "ba"])
+ assert.equal(evaluateBlocks(BOTH_SYMBOLS, [...input], "Accept").accepted, true);
+ assert.equal(evaluateBlocks(BOTH_SYMBOLS, [..."aa"], "Accept").accepted, false);
+ assert.equal(evaluateBlocks(A_BEFORE_B, [..."ab"], "Accept").accepted, true);
+ assert.equal(evaluateBlocks(A_BEFORE_B, [..."ba"], "Accept").accepted, false);
+ assert.equal(evaluateBlocks(A_BEFORE_B, [..."a()b"], "Accept").accepted, true);
+});
diff --git a/tutorial_dual/.gitignore b/tutorial_dual/.gitignore
new file mode 100644
index 0000000..7d17e0f
--- /dev/null
+++ b/tutorial_dual/.gitignore
@@ -0,0 +1,6 @@
+/public/
+/.public-publish/
+/.vendor-vendor/
+/.tola/
+/.tola-build.lock
+.DS_Store
diff --git a/tutorial_dual/.ignore b/tutorial_dual/.ignore
new file mode 100644
index 0000000..7d17e0f
--- /dev/null
+++ b/tutorial_dual/.ignore
@@ -0,0 +1,6 @@
+/public/
+/.public-publish/
+/.vendor-vendor/
+/.tola/
+/.tola-build.lock
+.DS_Store
diff --git a/tutorial_dual/README.md b/tutorial_dual/README.md
new file mode 100644
index 0000000..c58f2b5
--- /dev/null
+++ b/tutorial_dual/README.md
@@ -0,0 +1,86 @@
+# One tutorial, two export modes
+
+**`manuscript.typ` is the only article source. Edit that file for both exports.**
+There are no separate PDF and HTML copies of the prose. The small entry-point
+files supply layout or metadata and include the same manuscript:
+
+```text
+manuscript.typ
+ ├── ../draft_2.typ → PDF layout → draft_2.pdf
+ └── content/index.typ → Tola HTML layout → public/index.html
+```
+
+- `../draft_2.typ`: PDF entry point, numbered headings, contents, page numbers,
+ and every detailed explanation fully visible.
+- `content/index.typ` and `site/page.typ`: Tola HTML entry point and blog layout.
+ Detailed explanations are initially collapsed. Each can be toggled individually;
+ the top buttons expand or collapse all of them. These use native HTML `details`
+ elements, so individual toggles also work without JavaScript.
+- `components.typ`: the `details[Title][Content]` helper and the interaction slot.
+ To add another optional explanation, wrap an existing passage with that helper.
+
+From the repository root:
+
+```sh
+./build_tutorial.sh pdf # writes draft_2.pdf
+./build_tutorial.sh html # writes tutorial_dual/public/index.html + assets
+./build_tutorial.sh both # default: build both
+./build_tutorial.sh check # Tola's full compilation and reference checks
+./build_tutorial.sh preview # prints a local URL and rebuilds on edits; Ctrl-C stops
+```
+
+The build script uses `TYPST_BIN` / `TOLA_BIN` overrides when set, then tools on
+PATH, then binaries under `.tools/` in this checkout or its parent directories.
+This also finds the workspace tools when this checkout is nested inside it.
+Verified with **Typst 0.15.1** and **Tola 0.8.1**. Tola embeds its own compiler;
+Typst CLI is only needed for the PDF. There are no external Typst package imports
+in this new version. Keep `tola.toml` and the `@tola` imports with Tola 0.8.x;
+the older 0.7-era README examples use a different configuration API.
+
+To view a built site without the development watcher:
+
+```sh
+python3 -m http.server 8000 --bind 127.0.0.1 --directory tutorial_dual/public
+```
+
+Open http://127.0.0.1:8000/. Use HTTP for the blog: opening an HTML file directly
+from the filesystem prevents JavaScript modules from loading in many browsers.
+The complete `public/` directory is the static blog export; keep its assets with it.
+No hosting or deployment is configured.
+
+## Where the interaction belongs
+
+The original notebook is embedded immediately after the worked Dyck-1 recognizer
+and the `())(` counterexample, before the complete operation reference. It contains
+the walkthrough, block builder, repair exercise, token sandbox, and value prediction.
+The parent page hides the notebook's duplicate introduction and resizes its frame.
+The standalone notebook and source attribution remain linked below it.
+
+The PDF omits this interaction slot. The derivation and all detailed explanations
+remain in the PDF.
+
+The prose uses positions 1 through n; the existing notebook uses zero-based labels.
+This difference is stated beside the embed. In the token sandbox, remove the last
+two tokens of `()(())` and flip position 2 to obtain `())(`. The first invalid prefix
+is position 2 in the notebook, corresponding to position 3 in the prose.
+
+The notebook assets are vendored locally under `static/web-assets/interactive/`;
+see `UPSTREAM.md` for provenance. Its core notebook works without network access.
+The optional upstream Python text editor downloads its runtime separately.
+
+## Checks
+
+The PDF was compiled and its rendered layout inspected. The Tola build and full
+site check pass. Browser checks cover opening and closing the detail sections,
+notebook execution on a balanced input, and rejection of the equal-count counterexample.
+Existing upstream tests cover typing/dependencies and inclusive prefix evaluation.
+
+```sh
+# Use Node >= 20 (or the workspace-local Node binary).
+node --test tutorial_dual/tests/*.test.mjs
+```
+
+The examples have been organized: the examples progress from block order
+to equal blocks and balanced prefixes; compilation details are split into four folds;
+and the length-generalization section separates the setup, learner, theorem, and
+consequences.
diff --git a/tutorial_dual/UPSTREAM.md b/tutorial_dual/UPSTREAM.md
new file mode 100644
index 0000000..808673e
--- /dev/null
+++ b/tutorial_dual/UPSTREAM.md
@@ -0,0 +1,12 @@
+# Interactive tutorial source
+
+Vendored from https://github.com/lacoco-lab/Length_Generalization_Tutorial/tree/blog/interactive-tutorial/site
+on 2026-10-29 (branch `blog/interactive-tutorial`). The notebook and its static assets
+are copied without modification. `static/web-assets/scripts/article.js` adapts the
+same-origin notebook presentation when embedded; its full standalone page remains available.
+
+The article retains its one-based mathematical positions; the notebook labels positions
+starting at zero and demonstrates a small typed C-RASP subset. It does not train a transformer.
+
+The downloaded repository did not contain a LICENSE file. No license is asserted here;
+retain upstream attribution when reusing or publishing these assets.
diff --git a/tutorial_dual/components.typ b/tutorial_dual/components.typ
new file mode 100644
index 0000000..bf3e976
--- /dev/null
+++ b/tutorial_dual/components.typ
@@ -0,0 +1,89 @@
+#let c-rasp-editor-url = "https://github.com/lacoco-lab/Length_Generalization_Tutorial/tree/blog/interactive-tutorial/site/version2"
+
+// Shared authoring helpers: HTML folds details; PDF always includes the full text.
+#let details(title, body) = context {
+ if target() == "html" {
+ html.elem("details", attrs: (class: "explanation"))[
+ #html.elem("summary", title)
+ #html.elem("div", attrs: (class: "explanation-body"), body)
+ ]
+ } else {
+ block(
+ width: 100%,
+ above: 0.9em,
+ below: 1em,
+ inset: 10pt,
+ fill: rgb("f1f6f3"),
+ stroke: 0.6pt + rgb("c5d7cd"),
+ radius: 4pt,
+ breakable: true,
+ )[
+ #block(below: 0.5em, sticky: true)[
+ #text(fill: rgb("17635e"), weight: "bold", title)
+ ]
+ #body
+ ]
+ }
+}
+
+#let operation-columns(..columns) = context {
+ if target() == "html" {
+ html.elem("div", attrs: (class: "operation-columns"))[
+ #for column in columns.pos() { html.elem("div", column) }
+ ]
+ } else {
+ grid(columns: (1fr, 1fr), gutter: 1em, ..columns.pos())
+ }
+}
+
+#let interactive-notebook() = context {
+ if target() == "html" {
+ html.elem("section", attrs: (class: "interactive", "aria-label": "Explore the Dyck-1 program"))[
+ #heading(level: 3)[Try the Dyck-1 program]
+ Use the token sandbox to change `()(())` to `())(` and inspect the first prefix violation.
+ Then try the block builder or repair a rule.
+
+ #html.elem("p", attrs: (class: "small"))[
+ The notebook labels positions starting at 0; the equations above start at 1.
+ It evaluates a small typed C-RASP subset in your browser.
+ ]
+ // Relative assets keep this page portable under a hosting subdirectory.
+ #html.elem("iframe", attrs: (
+ src: "assets/interactive/notebook.html",
+ title: "Interactive C-RASP walkthrough and block builder",
+ loading: "lazy",
+ class: "notebook-frame",
+ height: "1100",
+ ))[]
+ #html.elem("p")[
+ #link("assets/interactive/notebook.html")[Open the notebook on its own]
+ #linebreak()
+ #link("https://github.com/lacoco-lab/Length_Generalization_Tutorial/tree/blog/interactive-tutorial")[Interactive tutorial source]
+ ]
+ ]
+ } else {
+ [*Try it yourself.* The #link(c-rasp-editor-url)[drag-and-drop C-RASP editor (source and launch instructions)] lets you build programs and inspect their values on sample inputs. It evaluates C-RASP programs; compilation into transformer weights is the separate construction discussed below.]
+ }
+}
+
+// A visible explanatory box in both output modes.
+#let notation-box(body) = context {
+ if target() == "html" {
+ html.elem("aside", attrs: (class: "notation-box", "aria-label": "Semantics of prefix counting"), body)
+ } else {
+ block(width: 100%, inset: 10pt, stroke: 0.6pt + luma(190),
+ fill: luma(248), radius: 4pt, breakable: false, body)
+ }
+}
+
+// Keep the operation reference spacious in print without changing the HTML tables.
+#let operation-table(..cells) = context {
+ if target() == "html" {
+ table(columns: (auto, 1fr), row-gutter: 0pt, ..cells.pos())
+ } else {
+ set text(size: 9.5pt)
+ set par(justify: false)
+ table(columns: (0.85fr, 2fr), row-gutter: 0pt, inset: (x: 6pt, y: 10pt),
+ align: left + horizon, stroke: 0.5pt + luma(205), ..cells.pos())
+ }
+}
diff --git a/tutorial_dual/content/index.typ b/tutorial_dual/content/index.typ
new file mode 100644
index 0000000..6869ad7
--- /dev/null
+++ b/tutorial_dual/content/index.typ
@@ -0,0 +1,6 @@
+#import "@tola/source:0.0.0": tola-meta
+#tola-meta((
+ title: "Length Generalization in Transformers",
+ description: "C-RASP, transformer expressivity, and length generalization, with an interactive Dyck-1 notebook.",
+))
+#include "../manuscript.typ"
diff --git a/tutorial_dual/manuscript.typ b/tutorial_dual/manuscript.typ
new file mode 100644
index 0000000..ddfbebf
--- /dev/null
+++ b/tutorial_dual/manuscript.typ
@@ -0,0 +1,548 @@
+#import "components.typ": details, operation-columns, interactive-notebook, notation-box, operation-table, c-rasp-editor-url
+
+= Motivation
+
+We want models to apply what they learn to problems larger than those encountered during training. A model trained on short sequences might later need to copy a longer passage, count events across a longer record, or check the structure of a larger program. When the underlying task stays the same, can the model carry out the same computation on inputs longer than the training examples? This is the problem of *length generalization*.
+
+One motivation is to make better use of training data and computation. Long examples can be costly to collect, annotate, and process, and training cannot cover every input length a model may encounter. If shorter examples are sufficient to learn a reusable rule, we may reduce the need for long training examples while still handling longer inputs at test time. This does not necessarily mean that fewer training examples are needed overall: generalizing across lengths and learning from fewer samples are distinct goals.
+
+Length generalization also asks whether the learned computation continues to work as the task grows. Counting, copying, and checking nested structure provide simple settings in which the same rule applies across lengths. A larger context window allows a model to receive longer inputs, but does not ensure that it processes them correctly. Throughout this tutorial, we therefore distinguish the lengths seen during training from the maximum length the model can process.
+
+To understand what a transformer can learn from shorter inputs, we separate three questions:
+
++ *Representability:* Does there exist a transformer that computes the desired function across input lengths?
++ *Learnability:* Can a training procedure find a model that computes the function on the training distribution?
++ *Length-generalizable learnability:* Does training on bounded lengths yield a model that also computes the function on longer inputs?
+
+A positive answer to one question does not settle the next. Constructing suitable weights establishes that a computation is possible, but does not show that training will find them. Likewise, perfect accuracy on shorter inputs does not establish length generalization. For example, a rule that checks whether parentheses are correctly nested only up to the maximum training length can agree with the desired recognizer on every training input and fail on every longer valid string. The observations alone do not distinguish that rule from a recognizer that works at all lengths. We therefore need to understand which computations the architecture can represent and which solutions the learning procedure favors.
+
+Our main tool is *C-RASP*, a small programming language for computations over sequences. Its programs combine predicates on positions with counts over prefixes. A fixed program describes the same computation at every input length, making it possible to state an algorithm without choosing a maximum sequence length. C-RASP also connects these algorithms to transformers: prefix counts can be represented using attention, while feedforward layers implement comparisons and Boolean operations. This connection gives constructive expressivity results; it does not by itself establish a learning guarantee.
+
+We first develop C-RASP through a worked parentheses recognizer and examples of what the language can and cannot express. We then explain its compilation into transformers and the role of positional information. This leads to the length-generalization framework of #link()[Huang et al. (2025)], which connects suitable representations to eventual generalization under an idealized inference procedure. We keep this guarantee separate from ordinary gradient-based training and discuss what it does, and does not, tell us about the training lengths required. The final sections outline how additional computation through chain of thought changes the question and which limitations remain open.
+
+
+= Background
+
+== Computational complexity
+
+Complexity classes describe which problems can be solved with specified computational resources. For transformers, *circuit complexity* is useful because it separates the amount of computation performed in parallel from the number of sequential stages.
+
+A Boolean circuit is a directed acyclic graph whose gates compute Boolean operations. Its *size* is the number of gates; its *depth* is the longest path from an input to the output. Gates at the same depth can be evaluated in parallel. To recognize inputs of arbitrary length, we use a family of circuits $(C_n)_(n >= 1)$, with one circuit for each input length $n$. Polynomial size means at most $n^{O(1)}$ gates, while constant depth means a bound independent of $n$. We use logspace-uniform families: one algorithm constructs $C_n$ from $n$ using $O(log n)$ working space, rather than choosing unrelated circuits separately at every length (#link()[Immerman, 1999, Chapter 5]).
+
+*Three circuit classes.* The following classes all require polynomial size but differ in their gates and depth:
+
+- *$#math.sans("AC")^0$:* Constant-depth circuits with AND, OR, and NOT gates. AND and OR have unbounded fan-in: a single gate may combine arbitrarily many inputs. For example, testing whether a string contains an $a$ needs only an OR over the input indicators. Parity cannot be computed in this class.
+- *$#math.sans("TC")^0$:* Constant-depth circuits that additionally allow unbounded-fan-in majority gates. A majority gate tests whether more than half its inputs are true; this gives the same class as allowing unweighted threshold gates. This class contains both majority and parity.
+- *$#math.sans("NC")^1$:* Circuits of depth $O(log n)$ with AND and OR gates of fan-in at most two, and NOT gates. The additional depth allows information to be combined through a tree. For example, a balanced tree of constant-size XOR circuits computes parity in logarithmic depth.
+
+The classes satisfy
+
+$ #math.sans("AC")^0 subset #math.sans("TC")^0 subset.eq #math.sans("NC")^1. $
+
+The first inclusion is strict; whether the second is strict remains open. Thus, constant depth alone does not determine expressive power: the permitted gates matter as well (#link()[Viola, 2023, Chapter 10]).
+
+*Turing-computable functions.* A total function is Turing-computable if a single Turing machine halts and returns its value on every finite input. Unlike the circuit classes above, this imposes no polynomial time or small-depth bound. For a language, the corresponding notion is decidability: the machine must halt with the correct acceptance decision on every input. Computability asks whether an algorithm exists; complexity asks which resources it needs.
+
+*Why these classes matter for transformers.* A fixed number of transformer layers suggests a bounded number of parallel stages, but a circuit simulation must also account for arithmetic precision and the operations within each layer. With $O(log n)$-bit arithmetic and feedforward maps computable in space linear in their input representation, a fixed transformer forward pass can be simulated in $#math.sans("TC")^0$ (#link()[Merrill and Sabharwal, 2023]). Claims of Turing completeness concern a different resource regime and must specify how computation can grow, for example through additional generated tokens or iterations. Neither a circuit upper bound nor Turing completeness establishes that training will learn a solution that generalizes to longer inputs.
+
+== Formal languages and sequence tasks
+
+We use a small set of tasks to study which computations transformers can represent and learn across input lengths. Their rules are easy to state, but involve different operations: counting, tracking order, checking nested structure, and reproducing a sequence. They will serve as running examples throughout the tutorial.
+
+*Language recognition.* A formal language is a set of finite strings over an alphabet $Sigma$. A recognizer decides whether an input string belongs to that set. We consider:
+
+- *Parity:* Accept a string over $\{a,b\}$ exactly when it contains an even number of $a$ symbols. Encoding $a$ as 1 and $b$ as 0 gives the usual bit-parity problem. For example, `aba` is accepted and `abb` is rejected.
+- *Dyck-1:* Accept correctly nested parentheses of a single type. Every prefix must have at least as many opening as closing parentheses, and the final counts must agree. For example, `()(())` is accepted, while `())(` is rejected despite having equal final counts.
+- *Dyck with multiple bracket types:* Require both correct nesting and matching bracket types. For example, `([])` is accepted, while `([)]` is rejected. Unlike Dyck-1, counting opening and closing brackets alone cannot check that their types match.
+- *Flip-flop:* For a string over $\{a,b,e\}$, accept exactly when the last non-$e$ symbol is $b$. The symbol $e$ is neutral: appending it leaves the decision unchanged. For example, `abee` is accepted and `baee` is rejected. Strings with no $a$ or $b$, including the empty string, are rejected.
+
+*Sequence generation.* Copying asks the model to produce an output sequence rather than an acceptance decision. After reading an input and a separator, it must reproduce the input in the same order. Following #link()[Huang et al. (2025, Appendix E.2.1)], an example is `SOS b a b SEP b a b EOS`. The paper distinguishes _Copy (repeat)_, with inputs over a binary alphabet that may contain repeated symbols, from _Copy (unique)_, in which all input tokens are distinct. Training loss is applied to the copied sequence and the final `EOS` token.
+
+== Transformer models
+
+We follow the causal transformer model of #link()[Huang et al. (2025, Section 2)]. Its main design choices are:
+
+#table(
+ columns: (1.2fr, 4fr),
+ table.header([*Choice*], [*Setting*]),
+ [Positions], [NoPE or learned APE.],
+ [Attention], [Causal softmax, with logits scaled by $log m$.],
+ [Blocks], [Residual attention and feedforward layers; fixed depth, width, and head count.],
+ [Activations], [ReLU or Heaviside.],
+ [Normalization], [LayerNorm omitted from the formal model.],
+ [Precision], [Rounded attention logits and exponentials; other activations may have unbounded precision.],
+ [Readout], [Final-position logits for recognition.],
+)
+
+Here $m$ includes the beginning-of-sequence token. The details below specify the computation and distinguish the formal model from the experiments.
+
+
+#details[Transformer equations and numerical assumptions][
+
+*Inputs and positions.* Let $w = w_1 dots w_n$ be a word over a finite alphabet $Sigma$. We prepend a special beginning-of-sequence token, $w_0 = "BOS"$, and write $m = n+1$ for the total sequence length. At each position $i$, the model maintains a vector $h_i^(ell) in RR^d$ after layer $ell$. The initial vector is
+
+$ h_i^(0) = e(w_i) + p_i, quad 0 <= i <= n, $
+
+where $e$ maps token identities to learned vectors. The optional vector $p_i$ supplies explicit positional information:
+
+- With *no positional encodings (NoPE)*, $p_i = 0$ at every position. Positions can nevertheless have different representations because causal attention gives them access to different prefixes.
+- With *absolute positional encodings (APE)*, each supported position has a learned vector $p_i$. The positional table is finite, so a fixed model supports only a finite context window. We discuss how offsets and increasing context lengths enter the generalization framework below.
+
+*Causal attention.* A model has $L$ layers and $H$ attention heads per layer. Within layer $ell$, head $h$ applies learned linear maps to the previous layer's vectors:
+
+$ q_i = Q_(ell,h) h_i^(ell-1), quad k_j = K_(ell,h) h_j^(ell-1), quad v_j = V_(ell,h) h_j^(ell-1). $
+
+The query $q_i$ describes what position $i$ uses to score other positions; the key $k_j$ supplies the features against which it is scored. The value $v_j$ is the information that the head aggregates. Suppressing the layer and head indices, the computation is
+
+$ a_(i,j) = q_i dot k_j, $
+
+$ alpha_(i,j) = frac(exp(s_m a_(i,j)), sum_(t=0)^i exp(s_m a_(i,t))), quad j <= i, $
+
+$ z_i = sum_(j=0)^i alpha_(i,j) v_j. $
+
+The causal mask excludes positions $j > i$: a position can attend to BOS, itself, and earlier tokens, but not to future tokens. All queries, keys, and values in a layer come from the previous layer; the prefix restriction does not turn a layer into a left-to-right recurrent computation. The weights are nonnegative and sum to 1 over the visible prefix, so a head returns a weighted average of its values.
+
+*Logit scaling and uniform attention.* Ordinary softmax attention uses $s_m = 1$. Huang et al. instead use $s_m = log m$, making the scale depend explicitly on sequence length. For positive $s_m$, the ratio between two attention weights is
+
+$ frac(alpha_(i,j), alpha_(i,t)) = exp(s_m (a_(i,j) - a_(i,t))). $
+
+Thus scaling changes how strongly the head distinguishes unequal scores. If all visible scores are equal, however, every visible position receives weight $1/(i+1)$. This is the uniform attention used later to represent prefix counts. The usual fixed factor $1/sqrt(d_k)$, where $d_k$ is the query/key dimension, can be absorbed into the learned query or key matrices. These equations display the algebraic operations; the rounding convention is stated below.
+
+*Residual and feedforward updates.* Each head contributes a vector in the model's width-$d$ space; its value map incorporates the required output projection. The head outputs are summed and added to the current representation:
+
+$ u_i^(ell) = h_i^(ell-1) + sum_(h=1)^H z_i^(ell,h). $
+
+The feedforward update is
+
+$ h_i^(ell) = u_i^(ell) + B_ell psi_ell (A_ell u_i^(ell) + b_ell). $
+
+Here $A_ell$ maps into the feedforward hidden space, $b_ell$ is a bias vector, and $B_ell$ maps back to width $d$. The activation $psi_ell$ acts coordinatewise. Each coordinate may use ReLU,
+
+$ "ReLU"(x) = max(0,x), $
+
+or Heaviside,
+
+$ "H"(x) = cases(1 & "if" x >= 0, 0 & "if" x < 0). $
+
+The feedforward map uses the same parameters at every position within a layer. It does not itself combine different positions; that communication occurs through attention. The residual additions retain the preceding representation while adding the attention and feedforward contributions.
+
+*LayerNorm.* The equations above omit normalization, as does Huang et al.'s main formal model. Their experiments include LayerNorm. Appendix D.3 sketches how to incorporate normalization into the limit-transformer simulation by retaining additional information from which activation norms can be recovered. We therefore distinguish the stated formal model from its proposed extension with normalization. Yang and Chiang's original C-RASP compilation uses LayerNorm explicitly; we return to its role alongside that construction.
+
+*Numerical precision.* Huang et al. round attention logits and the outputs of the exponential function to a fixed number $p$ of fractional bits before further processing. Rounding to $p$ fractional bits restricts these quantities to multiples of $2^(-p)$; it does not impose a fixed total bit budget on every number in the computation. Other activations may retain unbounded precision. In particular, this model differs from one that rounds every stored activation and arithmetic result to a fixed number of bits. The circuit upper bound discussed next has its own precision assumptions and should not be transferred to this model without checking them.
+
+*Output and computation budget.* After $L$ layers, a learned linear map produces the output logits
+
+$ o_i = U h_i^(L). $
+
+For recognition, the output at the final input position $i = n$ determines acceptance; for example, a binary readout can compare acceptance and rejection logits. A single forward pass applies the $L$ layers once. For a fixed transformer, its parameters, depth, width, and head count do not grow when the input gets longer. Autoregressive generation instead uses the output to append a token and runs another forward pass, introducing additional computation that we consider separately in the chain-of-thought section.
+
+]
+
+=== Forward-pass Expressivity
+
+We first ask which languages a fixed transformer can recognize in a single forward pass. Its depth $L$, width $d$, and number of heads $H$ are independent of the input length. Increasing the input length adds positions to the computation, while the number of layers remains fixed.
+
+The circuit classes introduced in #link()[the complexity background] make this parallel structure precise. #link()[Merrill and Sabharwal (2023)] show that transformers using $O(log n)$ bits of arithmetic precision, with feedforward maps computable in space linear in their input representation, can be simulated by logspace-uniform $#math.sans("TC")^0$ circuits. This is an upper bound on their forward-pass expressivity, not a claim that every such circuit can be represented by a transformer.
+
+The precision assumption matters: Huang et al.'s model permits unbounded-precision activations, so this circuit bound does not apply to it directly. Nor does the bound characterize C-RASP: for example, $#math.sans("TC")^0$ contains parity, whereas C-RASP does not. We consider autoregressive generation, which runs further forward passes to generate additional tokens, in the chain-of-thought section.
+
+= C-RASP
+
+== Motivation
+- Why introduce C-RASP?
+- What does it abstract away / capture?
+
+== Definition
+
+A C-RASP program is a fixed finite sequence of assignments defining vectors indexed by input positions. Boolean-valued vectors represent predicates on positions; count-valued vectors assign an integer to each position. Assignments are evaluated in sequence, each defining a whole vector from the input and previously defined vectors. Both the number of assignments and the permitted operations are independent of the input length.
+
+Our running example is Dyck-1, the language of correctly nested parentheses over the alphabet $Sigma = {"(", ")"}$. We will trace the program on the input `()(())`.
+
+*Input representation.* We assume that inputs are words over a finite alphabet $Sigma$, corresponding to tokens in a transformer. For each character $a in Sigma$, we create an initial Boolean vector $Q_a in \{0,1\}^n$, where $n$ is the input length. Its entry $Q_(a)(i)$ is 1 if the input character at position $i$ is $a$, and 0 otherwise. In other words, each $Q_a$ marks all the positions containing the character $a$.
+
+The two input-marker vectors for our example are:
+
+$ Q_"(" = [1, 0, 1, 1, 0, 0], $
+
+$ Q_")" = [0, 1, 0, 0, 1, 1]. $
+
+Both kinds of vectors have one entry per input position, but their entries mean different things. A Boolean-valued vector answers a yes-or-no question at each position: for example, $Q_("(")(i)$ says whether position $i$ contains an opening parenthesis.
+A count-valued vector instead stores a number at each position: $C_("(")(i)$ tells us how many opening parentheses have appeared from the start through position $i$. Thus, $Q_"("$ marks individual tokens, while $C_"("$ summarizes prefixes. Prefix counting turns a Boolean predicate into counts; comparing counts, as in $V(i)$ below, produces a Boolean predicate again.
+
+We now combine these markers into a recognizer.
+
+#block[
+ #set math.equation(numbering: "(1)")
+
+*Prefix counts.* First, count how many opening and closing parentheses have appeared up to each position. The notation $"#" [j <= i] P(j)$ counts the positions $j$ up to $i$ where the predicate $P$ is true:
+
+$ C_("(")(i) := "#" [j <= i] Q_("(")(j) $
+
+$ C_(")")(i) := "#" [j <= i] Q_(")")(j) $
+
+For $()(())$, these vectors are $C_"(" = [1, 1, 2, 3, 3, 3]$ and $C_")" = [0, 1, 1, 1, 2, 3]$. Each entry records the count at the corresponding prefix.
+
+*Prefix violations.* A valid string must never close more parentheses than it has opened. Mark each position where this condition fails:
+
+$ V(i) := C_("(")(i) < C_(")")(i) $
+
+For $()(())$, no prefix has more closing than opening parentheses, so $V = [0, 0, 0, 0, 0, 0]$.
+
+Then count the positions marked as violations and check whether that count is zero:
+
+$ C_(V)(i) := "#" [j <= i] V(j) $
+
+$ M(i) := C_(V)(i) = 0 $
+
+For this input, $C_V = [0, 0, 0, 0, 0, 0]$ and $M = [1, 1, 1, 1, 1, 1]$. In general, $M(i)$ is true exactly when no prefix ending at or before $i$ has violated the nesting condition.
+
+*Balanced prefixes.* The final condition is that the prefix has equally many opening and closing parentheses:
+
+$ B(i) := C_("(")(i) = C_(")")(i) $
+
+$ D(i) := M(i) ∧ B(i) $
+
+For this input, $B = [0, 1, 0, 0, 0, 1]$ and $D = [0, 1, 0, 0, 0, 1]$. The prefix `()` is balanced at position 2, and the entire string is balanced at position 6. Following #link()[Yang and Chiang (2024, Definition 3.1)], a recognition program must end with a Boolean-valued operation $D$ and accepts a word $w$ exactly when $D(|w|)$ is true. Here, $D$ is the output vector, and $D(6) = 1$, so $()(())$ is in Dyck-1.
+]
+
+The two conditions are both necessary. The string `())(` has equal total numbers of opening and closing parentheses, but it has already closed one too many at position 3. It therefore fails the prefix condition and is rejected.
+
+#interactive-notebook()
+
+#details[Reference: all allowed C-RASP operations][
+
+The full set of allowed operations is:
+
+#operation-columns(
+ [
+ *Boolean-valued vectors*
+ #operation-table(
+ [*Input marker*], [$P(i) := Q_(a)(i)$ for $a in Sigma$],
+ [*Negation*], [$P(i) := ¬P_(1)(i)$],
+ [*Conjunction*], [$P(i) := P_(1)(i) ∧ P_(2)(i)$],
+ [*Count comparison*], [$P(i) := C_(1)(i) <= C_(2)(i)$],
+ [*Constant*], [$P(i) := 1$],
+ )
+ ],
+ [
+ *Count-valued vectors*
+ #operation-table(
+ [*Prefix count*], [$C(i) := "#" [j <= i] P(j)$],
+ [*Conditional*], [$C(i) := P(i) ? C_(1)(i) : C_(2)(i)$],
+ [*Addition*], [$C(i) := C_(1)(i) + C_(2)(i)$],
+ [*Subtraction*], [$C(i) := C_(1)(i) - C_(2)(i)$],
+ [*Minimum*], [$C(i) := min(C_(1)(i), C_(2)(i))$],
+ [*Maximum*], [$C(i) := max(C_(1)(i), C_(2)(i))$],
+ [*Constant*], [$C(i) := 1$],
+ )
+ ],
+)
+
+]
+
+#notation-box[
+
+*Semantics of prefix counting.* The operator $"#" [j <= i] P(j)$ counts the positions from 1 through $i$ at which $P$ is true, including position $i$ itself. Identifying false with 0 and true with 1, this means
+
+$ C(i) := "#" [j <= i] P(j) = sum_(j=1)^i P(j). $
+
+For example, $P = [1, 0, 1, 1]$ gives $C = [1, 1, 2, 3]$. The result is 0 when no position in the prefix satisfies $P$.
+
+As in the paper's examples, we use derived shorthand: 0 can be defined as $1 - 1$, equality by two opposite $<=$ comparisons, and $C_1 < C_2$ by $not(C_2 <= C_1)$.
+
+
+The Dyck-1 recognizer above uses prefix counting, count comparisons, and Boolean conjunction.
+
+]
+
+== Examples
+
+Below we provide some more examples of C-RASP programms for some formal languages. In contrast we also discuss languages that are not expressible by C-RASP and hence are not expected to length-generalize.
+
+=== Expressible in C-RASP
+
+==== $a^*b^*$
+
+The goal is to accept strings in which every $a$ precedes every $b$. Thse key idea is to mark an $a$ as a violation once a $b$ has appeared, then check that there are no violation.
+
+#details[C-RASP Program for $a^*b^*$][
+
+#table(
+ columns: (auto, auto, auto),
+ align: (right, center, left),
+ [$C_(b)(i)$], [$:=$], [$"#" [j <= i] Q_(b)(j)$],
+ [$S_(b)(i)$], [$:=$], [$¬(C_(b)(i) <= 0)$],
+ [$V(i)$], [$:=$], [$Q_(a)(i) ∧ S_(b)(i)$],
+ [$C_(V)(i)$], [$:=$], [$"#" [j <= i] V(j)$],
+ [$M(i)$], [$:=$], [$C_(V)(i) = 0$],
+)
+
+*Reading the program.* $S_b$ records whether a $b$ has appeared; $V$ marks an $a$ after a $b$; and $M$ checks that no such position exists.
+
+*Check.* `aaabbb` and `bbb` are accepted; `ba` is rejected. The empty word also belongs to $a^*b^*$.
+
+]
+
+==== Add equal counts: $a^n b^n$
+
+Now require equally many $a$ and $b$ symbols, with at least one of each. The following program over $\{a,b\}$ checks block order, equal counts, and nonzero counts:
+
+#details[C-RASP Program for $a^n b^n$][
+
+#table(
+ columns: (auto, auto, auto),
+ align: (right, center, left),
+ [$C_(a)(i)$], [$:=$], [$"#" [j <= i] Q_(a)(j)$],
+ [$C_(b)(i)$], [$:=$], [$"#" [j <= i] Q_(b)(j)$],
+ [$S_(b)(i)$], [$:=$], [$¬(C_(b)(i) <= 0)$],
+ [$V(i)$], [$:=$], [$Q_(a)(i) ∧ S_(b)(i)$],
+ [$C_(V)(i)$], [$:=$], [$"#" [j <= i] V(j)$],
+ [$M(i)$], [$:=$], [$C_(V)(i) = 0$],
+ [$E(i)$], [$:=$], [$C_(a)(i) = C_(b)(i)$],
+ [$N(i)$], [$:=$], [$¬(C_(a)(i) <= 0)$],
+ [$D(i)$], [$:=$], [$M(i) ∧ E(i) ∧ N(i)$],
+)
+
+*Reading the program.* $S_b$ records whether a $b$ has appeared, and $V$ marks any $a$ that appears after a $b$. The condition $M$ requires that there are no such violations, $E$ requires equal counts, and $N$ requires at least one $a$. The final output $D$ combines these three checks. Together they recognize $a^n b^n$ for $n >= 1$; the empty word is excluded.
+
+*Check.* `aabb` is accepted. `aab` fails the count check, while `abab` has equal counts but fails the order check.
+]
+
+*TODO: other programms*
+
+=== Not expressible in C-RASP
+
+*Parity.* Strings over $\{a,b\}$ containing an even number of $a$ symbols. Parity is theoretically expressible by transformers, but both theoreticaly and experimentally it is not learnable. C-RASP can count $a$ symbols, but expressing modula properties is not possible.
+*TODO* be more precise why Parity not in Crasp
+
+*Flip-flop.* The language $Sigma^* b e^*$ over $Sigma = \{a,b,e\}$. Membership depends on the last non-$e$ symbol being $b$.
+TODO C-RASP can not recover the last occurence of the non-e symbols.
+
+=== Exercise: three equal blocks
+
+Write a recognizer for strings of the form $a^n b^n c^n$ over $\{a,b,c\}$, with $n >= 1$. Extend the two-block construction in three steps:
+
++ Compute the three symbol counts.
++ Mark and count positions that violate the block order.
++ Combine the order check with equal, nonzero counts in the final Boolean output.
+
+You can use the #link(c-rasp-editor-url)[drag-and-drop C-RASP editor (source and launch instructions)] to check your solution on concrete inputs and inspect the intermediate vectors. Test your solution on `abc` and `aabbcc` (accept), and on `aabcc`, `acb`, and `abccba` (reject). The last input has equal counts but the wrong order. These tests help catch mistakes; justify separately why your program works for every input length. Use the hint before consulting the solution.
+
+#details[Hint][
+
+Start by computing prefix counts for each symbol. Use them to mark positions where the order is broken: an $a$ after any $b$ or $c$, or a $b$ after any $c$. Count these violations, then combine the no-violation check with a final comparison of the three counts and a check that they are nonzero.
+
+]
+
+#context { if target() != "html" { pagebreak(weak: true) } }
+
+#details[Solution][
+
+Use $S_("bc")(i)$ to mark positions at or after the first $b$ or $c$, and $S_(c)(i)$ to mark positions at or after the first $c$. The disjunction below is shorthand for negation and conjunction: $P ∨ Q := ¬(¬P ∧ ¬Q)$.
+
+#table(
+ columns: (auto, auto, auto),
+ align: (right, center, left),
+ [$C_(a)(i)$], [$:=$], [$"#" [j <= i] Q_(a)(j)$],
+ [$C_(b)(i)$], [$:=$], [$"#" [j <= i] Q_(b)(j)$],
+ [$C_(c)(i)$], [$:=$], [$"#" [j <= i] Q_(c)(j)$],
+ [$S_("bc")(i)$], [$:=$], [$¬((C_(b)(i) <= 0) ∧ (C_(c)(i) <= 0))$],
+ [$S_(c)(i)$], [$:=$], [$¬(C_(c)(i) <= 0)$],
+ [$V_(a)(i)$], [$:=$], [$Q_(a)(i) ∧ S_("bc")(i)$],
+ [$V_(b)(i)$], [$:=$], [$Q_(b)(i) ∧ S_(c)(i)$],
+ [$V(i)$], [$:=$], [$V_(a)(i) ∨ V_(b)(i)$],
+ [$C_(V)(i)$], [$:=$], [$"#" [j <= i] V(j)$],
+ [$M(i)$], [$:=$], [$C_(V)(i) = 0$],
+ [$E(i)$], [$:=$], [$(C_(a)(i) = C_(b)(i)) ∧ (C_(b)(i) = C_(c)(i))$],
+ [$K(i)$], [$:=$], [$¬(C_(a)(i) <= 0) ∧ ¬(C_(b)(i) <= 0) ∧ ¬(C_(c)(i) <= 0)$],
+ [$D(i)$], [$:=$], [$M(i) ∧ E(i) ∧ K(i)$],
+)
+
+$V_a$ flags an $a$ after a $b$ or $c$, while $V_b$ flags a $b$ after a $c$. Thus $M(|w|)$ enforces the block order. The conditions $E(|w|)$ and $K(|w|)$ require equal, nonzero counts, so the program accepts exactly $a^n b^n c^n$ for $n >= 1$.
+
+]
+
+// - *Unique copying:* Copying a sequence whose tokens are all distinct. Prior work shows this task is expressible in C-RASP, but its growing set of token identities lies outside the basic fixed-finite-alphabet setup. The paper discusses this caveat and treats vocabulary growth with C\*-RASP.
+
+== From C-RASP programs to transformers
+
+C-RASP provides a constructive lower bound on transformer expressivity: any C-RASP program can be simulated by a suitable transformer. Boolean operations are encoded by the transformer's feedforward layers, while prefix counting is implemented through attention.
+
+The central idea is that uniform causal attention averages a predicate over the visible prefix. The resulting normalized counts share a positive denominator at each position, so comparing them gives the same answer as comparing the integer counts.
+
+#details[Derivation: prefix counting with uniform attention][
+
+For an input over a finite alphabet $Sigma$, represent the token at position $i$ by a one-hot vector $x_i in \{0,1\}^{|Sigma|}$, where $x_i[a] = 1$ exactly when the token is $a$. These coordinates are precisely the C-RASP indicators $Q_(a)(i)$. A standard transformer's token-embedding matrix maps this one-hot representation (or its equivalent token ID) to a learned vector.
+
+*Counting with attention.* Following _Counting Like Transformers_ (#link()[Yang and Chiang, 2024]), the key idea is that attention can aggregate a predicate over a prefix. At position $i$, a causally masked attention head assigns position $j$ the weight
+
+$ alpha_(i, j) = frac(exp(q_i dot k_j / sqrt(d_k)), sum_(t <= i) exp(q_i dot k_t / sqrt(d_k))), quad j <= i. $
+
+Set $q_i dot k_j = 0$ for every visible position $j$ (for example, set all keys to zero). Each softmax score is then $exp(0) = 1$, so its normalized weight is $alpha_(i, j) = 1 / i$. With values encoding $P(j)$, attention returns
+
+$ "Att"(x)_i = sum_(j <= i) alpha_(i, j) P(j) = 1 / i sum_(j <= i) P(j) = ("#" [j <= i] P(j)) / i. $
+
+
+Thus attention can encode the C-RASP prefix count $C(i) := "#" [j <= i] P(j)$ as the normalized count
+
+$ tilde(C)(i) := C(i) / i. $
+
+At each position $i$, count values share the positive normalization factor $1/i$, so comparisons between them are unchanged. Counts can also be combined using arithmetic and conditional selection, as described below. Constants must use the same encoding: the integer constant 1 is represented by $1/i$, and more generally a constant $c$ by $c/i$.
+
+For Dyck-1, for example, $C_"("$ becomes $tilde(C_"(") = [1, 1\/2, 2\/3, 3\/4, 3\/5, 1\/2]$. Both opening- and closing-counts share divisor $i$, so their comparisons-and hence the prefix-balance test—are unchanged.
+
+]
+
+#details[Arithmetic and numerical precision][
+
+Sums, differences, minima, maxima, and conditional selection also preserve this shared divisor, allowing normalized counts to flow through the program. Distinguishing adjacent normalized counts requires resolution on the order of $1/i$, so a fixed number of fractional bits cannot suffice at arbitrary lengths. This motivates allowing logarithmic precision for these attention results: for inputs of length at most $n$, $log_2(n) + O(1)$ fractional bits give resolution finer than the smallest adjacent-count gap $1/n$. This observation concerns the resolution of the normalized counts, rather than a guarantee that rounding every intermediate computation to this precision preserves the full simulation.
+
+]
+
+#details[Constants and the BOS token][
+
+The simplified calculation above omits the beginning-of-sequence (BOS) token used in the full construction. With BOS preceding the $i$ ordinary input tokens, uniform attention averages over $i+1$ positions. Assigning value 1 at BOS and 0 elsewhere therefore yields $1/(i+1)$, the normalized encoding of the integer constant 1. Ordinary predicates are false at BOS, so their counts are encoded as $C(i)/(i+1)$. Counts and constants thus share the same denominator (#link()[Yang and Chiang, 2024, Appendix B.2]).
+
+]
+
+#details[How LayerNorm is handled][
+
+For clarity, this explanation omits LayerNorm, but it can be accounted for in the compilation. #link()[Yang and Chiang (2024, Section 5.2 and Appendix B.3)] use paired coordinates with opposite signs, ensuring zero mean so that LayerNorm applies a shared scaling factor at each position. Their construction handles this scaling when implementing Boolean values and count comparisons.
+
+]
+
+Putting these constructions together, any fixed C-RASP program can be compiled into a transformer whose depth and width are independent of the input length.
+
+This compilation establishes representability, but does not imply that training will find the constructed solution. Empirical studies nevertheless find that C-RASP expressibility predicts length generalization on a range of algorithmic and formal-language tasks (#link()[Huang et al., 2025]).
+
+
+== Positional information
+
+So far, we have not used explicit positional embeddings. Positions are distinguished only by their tokens and the information available from their prefixes through causal attention. Positional embeddings provide an additional, explicit representation of *where* each token occurs. Here we consider absolute positional embeddings: each position $i$ has its own learned vector $p_i$, which is added to the token embedding $e(x_i)$:
+
+$ h_i = e(x_i) + p_i. $
+
+=== Finite positional tables and offsets
+
+The position vectors are stored in a finite table, so the model is defined only up to its maximum context length $L$. To train the embeddings across this table using shorter examples, the *offset trick* assigns an example of length $k$ the positional vectors $p_(o+1), dots, p_(o+k)$ for a valid offset $o$. Varying the offset allows training examples to occur throughout the context window, but does not guarantee that every position is observed in a finite training run or teach the model to generalize to longer examples. Since the table is still finite, the C-RASP simulation cannot be realized by one such fixed transformer for inputs of arbitrary length. Instead, consider a sequence of transformers with increasing context lengths and corresponding positional tables. To describe the underlying computations across these context lengths, the framework introduces a mathematical object called a limit transformer.
+
+=== Limit transformers
+
+A limit transformer is an idealized model that captures the positional information provided by positional embeddings while remaining defined on inputs of arbitrary length. It has fixed width and finite-precision parameters. Besides positional vectors $p_i$, it can use an additional positional function in each attention head. Writing $i$ for the query position and $j$ for the key position, the attention logit has the form
+
+$ a_(i,j)^(l,h) = q_i^(l,h) dot k_j^(l,h) + r_(l,h)(i,j). $
+
+The term $r_(l,h)(i,j)$ directly expresses a relation between positions, such as whether $j$ immediately precedes $i$. This allows the model to describe positional interactions at every input length without storing a separate learned vector for every position in a growing table.
+
+For the length-generalization result, the positional vectors must be periodic, and the additional positional functions must be translation-invariant and local. Below, $r$ denotes the positional function of one attention head, with query position $i$ and key position $j <= i$.
+
+_Periodicity._ The positional vectors repeat with a fixed positive integer period $Delta$:
+
+$ p_(i + Delta) = p_i quad "for every" i >= 1. $
+
+For example, period 2 allows alternating vectors $(u, v, u, v, dots)$ for odd and even positions. The sequence is infinite, but only $Delta$ vectors need to be specified, independently of the input length.
+
+_Translation invariance._ Shifting both positions equally leaves the interaction unchanged:
+
+$ r(i + s, j + s) = r(i,j) quad "for every integer" s >= 0. $
+
+For example, assigning score 1 to the preceding position and 0 elsewhere treats $(2,1)$ and $(8,7)$ identically. Singling out absolute position $j = 1$ would violate translation invariance.
+
+_Locality._ The additional positional interaction vanishes beyond a fixed nonnegative integer distance $tau$:
+
+$ r(i,j) = 0 quad "whenever" i - j > tau. $
+
+The preceding-position example is local with $tau = 1$. Assigning score 1 at every even distance is translation-invariant but not local. This restriction applies only to $r$; content-based attention can still aggregate over the entire prefix.
+
+=== Positional information in C-RASP
+
+We can express the same kinds of information at the program level by extending C-RASP to $"C-RASP"[Phi, Psi]$. Here, $Phi$ is a collection of Boolean predicates on individual positions, and $Psi$ is a collection of Boolean relations between pairs of positions. We add positional predicates
+
+$ P(i) := phi(i), quad phi in Phi, $
+
+and allow counting to be restricted by a positional relation:
+
+$ C(i) := "#" [j <= i, psi(i,j)] P(j), quad psi in Psi union {top}. $
+
+This counts positions $j$ for which both $P(j)$ and $psi(i,j)$ hold. Ordinary prefix counting remains a separate allowed primitive, represented here by the always-true relation $top$. The locality restriction applies to relations in $Psi$, not to this unrestricted prefix-counting operation.
+
+For example, a unary predicate $phi(i)$ can mark the even positions. A binary relation $psi(i,j)$ can hold exactly when $j = i - 1$. Counting with this relation gives 1 if the preceding position satisfies $P$, and 0 otherwise, including when no preceding position exists. Comparing this count with 0 therefore lets a program test the preceding token.
+
+When the predicates in $Phi$ are periodic and the relations in $Psi$ are translation-invariant and local, we obtain the variant we denote by $"C-RASP"["Pos"]$ throughout this tutorial. This abbreviates the notation $"C-RASP"["periodic", "local"]$ used by #link()[Huang et al. (2025)]. The position-free language introduced earlier is the special case with no added positional predicates or relations.
+
+
+== From representability to length generalization
+
+A C-RASP program specifies one computation that works at every input length. Compiling it into a transformer establishes that the computation is representable. Length generalization asks a further question: can a learner identify such a computation from its behavior on shorter inputs?
+
+=== Training length and context length
+
+To formalize this question, we must separate the lengths seen during training from the lengths the model can process. A model with context length $n$ can be tested on longer examples than it has seen, but only up to length $n$. Success within one fixed window therefore cannot establish generalization to arbitrarily long inputs. #link()[Huang et al. (2025)] address this by considering a sequence of transformers $T_n$ with increasing context length $n$. For each $n$, an idealized learner observes the target function $f$ on all inputs of length at most $n/2$ and selects $T_n$. We ask whether, for every sufficiently large $n$, this model also computes $f$ correctly on all unseen lengths between $n/2$ and $n$.
+
+This definition captures the idea that, once the learner has seen sufficiently long examples, its solution continues to work at greater lengths. Increasing the context window prevents the model's finite positional table from setting an arbitrary ceiling on the analysis, while leaving the longer inputs unseen makes extrapolation necessary. The factor of two is a convenient choice, rather than an essential assumption of the framework. Observing *all* inputs through the training length also isolates generalization across lengths from the separate problem of learning from a finite sample. The resulting guarantee concerns this idealized inference procedure; it does not say that one trained finite-context model can process inputs of every length.
+
+=== The idealized learner
+
+The learner must fit every input through the training length and approximately minimize a complexity regularizer. Its positional contributions must also satisfy an offset-independence restriction. This is an idealized inference procedure; ordinary gradient-based training need not satisfy these assumptions.
+
+#details[Formal selection rule and positional restrictions][
+
+The selection rule requires $T_n$ to match $f$ exactly on *all* inputs through the training length and to approximately minimize a particular complexity regularizer among the models that do so. This regularizer controls quantities such as depth, precision, parameter norms, and positional interactions. The width $d$ is not explicitly bounded by the hypothesis class. More precisely, let $U_n$ be the set of admissible models that match $f$ on all inputs of length at most $n/2$, at every valid offset. The procedure selects $T_n in U_n$ satisfying
+
+$ cal(R)(T_n) <= inf_(T in U_n) cal(R)(T) + 1/n. $
+
+*Why approximate minimization?* An exact minimizer need not exist: the infimum may not be attained by any model in $U_n$. The allowance $1/n$ makes approximate minimization sufficient, with an error that vanishes as $n$ grows (#link()[Huang et al., 2025, Definition 6]).
+
+A separate restriction concerns how the model uses positional information. Although the embeddings $p_i$ may vary across positions, the parameter products through which they contribute to the computation must be unchanged when all positions are shifted equally. This is stronger than requiring the same final prediction at every offset: the underlying computation must use positional information in the same way. For example, an interaction based on the distance between two positions survives such a shift, whereas one tied to a particular absolute position generally does not.
+
+]
+
+=== The main equivalence
+
+Under the framework's assumptions, #link()[Huang et al. (2025, Theorem 7)] establish that the following statements are equivalent:
+
++ *Representation:* The target function $f$ is representable by a limit transformer with periodic positional vectors and translation-invariant, local additional positional interactions.
++ *Identification with bounded complexity:* Any sequence $T_n$ selected by the idealized inference procedure has uniformly bounded regularizer values and eventually generalizes. That is, there are constants $C$ and $N_0$ such that $cal(R)(T_n) <= C$ for every $n$, and for every $n > N_0$, $T_n$ matches $f$ on all inputs of length at most $n$, despite having fitted it only through length $n/2$.
+
+The forward direction shows that such a representation guarantees eventual generalization with bounded complexity under the inference procedure. The reverse direction shows that a sequence selected by this procedure with both properties implies the existence of such a representation. Bounded regularization is part of this equivalence: eventual generalization alone is not the condition being characterized.
+
+The procedure itself requires approximate minimization of the regularizer, rather than a prescribed uniform bound $C$. When the representation in the first statement exists, the theorem guarantees that the selected sequence remains uniformly bounded.
+
+#details[Intuition: eliminating incorrect candidate algorithms][
+
+Why is this possible? Under the theorem's assumptions, bounded complexity leaves only finitely many candidate underlying algorithms. Any incorrect candidate disagrees with $f$ on some finite input. As the training length increases, that input eventually becomes visible and rules out the candidate. Once every incorrect candidate has been eliminated, the remaining algorithms agree with $f$ at every length. This is a statement about the computations identified by the learner; it does not require the parameter vectors to converge to a unique set of weights.
+
+]
+
+=== What follows for C-RASP?
+
+The compilation introduced above connects $"C-RASP"["Pos"]$ directly to the main equivalence: every such program supplies a limit-transformer representation satisfying the theorem's restrictions. Expressing a target function in $"C-RASP"["Pos"]$ therefore guarantees eventual generalization with bounded regularization under the idealized inference procedure. This is a sufficient condition; the converse claim that every such limit transformer has an equivalent C-RASP[Pos] program is not established.
+
+=== How much training length is enough?
+
+Eventual identification does not provide a practical stopping rule. #link()[Yang et al. (2026)], in _Length Generalization Bounds for Transformers_, show that no computable bound in terms of program description size exists for general C-RASP, even at counting depth two, with a corresponding result for the transformer model considered there. This concerns the maximum input length needed to distinguish candidate programs, rather than simply the number of training examples. The same work also identifies a restricted positive fragment, $"C-RASP"_+$, for which a computable bound does exist: the required training length is exponential in program description size, and this bound is optimal in the worst case. This fragment restricts count comparisons to nonnegative linear combinations of counts against constants. It is expressively equivalent to the paper's fully fixed-precision transformer model, for which the authors also obtain exponential bounds.
+
+== Chain of thought
+- Can CoT increase expressivity? (and how to fix this)
+- C-RASP CoT
+
+= Extensions / Limitations / Open Questions
+- Open C-RASP conjecture
+- Incomputable bound
+- Extensions of C-RASP
+- how the model differs from real transformers (e.g., precision, attention, etc.)
+- Other formalisms of length generalization (RASP-L etc.)
+
+= References
+
+#block[Neil Immerman. 1999. _Descriptive Complexity._ Springer. #link("https://people.cs.umass.edu/~immerman/book/ch5.pdf")[Chapter 5: Parallelism].]
+
+#block[Emanuele Viola. 2023. _Mathematics of the Impossible_, Chapter 10: Constant-depth circuits. #link("https://emanueleviola.wordpress.com/2023/05/02/mathematics-of-the-impossible-chapter-10-constant-depth-circuits/")[Author's chapter].]
+
+#block[William Merrill and Ashish Sabharwal. 2023. _The Parallelism Tradeoff: Limitations of Log-Precision Transformers._ Transactions of the Association for Computational Linguistics, 11:531–545. #link("https://aclanthology.org/2023.tacl-1.31/")[ACL Anthology].]
+
+#block[Andy Yang and David Chiang. 2024. _Counting Like Transformers: Compiling Temporal Counting Logic Into Softmax Transformers._ COLM. #link("https://arxiv.org/abs/2404.04393")[arXiv:2404.04393].]
+
+#block[Xinting Huang, Andy Yang, Satwik Bhattamishra, Yash Sarrof, Andreas Krebs, Hattie Zhou, Preetum Nakkiran, and Michael Hahn. 2025. _A Formal Framework for Understanding Length Generalization in Transformers._ ICLR. #link("https://arxiv.org/abs/2410.02140")[arXiv:2410.02140].]
+
+#block[Andy Yang, Pascal Bergsträßer, Georg Zetzsche, David Chiang, and Anthony W. Lin. 2026. _Length Generalization Bounds for Transformers._ #link("https://arxiv.org/abs/2603.02238")[arXiv:2603.02238].]
diff --git a/tutorial_dual/site.typ b/tutorial_dual/site.typ
new file mode 100644
index 0000000..b722de9
--- /dev/null
+++ b/tutorial_dual/site.typ
@@ -0,0 +1,19 @@
+#import "@tola/site:0.0.0": site
+#import "@tola/source:0.0.0": all-sources
+#import "site/selection.typ": select-pages
+#import "site/page.typ": page-template
+#import "site/not-found.typ": not-found-template
+
+#set text(
+ lang: site.language.lang,
+ script: site.language.script,
+ region: site.language.region,
+)
+
+/// Every page this site publishes: one entry per source, paired with the output file its route
+/// names.
+#let pages = select-pages(all-sources())
+
+#for page in pages { page-template(page) }
+
+#not-found-template()
diff --git a/tutorial_dual/site/not-found.typ b/tutorial_dual/site/not-found.typ
new file mode 100644
index 0000000..e81369d
--- /dev/null
+++ b/tutorial_dual/site/not-found.typ
@@ -0,0 +1,20 @@
+#import "@tola/site:0.0.0": site
+#import "seo.typ": head-entries
+
+/// Write `404.html`, the page shown for an address the site does not publish.
+/// -> content
+#let not-found-template() = {
+ document("404.html", format: "html", title: "Page not found")[
+ #html.html(lang: site.language.tag)[
+ #html.head[
+ #for entry in head-entries("Page not found") { entry }
+ ]
+ #html.body[
+ #html.elem("main")[
+ #title()
+ The page you requested could not be found.
+ ]
+ ]
+ ]
+ ]
+}
diff --git a/tutorial_dual/site/page.typ b/tutorial_dual/site/page.typ
new file mode 100644
index 0000000..05db210
--- /dev/null
+++ b/tutorial_dual/site/page.typ
@@ -0,0 +1,48 @@
+#import "@tola/site:0.0.0": site
+#import "@tola/address:0.0.0": asset-url
+#import "@tola/document:0.0.0": headings
+
+#let page-template(page) = {
+ let meta = page.source.meta
+ document(page.output, format: "html", title: meta.title, description: meta.description)[
+ #html.html(lang: "en")[
+ #html.head[
+ #html.elem("meta", attrs: (charset: "utf-8"))
+ #html.elem("meta", attrs: (name: "viewport", content: "width=device-width, initial-scale=1"))
+ #html.link(rel: "stylesheet", href: asset-url("/assets/css/article.css"))
+ #html.elem("script", attrs: (src: asset-url("/assets/scripts/article.js"), defer: ""))[]
+ ]
+ #html.body[
+ #html.elem("a", attrs: (href: "#article", class: "skip"))[Skip to article]
+ #html.elem("header", attrs: (class: "masthead"))[
+ #html.elem("p", attrs: (class: "eyebrow"))[A tutorial · C-RASP and transformers]
+ #html.elem("h1", meta.title)
+ #html.elem("p", attrs: (class: "deck"))[From prefix counting to computations that work on inputs longer than the training examples.]
+ #html.elem("div", attrs: (class: "reading-tools", hidden: "", id: "reading-tools"))[
+ #html.elem("button", attrs: (type: "button", id: "expand-details"))[Expand all explanations]
+ #html.elem("button", attrs: (type: "button", id: "collapse-details"))[Collapse all explanations]
+ ]
+ #html.elem("p", attrs: (class: "small"))[Read straight through, or open the detailed explanations as needed.]
+ ]
+ #html.elem("div", attrs: (class: "layout"))[
+ #html.elem("nav", attrs: (class: "contents", "aria-label": "Contents"))[
+ #html.elem("strong")[Contents]
+ #context {
+ for section in headings(depth: 2).filter(section => section.outlined) {
+ html.elem("p", attrs: (class: "toc-level-" + str(section.level)))[#link(section.location, section.body)]
+ }
+ }
+ ]
+ #html.elem("main", attrs: (id: "article"))[
+ #set heading(numbering: none)
+ // Scroll the HTML wrapper, preserving the browser's native MathML layout.
+ #show math.equation.where(block: true): it => html.elem("div", attrs: (class: "display-equation"), it)
+ // Main title is h1; native section headings begin at h2.
+ #show heading: it => html.elem("h" + str(calc.min(it.level + 1, 6)), it.body)
+ #include page.source.file
+ ]
+ ]
+ ]
+ ]
+ ]
+}
diff --git a/tutorial_dual/site/schema.typ b/tutorial_dual/site/schema.typ
new file mode 100644
index 0000000..71312e2
--- /dev/null
+++ b/tutorial_dual/site/schema.typ
@@ -0,0 +1,76 @@
+// Add custom page fields here; unknown fields are errors.
+
+#import "@tola/schema:0.0.0": (
+ date, describe, literal, nullable, one-or-many, optional, schema, timestamp, trim, union, url-path,
+)
+#import "@tola/site:0.0.0": site
+
+/// One page author: a `name`, with an optional `email` and `url`.
+///
+/// Unknown author fields stay available to templates; the feed recipe reads `name`, `email`, and
+/// `url`.
+#let author = schema(
+ (
+ name: str,
+ email: optional(nullable(str)),
+ url: optional(nullable(str)),
+ ),
+ unknown: "keep",
+)
+
+/// The page metadata declaration: the fields a source may set, each defaulting to the site's
+/// value where one applies.
+#let page-schema = schema((
+ title: describe(
+ optional(nullable(union(trim(str), content)), default: site.title),
+ "the page title, shown in the browser tab and in lists",
+ ),
+ description: describe(
+ optional(nullable(union(trim(str), content)), default: site.description),
+ "the page summary, shown in search results and link previews",
+ ),
+ authors: describe(
+ optional(nullable(one-or-many(union(str, author))), default: site.authors),
+ "who wrote the page: a name, or a dictionary with name, email, and url",
+ ),
+ tags: describe(
+ optional(one-or-many(str), default: ()),
+ "the topics this page belongs to",
+ ),
+ draft: describe(
+ optional(bool, default: false),
+ "keeps the page out of the published site",
+ ),
+ permalink: describe(
+ optional(nullable(url-path), default: none),
+ "overrides the file-based route: `/about/` is a directory, `/about.html` is a file",
+ ),
+ feed: describe(
+ optional(bool, default: true),
+ "includes the page in feeds when a publication date is set",
+ ),
+ sitemap: describe(
+ optional(bool, default: true),
+ "includes the page in `sitemap.xml`",
+ ),
+ id: describe(
+ optional(union(str, literal(auto)), default: auto),
+ "a stable feed identifier; `auto` derives one from the page's address",
+ ),
+ published: describe(
+ optional(nullable(timestamp), default: none),
+ "the publication time: a calendar datetime interpreted as UTC, or an RFC 3339 string with timezone",
+ ),
+ updated: describe(
+ optional(nullable(union(date, timestamp)), default: none),
+ "the last change: a calendar date or timestamp; date-only values keep their calendar precision",
+ ),
+ feed-summary: describe(
+ optional(nullable(union(trim(str), content)), default: none),
+ "the summary a feed shows in place of the whole page: plain text, or markup limited to text, breaks, emphasis, strong, strike, and URL links",
+ ),
+ feed-content: describe(
+ optional(nullable(union(str, content, dictionary)), default: none),
+ "the feed body: plain text, markup limited to text, breaks, emphasis, strong, strike, and URL links, or a `(document:, id:)` selection",
+ ),
+))
diff --git a/tutorial_dual/site/selection.typ b/tutorial_dual/site/selection.typ
new file mode 100644
index 0000000..3e19133
--- /dev/null
+++ b/tutorial_dual/site/selection.typ
@@ -0,0 +1,34 @@
+#import "@tola/address:0.0.0": join-route, output-of, slugify
+#import "@tola/site:0.0.0": site
+#import "@tola/source:0.0.0": parse-sources
+#import "schema.typ": page-schema
+
+/// The decoded site-root route one source publishes at: its `permalink` when it sets one, and
+/// otherwise the route its file layout gives it, each segment slugged for the site's language.
+///
+/// A trailing `/` names a directory: `/about/` publishes `about/index.html`.
+///
+/// - source (dictionary): one source record, as `parse-sources` returns it.
+/// -> string
+#let source-route(source) = {
+ if source.meta.permalink == none {
+ join-route(source.route-segments.map(segment => slugify(segment, language: site.language.lang)))
+ } else {
+ source.meta.permalink
+ }
+}
+
+/// Select the pages to publish: one entry per source that is not a draft, pairing the source with
+/// the output file its route names.
+///
+/// - sources (array): complete source records from `all-sources()`.
+/// -> array
+#let select-pages(sources) = {
+ // Every source is validated here, drafts included, before choosing which to publish.
+ parse-sources(sources, page-schema)
+ .filter(source => not source.meta.draft)
+ .map(source => (
+ source: source,
+ output: output-of(source-route(source)),
+ ))
+}
diff --git a/tutorial_dual/site/seo.typ b/tutorial_dual/site/seo.typ
new file mode 100644
index 0000000..bb4f01e
--- /dev/null
+++ b/tutorial_dual/site/seo.typ
@@ -0,0 +1,17 @@
+#import "@tola/site:0.0.0": site
+#import "@tola/web:0.0.0": head-metadata
+
+/// The head entries one document carries: the character set, the viewport, the title, the
+/// description, and the entries the selected features add for a page.
+///
+/// A document that is not a source, such as `404.html`, passes no `page` and carries only the
+/// metadata entries.
+///
+/// - title (content | string): the document's title.
+/// - description (none | content | string): the document's description.
+/// - page (none | dictionary): the `(source:, output:)` record of the page being emitted.
+/// -> array
+#let head-entries(title, description: none, page: none) = {
+ let entries = head-metadata(site, title: title, description: description)
+ entries
+}
diff --git a/tutorial_dual/static/web-assets/css/article.css b/tutorial_dual/static/web-assets/css/article.css
new file mode 100644
index 0000000..b5882d6
--- /dev/null
+++ b/tutorial_dual/static/web-assets/css/article.css
@@ -0,0 +1,41 @@
+:root { color-scheme: light; --ink:#202b32; --muted:#58666e; --accent:#17635e; --line:#d7dfdf; --paper:#fffefa; font-family:Georgia, 'Times New Roman', serif; color:var(--ink); background:var(--paper); }
+* { box-sizing:border-box; }
+body { margin:0; font-size:18px; line-height:1.7; }
+a { color:var(--accent); text-underline-offset:.18em; }
+a:hover { text-decoration-thickness:2px; }
+.masthead { max-width:1100px; margin:0 auto; padding:4rem 2rem 2.5rem; border-bottom:1px solid var(--line); }
+h1,h2,h3,h4,summary,button,.eyebrow,.contents { font-family:system-ui,sans-serif; }
+h1 { font-size:clamp(2rem,5vw,3.5rem); line-height:1.12; letter-spacing:-.04em; max-width:850px; margin:.5em 0; }
+.eyebrow { color:var(--accent); text-transform:uppercase; font-size:.75rem; letter-spacing:.13em; font-weight:700; }
+.deck { max-width:700px; color:var(--muted); font-size:1.2rem; }
+.layout { max-width:1160px; margin:0 auto; display:grid; grid-template-columns:220px minmax(0,1fr); gap:3rem; padding:2rem; }
+.contents { position:sticky; top:1.5rem; align-self:start; max-height:90vh; overflow:auto; font-size:.8rem; line-height:1.45; }
+.contents p { margin:.6em 0; }.contents .toc-level-2 { padding-left:.8rem; }
+main { min-width:0; max-width:820px; padding-bottom:5rem; }
+h2 { font-size:1.8rem; line-height:1.25; margin:2.5rem 0 1rem; letter-spacing:-.025em; }
+h3 { font-size:1.3rem; line-height:1.3; margin:2rem 0 .8rem; }
+h4 { font-size:1.05rem; } h2,h3,h4 { scroll-margin-top:1.5rem; }
+p { margin:.9em 0; } li { margin:.4em 0; }
+code,pre { font-size:.88em; } code { background:#edf2f0; border-radius:3px; padding:.1em .25em; }
+pre { overflow:auto; padding:1rem; background:#edf2f0; }
+.explanation { margin:1.5rem 0; border:1px solid var(--line); border-radius:6px; }
+summary { cursor:pointer; padding:.9rem 1rem; font-size:.95rem; font-weight:650; background:#f0f5f2; }
+summary:hover { background:#e5efea; } .explanation-body { padding:.2rem 1.2rem .8rem; min-width:0; }
+.explanation .explanation { margin:1rem 0; }
+.reading-tools { display:flex; flex-wrap:wrap; gap:.6rem; }
+button { font-size:.85rem; padding:.55rem .85rem; color:var(--accent); border:1px solid var(--line); background:white; border-radius:4px; cursor:pointer; }
+button:hover { background:#eef5f0; } :focus-visible { outline:3px solid #b87932; outline-offset:3px; }
+.small { font-size:.85rem; color:var(--muted); }
+.operation-columns { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; }
+table { border-collapse:collapse; width:100%; margin:1rem 0; font-size:.85em; }
+td,th { border:1px solid var(--line); padding:.45rem; text-align:left; }
+.display-equation { overflow-x:auto; overflow-y:hidden; max-width:100%; padding:.5rem 0; }
+.interactive { margin:2.5rem -1rem; border-top:2px solid var(--accent); border-bottom:1px solid var(--line); padding:0 1rem 1rem; }
+.notebook-frame { display:block; width:100%; border:0; background:#fafbf9; min-height:650px; }
+.skip { position:absolute; left:1rem; top:-5rem; background:white; padding:.5rem; }.skip:focus { top:1rem; }
+[hidden] { display:none !important; }
+@media(max-width:900px) { .layout { grid-template-columns:1fr; gap:1rem; }.contents { position:static; max-height:none; border-bottom:1px solid var(--line); padding-bottom:1rem; }.contents p { display:inline-block; margin:.2rem .8rem .2rem 0; }.contents .toc-level-2 { padding-left:0; } }
+@media(max-width:600px) { body { font-size:17px; }.masthead { padding:2rem 1rem 1.5rem; }.layout { padding:1rem; }.operation-columns { grid-template-columns:1fr; }.explanation-body { padding:.2rem .7rem .8rem; }.interactive { margin:2rem 0; padding:0 0 1rem; } }
+@media print { .contents,.reading-tools,.notebook-frame,.skip { display:none; }.layout { display:block; }.masthead { padding:0; }.explanation { border:0; } }
+
+.notation-box { margin:1.5rem 0; padding:.3rem 1.2rem; border:1px solid var(--line); border-left:3px solid var(--accent); border-radius:5px; background:#f4f7f4; }
diff --git a/tutorial_dual/static/web-assets/interactive/.nojekyll b/tutorial_dual/static/web-assets/interactive/.nojekyll
new file mode 100644
index 0000000..e69de29
diff --git a/tutorial_dual/static/web-assets/interactive/assets/app.js b/tutorial_dual/static/web-assets/interactive/assets/app.js
new file mode 100644
index 0000000..2791db1
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/app.js
@@ -0,0 +1,521 @@
+import { tokenize, validateEvaluations } from "./lib.js";
+const $ = (id) => document.getElementById(id);
+// -----------------------------------------------------------------------------
+// Example programs and playground state
+// -----------------------------------------------------------------------------
+const examples = {
+ dyck: {
+ tokens: "( ) ( ( ) )",
+ source:
+ 'C_open = # "("\nC_close = # ")"\nV = C_open < C_close\nCV = # V\nM = (CV == 0)\nB = (C_open == C_close)\nD = M && B',
+ },
+ anbn: {
+ tokens: "a a a b b b",
+ source:
+ 'A = # "a"\nB = # "b"\nBad = "a" && (B > 0)\nOther = !("a" || "b")\nErrors = #(Bad || Other)\nAccept = (A == B) && (Errors == 0) && (A > 0)',
+ },
+ previous: {
+ tokens: "b a b a",
+ source:
+ '#import pred\nC = #[pred] "a"\nPair = "b" && (C > 0)\nPairs = # Pair\nAccept = Pairs > 0',
+ },
+};
+let trace,
+ selected = 0,
+ worker,
+ timer,
+ running = false,
+ lastRequest;
+function status(message, error = false) {
+ $("engine-status").textContent = message;
+ $("engine-status").classList.toggle("error", error);
+}
+function stop() {
+ worker?.terminate();
+ worker = undefined;
+ clearTimeout(timer);
+ running = false;
+ $("run").disabled = false;
+ $("stop").hidden = true;
+}
+function markStale() {
+ $("download-trace").disabled = true;
+ $("result").textContent = "Edits not evaluated";
+ $("result").className = "result";
+ $("acceptance-note").textContent =
+ "The trace below belongs to the previous input and program. Choose Run program to replace it.";
+ status("The table shows the previous run. Run the interpreter to evaluate your edits.");
+}
+// -----------------------------------------------------------------------------
+// Trace rendering and assignment explanations
+// -----------------------------------------------------------------------------
+function renderTrace(data) {
+ trace = data;
+ selected = Math.min(selected, data.rows.length - 1);
+ const table = $("trace");
+ table.replaceChildren();
+ const caption = table.createCaption();
+ caption.textContent = "Interpreter values by token position (zero-based)";
+ const head = table.createTHead().insertRow();
+ ["Assignment", ...data.tokens.map((t, i) => `${i}: ${t}`)].forEach((t) => {
+ const th = document.createElement("th");
+ th.textContent = t;
+ head.append(th);
+ });
+ const body = table.createTBody();
+ data.rows.forEach((row, ri) => {
+ const tr = body.insertRow();
+ tr.dataset.row = ri;
+ const th = document.createElement("th");
+ const b = document.createElement("button");
+ b.className = "secondary";
+ b.textContent = row.name;
+ b.onclick = () => selectRow(ri);
+ th.append(b);
+ tr.append(th);
+ row.values.forEach((value, ci) => {
+ const td = tr.insertCell();
+ td.textContent = typeof value === "boolean" ? (value ? "✓" : "·") : value;
+ td.tabIndex = 0;
+ const inspect = () => {
+ selectRow(ri);
+ table
+ .querySelectorAll(".active,.contributes")
+ .forEach((e) => e.classList.remove("active", "contributes"));
+ td.classList.add("active");
+ // Only infer a contributor explanation for the named, unedited Dyck example.
+ if (
+ lastRequest?.source === examples.dyck.source &&
+ ["C_open", "C_close"].includes(row.name)
+ ) {
+ const token = row.name === "C_open" ? "(" : ")";
+ [...head.cells].forEach((cell, k) => {
+ if (k > 0 && k - 1 <= ci && data.tokens[k - 1] === token)
+ cell.classList.add("contributes");
+ });
+ $("cell-note").textContent =
+ `${row.name}[${ci}] = ${value}: count “${token}” at positions 0 through ${ci}, inclusive. Highlighted column headings contribute.`;
+ } else
+ $("cell-note").textContent =
+ `${row.name}[${ci}] = ${value}. This is the value returned by the Python interpreter.`;
+ };
+ td.onclick = inspect;
+ td.onkeydown = (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ inspect();
+ }
+ };
+ });
+ });
+ selectRow(selected);
+ $("result").textContent = data.accepted ? "Accepted" : "Rejected";
+ $("result").className = `result ${data.accepted ? "accepted" : "rejected"}`;
+ $("download-trace").disabled = false;
+ $("acceptance-note").textContent =
+ `The final assignment is ${data.rows.at(-1).name}. Its value at the last token is ${data.accepted ? "true (✓), so this input is accepted." : "false (·), so this input is rejected."}`;
+}
+function selectRow(i) {
+ if (!trace) return;
+ selected = Math.max(0, Math.min(i, trace.rows.length - 1));
+ $("trace")
+ .querySelectorAll("[data-row]")
+ .forEach((tr) => tr.classList.toggle("selected", +tr.dataset.row === selected));
+ $("row-label").textContent =
+ `${selected + 1} / ${trace.rows.length} · ${trace.rows[selected].name}`;
+ explainRow();
+ $("prev-row").disabled = selected === 0;
+ $("next-row").disabled = selected === trace.rows.length - 1;
+}
+
+const rowDescriptions = {
+ C_open:
+ "Count opening parentheses from the start through each column, including the current token.",
+ C_close: "Count closing parentheses in the same inclusive prefix.",
+ V: "Mark a violation wherever the number of closes exceeds the number of opens. Equal final totals do not rule out an earlier violation.",
+ CV: "Count how many prefix violations have occurred so far. Once this is positive, an earlier invalid prefix remains recorded.",
+ M: "True if no invalid prefix has occurred up to this position: CV equals zero.",
+ B: "True where the opening and closing counts agree. This checks balance, but does not alone check earlier prefixes.",
+ D: "Combine M and B: no prefix violation and equal counts. The last cell decides acceptance for the whole input.",
+};
+function explainRow() {
+ const row = trace.rows[selected];
+ $("row-explanation").textContent =
+ lastRequest?.source === examples.dyck.source
+ ? rowDescriptions[row.name]
+ : `${row.name}: values produced by this assignment at each token position. Compare this row with the assignment in the program above.`;
+}
+function updateGuide() {
+ const k = $("example").value;
+ $("try-valid").hidden = $("try-invalid").hidden = k !== "dyck";
+ $("example-guide").textContent =
+ k === "dyck"
+ ? "Compare two inputs: both have three opens and three closes, but only one has valid prefixes. Load an input, then choose Run program."
+ : k === "anbn"
+ ? "This program accepts one or more a tokens followed by the same number of b tokens. Run “a a a b b b”, then try “a b a b”: equal totals alone do not ensure the correct order."
+ : "This program checks whether an a is immediately followed by b anywhere in the input. Run “b a b a”, then try “b a a”: the second input has both symbols but no adjacent ab.";
+}
+$("try-valid").onclick = () => {
+ $("tokens").value = "()(())";
+ markStale();
+};
+$("try-invalid").onclick = () => {
+ $("tokens").value = "())(()";
+ markStale();
+};
+$("try-invalid").textContent = "Load invalid input: ())(()";
+updateGuide();
+
+$("prev-row").onclick = () => selectRow(selected - 1);
+$("next-row").onclick = () => selectRow(selected + 1);
+// -----------------------------------------------------------------------------
+// Python worker lifecycle and execution controls
+// -----------------------------------------------------------------------------
+$("run").onclick = () => {
+ if (running) return;
+ let request;
+ try {
+ request = { source: $("program").value, tokens: tokenize($("tokens").value) };
+ if (request.source.length > 12000) throw new Error("Program is limited to 12,000 characters.");
+ } catch (e) {
+ status(e.message, true);
+ return;
+ }
+ running = true;
+ $("run").disabled = true;
+ $("stop").hidden = false;
+ $("download-trace").disabled = true;
+ status("Starting interpreter…");
+ worker ??= new Worker("runtime/python-worker.js");
+ timer = setTimeout(() => {
+ stop();
+ status(
+ "Stopped after 90 seconds. The first run needs internet access to load Python and the interpreter. You can retry.",
+ true,
+ );
+ }, 90000);
+ worker.onmessage = ({ data }) => {
+ if (data.status) status(data.status);
+ if (data.result || data.error) {
+ clearTimeout(timer);
+ running = false;
+ $("run").disabled = false;
+ $("stop").hidden = true;
+ if (data.error) {
+ status(data.error, true);
+ worker.terminate();
+ worker = undefined;
+ } else {
+ lastRequest = request;
+ renderTrace(data.result);
+ status("Evaluated by the pinned Python interpreter.");
+ if ($("program").value !== request.source || $("tokens").value !== request.inputText)
+ markStale();
+ }
+ }
+ };
+ worker.onerror = (e) => {
+ stop();
+ status(`Interpreter could not start: ${e.message}`, true);
+ };
+ request.inputText = $("tokens").value;
+ worker.postMessage(request);
+};
+$("stop").onclick = () => {
+ stop();
+ status("Execution stopped.");
+};
+$("program").oninput = markStale;
+$("tokens").oninput = markStale;
+function reset() {
+ stop();
+ const e = examples[$("example").value];
+ $("tokens").value = e.tokens;
+ $("program").value = e.source;
+ updateGuide();
+ markStale();
+}
+$("example").onchange = reset;
+$("reset").onclick = reset;
+// -----------------------------------------------------------------------------
+// Trace export and shareable program links
+// -----------------------------------------------------------------------------
+function download(name, data) {
+ const url = URL.createObjectURL(
+ new Blob([JSON.stringify(data, null, 2) + "\n"], { type: "application/json" }),
+ );
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+$("download-trace").onclick = () =>
+ download("crasp-trace.json", {
+ ...trace,
+ program: lastRequest.source,
+ interpreter_commit: "1e821d7279015ce0e77830faa7932575447c5164",
+ });
+$("share").onclick = async () => {
+ const url = new URL(location.href);
+ url.hash =
+ "program=" +
+ encodeURIComponent(JSON.stringify({ source: $("program").value, tokens: $("tokens").value }));
+ try {
+ await navigator.clipboard.writeText(url.href);
+ status("Program link copied. Shared links do not execute automatically.");
+ } catch {
+ history.replaceState(null, "", url);
+ status("Program saved in the address bar. Copy the URL to share it.");
+ }
+};
+// -----------------------------------------------------------------------------
+// Uniform attention and positional relations
+// -----------------------------------------------------------------------------
+function tokenElement(label, cls = "") {
+ const el = document.createElement("span");
+ el.className = `token ${cls}`;
+ el.textContent = label;
+ return el;
+}
+function attention() {
+ const n = +$("prefix").value,
+ tokens = ["(", ")", "(", "(", ")", ")"];
+ $("prefix-label").textContent = n;
+ const strip = $("attention-tokens");
+ strip.replaceChildren();
+ ["BOS", ...tokens].forEach((t, k) => {
+ const visible = k <= n;
+ const el = tokenElement(t, visible ? "visible" : "masked");
+ const note = document.createElement("small");
+ note.textContent = visible
+ ? `value ${t === "(" ? 1 : 0} · weight 1/${n + 1}`
+ : "masked · weight 0";
+ el.append(note);
+ strip.append(el);
+ });
+ const count = tokens.slice(0, n).filter((t) => t === "(").length;
+ $("attention-equation").textContent = `${count} opening tokens × 1/${n + 1} = ${count}/${n + 1}`;
+ $("attention-explanation").textContent =
+ `There are ${n + 1} visible positions: BOS and ${n} ordinary tokens. ${count} opening parentheses contribute 1 each; all other values are 0. Their average is ${(count / (n + 1)).toFixed(3)}. A separate BOS-only indicator contributes once, giving 1/${n + 1} as the encoded constant 1.`;
+}
+$("prefix").oninput = attention;
+attention();
+function positions() {
+ const i = +$("query").value,
+ kind = $("relation").value;
+ $("query-label").textContent = i;
+ const strip = $("position-tokens");
+ strip.replaceChildren();
+ for (let j = 0; j < 12; j++) {
+ const match =
+ j <= i && (kind === "previous" ? j === i - 1 : kind === "even" ? (i - j) % 2 === 0 : j === 0);
+ const el = tokenElement(j, match ? "match" : j > i ? "masked" : "");
+ const label = document.createElement("small");
+ label.textContent = [
+ j === i ? "query" : "",
+ j > i ? "masked" : match ? "selected" : "not selected",
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ el.append(label);
+ strip.append(el);
+ }
+ const keys = Array.from({ length: i + 1 }, (_, j) => j).filter((j) =>
+ kind === "previous" ? j === i - 1 : kind === "even" ? (i - j) % 2 === 0 : j === 0,
+ );
+ $("position-selection").textContent =
+ `Query i = ${i}. Selected keys: ${keys.length ? keys.join(", ") : "none"}. ${kind === "previous" ? "Move the query one position to the right: the selected predecessor moves with it." : kind === "even" ? "Moving the query changes which parity of key positions is selected." : "Moving the query leaves key 0 selected; the relation stays tied to the sequence start."}`;
+ $("relation-description").textContent =
+ kind === "previous"
+ ? "j = i − 1: local (distance 1) and translation-invariant. At query 0 there is no preceding token."
+ : kind === "even"
+ ? "i − j is even: translation-invariant, but not local—matching keys can be arbitrarily far away. This is a binary relation, unlike the periodic unary predicate “position is even”."
+ : "j = 0: selects the start of the sequence. Shifting both positions changes the relation; it is not translation-invariant. Only keys in the causal prefix are shown as selected.";
+}
+$("query").oninput = positions;
+$("relation").onchange = positions;
+positions();
+// -----------------------------------------------------------------------------
+// Published figure selector
+// -----------------------------------------------------------------------------
+const figureLabels = {
+ main: "Figure 1: main comparison across tasks and formal languages.",
+ languages: "Figure 2: named formal languages and their generalization results.",
+};
+document.querySelectorAll("[data-figure]").forEach(
+ (b) =>
+ (b.onclick = () => {
+ const k = b.dataset.figure;
+ $("paper-figure").src = `assets/paper-${k}.svg`;
+ $("figure-link").href = `assets/paper-${k}.svg`;
+ $("paper-figure").alt =
+ `Huang et al. (2025), ${figureLabels[k]} See the original paper for the full caption.`;
+ $("paper-caption").textContent =
+ `Huang et al. (2025), ${figureLabels[k]} Published experiment results; not new runs. Open the image for full detail.`;
+ document
+ .querySelectorAll("[data-figure]")
+ .forEach((x) => x.classList.toggle("secondary", x !== b));
+ }),
+);
+// -----------------------------------------------------------------------------
+// Saved evaluation validation, plotting, and import
+// -----------------------------------------------------------------------------
+let evaluations = { schema_version: 1, runs: [] };
+const NS = "http://www.w3.org/2000/svg";
+function svg(tag, attrs = {}, text) {
+ const e = document.createElementNS(NS, tag);
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
+ if (text !== undefined) e.textContent = text;
+ return e;
+}
+function showRun() {
+ const r = evaluations.runs[$("run-select").value];
+ if (!r) return;
+ const points = [...r.points].sort((a, b) => a.length - b.length),
+ max = Math.max(r.train_max_length, r.selection_max_length || 0, ...points.map((p) => p.length));
+ const x = (n) => 60 + (n / max) * 670,
+ y = (n) => 290 - n * 240;
+ const chart = $("evaluation-plot");
+ chart.replaceChildren(svg("title", {}, `${r.task}: ${r.model}, seed ${r.seed}`));
+ chart.append(
+ svg("rect", { x: 60, y: 50, width: x(r.train_max_length) - 60, height: 240, fill: "#e3eee7" }),
+ );
+ if (r.selection_max_length > r.train_max_length)
+ chart.append(
+ svg("rect", {
+ x: x(r.train_max_length),
+ y: 50,
+ width: x(r.selection_max_length) - x(r.train_max_length),
+ height: 240,
+ fill: "#f5e6ca",
+ }),
+ );
+ for (const a of [0, 0.25, 0.5, 0.75, 1]) {
+ chart.append(
+ svg("line", { x1: 60, x2: 730, y1: y(a), y2: y(a), stroke: "#d9ddd8" }),
+ svg("text", { x: 48, y: y(a) + 4, "text-anchor": "end", "font-size": 12 }, `${100 * a}%`),
+ );
+ }
+ for (const n of [
+ ...new Set([0, r.train_max_length, r.selection_max_length, max].filter((n) => n !== undefined)),
+ ])
+ chart.append(svg("text", { x: x(n), y: 310, "text-anchor": "middle", "font-size": 12 }, n));
+ chart.append(
+ svg("polyline", {
+ points: points.map((p) => `${x(p.length)},${y(p.correct / p.total)}`).join(" "),
+ fill: "none",
+ stroke: "#246d66",
+ "stroke-width": 2,
+ }),
+ );
+ for (const p of points) {
+ const c = svg("circle", { cx: x(p.length), cy: y(p.correct / p.total), r: 4, fill: "#246d66" });
+ c.append(svg("title", {}, `Length ${p.length}: ${p.correct}/${p.total}`));
+ chart.append(c);
+ }
+ chart.append(
+ svg(
+ "text",
+ { x: 60, y: 25, "font-size": 13 },
+ "Green: training range · Sand: selection range (when supplied)",
+ ),
+ svg("text", { x: 390, y: 340, "text-anchor": "middle", "font-size": 13 }, "Input length"),
+ );
+ const meta = $("run-meta");
+ meta.replaceChildren();
+ for (const k of [
+ "task",
+ "model",
+ "positional_encoding",
+ "seed",
+ "checkpoint",
+ "evaluated_at",
+ "source",
+ ]) {
+ const p = document.createElement("p");
+ p.textContent = `${k.replaceAll("_", " ")}: ${r[k]}`;
+ meta.append(p);
+ }
+ const table = $("evaluation-table");
+ table.replaceChildren();
+ table.createCaption().textContent =
+ "Recorded sequence accuracy; no interpolation of missing evaluations";
+ const h = table.createTHead().insertRow();
+ ["Length", "Correct", "Total", "Accuracy"].forEach((t) => {
+ const th = document.createElement("th");
+ th.textContent = t;
+ h.append(th);
+ });
+ const body = table.createTBody();
+ points.forEach((p) => {
+ const row = body.insertRow();
+ [p.length, p.correct, p.total, `${((100 * p.correct) / p.total).toFixed(1)}%`].forEach(
+ (v) => (row.insertCell().textContent = v),
+ );
+ });
+}
+function loadEvaluations(data) {
+ evaluations = validateEvaluations(data);
+ const sel = $("run-select");
+ sel.replaceChildren();
+ data.runs.forEach((r, i) => {
+ const o = new Option(`${r.task} · ${r.model} · seed ${r.seed} · ${r.id}`, i);
+ sel.add(o);
+ });
+ const empty = !data.runs.length;
+ if (empty) sel.add(new Option("No saved runs supplied", ""));
+ sel.disabled = empty;
+ $("export-data").disabled = empty;
+ $("evaluation-empty").hidden = !empty;
+ $("evaluation-view").hidden = empty;
+ showRun();
+}
+$("run-select").onchange = showRun;
+$("export-data").onclick = () => download("evaluations.json", evaluations);
+$("evaluation-file").onchange = async (e) => {
+ try {
+ const f = e.target.files[0];
+ if (!f) return;
+ if (f.size > 10_000_000) throw new Error("Use a JSON file smaller than 10 MB.");
+ loadEvaluations(JSON.parse(await f.text()));
+ $("evaluation-status").textContent =
+ `Loaded ${evaluations.runs.length} runs locally. Nothing was uploaded.`;
+ } catch (err) {
+ $("evaluation-status").textContent = err.message;
+ }
+};
+// Initialize the saved trace and gallery; shared URLs never execute automatically.
+try {
+ const response = await fetch("data/default-trace.json");
+ if (!response.ok) throw new Error("Saved trace unavailable.");
+ lastRequest = { source: examples.dyck.source, tokens: tokenize(examples.dyck.tokens) };
+ renderTrace(await response.json());
+} catch (e) {
+ status(e.message, true);
+ $("download-trace").disabled = true;
+}
+try {
+ const response = await fetch("data/evaluations.json");
+ loadEvaluations(await response.json());
+} catch (e) {
+ $("evaluation-status").textContent = `Could not load saved evaluations: ${e.message}`;
+}
+if (location.hash.startsWith("#program=")) {
+ try {
+ const shared = JSON.parse(decodeURIComponent(location.hash.slice(9)));
+ if (
+ typeof shared.source !== "string" ||
+ typeof shared.tokens !== "string" ||
+ shared.source.length > 12000 ||
+ shared.tokens.length > 10000
+ )
+ throw new Error("Invalid shared program.");
+ $("program").value = shared.source;
+ $("tokens").value = shared.tokens;
+ markStale();
+ $("playground").scrollIntoView();
+ } catch (e) {
+ status(`Could not load shared program: ${e.message}`, true);
+ }
+}
diff --git a/tutorial_dual/static/web-assets/interactive/assets/article.js b/tutorial_dual/static/web-assets/interactive/assets/article.js
new file mode 100644
index 0000000..1294d45
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/article.js
@@ -0,0 +1,244 @@
+import { validateEvaluations } from "./lib.js";
+const $ = (id) => document.getElementById(id);
+function download(name, data) {
+ const url = URL.createObjectURL(
+ new Blob([JSON.stringify(data, null, 2) + "\n"], { type: "application/json" }),
+ );
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+function tokenElement(label, cls = "") {
+ const el = document.createElement("span");
+ el.className = `token ${cls}`;
+ el.textContent = label;
+ return el;
+}
+function attention() {
+ const n = +$("prefix").value,
+ tokens = ["(", ")", "(", "(", ")", ")"];
+ $("prefix-label").textContent = n;
+ const strip = $("attention-tokens");
+ strip.replaceChildren();
+ ["BOS", ...tokens].forEach((t, k) => {
+ const visible = k <= n;
+ const el = tokenElement(t, visible ? "visible" : "masked");
+ const note = document.createElement("small");
+ note.textContent = visible
+ ? `value ${t === "(" ? 1 : 0} · weight 1/${n + 1}`
+ : "masked · weight 0";
+ el.append(note);
+ strip.append(el);
+ });
+ const count = tokens.slice(0, n).filter((t) => t === "(").length;
+ $("attention-equation").textContent = `${count} opening tokens × 1/${n + 1} = ${count}/${n + 1}`;
+ $("attention-explanation").textContent =
+ `There are ${n + 1} visible positions: BOS and ${n} ordinary tokens. ${count} opening parentheses contribute 1 each; all other values are 0. Their average is ${(count / (n + 1)).toFixed(3)}. A separate BOS-only indicator contributes once, giving 1/${n + 1} as the encoded constant 1.`;
+}
+$("prefix").oninput = attention;
+attention();
+function positions() {
+ const i = +$("query").value,
+ kind = $("relation").value;
+ $("query-label").textContent = i;
+ const strip = $("position-tokens");
+ strip.replaceChildren();
+ for (let j = 0; j < 12; j++) {
+ const match =
+ j <= i && (kind === "previous" ? j === i - 1 : kind === "even" ? (i - j) % 2 === 0 : j === 0);
+ const el = tokenElement(j, match ? "match" : j > i ? "masked" : "");
+ const label = document.createElement("small");
+ label.textContent = [
+ j === i ? "query" : "",
+ j > i ? "masked" : match ? "selected" : "not selected",
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ el.append(label);
+ strip.append(el);
+ }
+ const keys = Array.from({ length: i + 1 }, (_, j) => j).filter((j) =>
+ kind === "previous" ? j === i - 1 : kind === "even" ? (i - j) % 2 === 0 : j === 0,
+ );
+ $("position-selection").textContent =
+ `Query i = ${i}. Selected keys: ${keys.length ? keys.join(", ") : "none"}. ${kind === "previous" ? "Move the query one position to the right: the selected predecessor moves with it." : kind === "even" ? "Moving the query changes which parity of key positions is selected." : "Moving the query leaves key 0 selected; the relation stays tied to the sequence start."}`;
+ $("relation-description").textContent =
+ kind === "previous"
+ ? "j = i − 1: local (distance 1) and translation-invariant. At query 0 there is no preceding token."
+ : kind === "even"
+ ? "i − j is even: translation-invariant, but not local—matching keys can be arbitrarily far away. This is a binary relation, unlike the periodic unary predicate “position is even”."
+ : "j = 0: selects the start of the sequence. Shifting both positions changes the relation; it is not translation-invariant. Only keys in the causal prefix are shown as selected.";
+}
+$("query").oninput = positions;
+$("relation").onchange = positions;
+positions();
+// -----------------------------------------------------------------------------
+// Published figure selector
+// -----------------------------------------------------------------------------
+const figureLabels = {
+ main: "Figure 1: main comparison across tasks and formal languages.",
+ languages: "Figure 2: named formal languages and their generalization results.",
+};
+document.querySelectorAll("[data-figure]").forEach(
+ (b) =>
+ (b.onclick = () => {
+ const k = b.dataset.figure;
+ $("paper-figure").src = `assets/paper-${k}.svg`;
+ $("figure-link").href = `assets/paper-${k}.svg`;
+ $("paper-figure").alt =
+ `Huang et al. (2025), ${figureLabels[k]} See the original paper for the full caption.`;
+ $("paper-caption").textContent =
+ `Huang et al. (2025), ${figureLabels[k]} Published experiment results; not new runs. Open the image for full detail.`;
+ document
+ .querySelectorAll("[data-figure]")
+ .forEach((x) => x.classList.toggle("secondary", x !== b));
+ }),
+);
+// -----------------------------------------------------------------------------
+// Saved evaluation validation, plotting, and import
+// -----------------------------------------------------------------------------
+let evaluations = { schema_version: 1, runs: [] };
+const NS = "http://www.w3.org/2000/svg";
+function svg(tag, attrs = {}, text) {
+ const e = document.createElementNS(NS, tag);
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
+ if (text !== undefined) e.textContent = text;
+ return e;
+}
+function showRun() {
+ const r = evaluations.runs[$("run-select").value];
+ if (!r) return;
+ const points = [...r.points].sort((a, b) => a.length - b.length),
+ max = Math.max(r.train_max_length, r.selection_max_length || 0, ...points.map((p) => p.length));
+ const x = (n) => 60 + (n / max) * 670,
+ y = (n) => 290 - n * 240;
+ const chart = $("evaluation-plot");
+ chart.replaceChildren(svg("title", {}, `${r.task}: ${r.model}, seed ${r.seed}`));
+ chart.append(
+ svg("rect", { x: 60, y: 50, width: x(r.train_max_length) - 60, height: 240, fill: "#e3eee7" }),
+ );
+ if (r.selection_max_length > r.train_max_length)
+ chart.append(
+ svg("rect", {
+ x: x(r.train_max_length),
+ y: 50,
+ width: x(r.selection_max_length) - x(r.train_max_length),
+ height: 240,
+ fill: "#f5e6ca",
+ }),
+ );
+ for (const a of [0, 0.25, 0.5, 0.75, 1]) {
+ chart.append(
+ svg("line", { x1: 60, x2: 730, y1: y(a), y2: y(a), stroke: "#d9ddd8" }),
+ svg("text", { x: 48, y: y(a) + 4, "text-anchor": "end", "font-size": 12 }, `${100 * a}%`),
+ );
+ }
+ for (const n of [
+ ...new Set([0, r.train_max_length, r.selection_max_length, max].filter((n) => n !== undefined)),
+ ])
+ chart.append(svg("text", { x: x(n), y: 310, "text-anchor": "middle", "font-size": 12 }, n));
+ chart.append(
+ svg("polyline", {
+ points: points.map((p) => `${x(p.length)},${y(p.correct / p.total)}`).join(" "),
+ fill: "none",
+ stroke: "#246d66",
+ "stroke-width": 2,
+ }),
+ );
+ for (const p of points) {
+ const c = svg("circle", { cx: x(p.length), cy: y(p.correct / p.total), r: 4, fill: "#246d66" });
+ c.append(svg("title", {}, `Length ${p.length}: ${p.correct}/${p.total}`));
+ chart.append(c);
+ }
+ chart.append(
+ svg(
+ "text",
+ { x: 60, y: 25, "font-size": 13 },
+ "Green: training range · Sand: selection range (when supplied)",
+ ),
+ svg("text", { x: 390, y: 340, "text-anchor": "middle", "font-size": 13 }, "Input length"),
+ );
+ const meta = $("run-meta");
+ meta.replaceChildren();
+ for (const k of [
+ "task",
+ "model",
+ "positional_encoding",
+ "seed",
+ "checkpoint",
+ "evaluated_at",
+ "source",
+ ]) {
+ const p = document.createElement("p");
+ p.textContent = `${k.replaceAll("_", " ")}: ${r[k]}`;
+ meta.append(p);
+ }
+ const table = $("evaluation-table");
+ table.replaceChildren();
+ table.createCaption().textContent =
+ "Recorded sequence accuracy; no interpolation of missing evaluations";
+ const h = table.createTHead().insertRow();
+ ["Length", "Correct", "Total", "Accuracy"].forEach((t) => {
+ const th = document.createElement("th");
+ th.textContent = t;
+ h.append(th);
+ });
+ const body = table.createTBody();
+ points.forEach((p) => {
+ const row = body.insertRow();
+ [p.length, p.correct, p.total, `${((100 * p.correct) / p.total).toFixed(1)}%`].forEach(
+ (v) => (row.insertCell().textContent = v),
+ );
+ });
+}
+function loadEvaluations(data) {
+ evaluations = validateEvaluations(data);
+ const sel = $("run-select");
+ sel.replaceChildren();
+ data.runs.forEach((r, i) => {
+ const o = new Option(`${r.task} · ${r.model} · seed ${r.seed} · ${r.id}`, i);
+ sel.add(o);
+ });
+ const empty = !data.runs.length;
+ if (empty) sel.add(new Option("No saved runs supplied", ""));
+ sel.disabled = empty;
+ $("export-data").disabled = empty;
+ $("evaluation-empty").hidden = !empty;
+ $("evaluation-view").hidden = empty;
+ showRun();
+}
+$("run-select").onchange = showRun;
+$("export-data").onclick = () => download("evaluations.json", evaluations);
+$("evaluation-file").onchange = async (e) => {
+ try {
+ const f = e.target.files[0];
+ if (!f) return;
+ if (f.size > 10_000_000) throw new Error("Use a JSON file smaller than 10 MB.");
+ loadEvaluations(JSON.parse(await f.text()));
+ $("evaluation-status").textContent =
+ `Loaded ${evaluations.runs.length} runs locally. Nothing was uploaded.`;
+ } catch (err) {
+ $("evaluation-status").textContent = err.message;
+ }
+};
+try {
+ const response = await fetch("data/evaluations.json");
+ loadEvaluations(await response.json());
+} catch (e) {
+ $("evaluation-status").textContent = `Could not load saved evaluations: ${e.message}`;
+}
+
+// Each notebook owns its state. Resize only messages from our two same-origin frames.
+window.addEventListener("message", (event) => {
+ if (event.origin !== location.origin || event.data?.type !== "crasp-notebook-height") return;
+ const frame = [...document.querySelectorAll(".notebook-frame")].find(
+ (f) => f.contentWindow === event.source,
+ );
+ const height = event.data.height;
+ if (frame && Number.isFinite(height) && height > 0 && height < 20000)
+ frame.style.height = `${Math.ceil(height)}px`;
+});
+// Retain old shared-program URLs from earlier editions.
+if (location.hash.startsWith("#program=")) location.replace("text-editor.html" + location.hash);
diff --git a/tutorial_dual/static/web-assets/interactive/assets/editor.js b/tutorial_dual/static/web-assets/interactive/assets/editor.js
new file mode 100644
index 0000000..3c1af25
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/editor.js
@@ -0,0 +1,299 @@
+import { tokenize, validateEvaluations } from "./lib.js";
+const $ = (id) => document.getElementById(id);
+// -----------------------------------------------------------------------------
+// Example programs and playground state
+// -----------------------------------------------------------------------------
+const examples = {
+ dyck: {
+ tokens: "( ) ( ( ) )",
+ source:
+ 'C_open = # "("\nC_close = # ")"\nV = C_open < C_close\nCV = # V\nM = (CV == 0)\nB = (C_open == C_close)\nD = M && B',
+ },
+ anbn: {
+ tokens: "a a a b b b",
+ source:
+ 'A = # "a"\nB = # "b"\nBad = "a" && (B > 0)\nOther = !("a" || "b")\nErrors = #(Bad || Other)\nAccept = (A == B) && (Errors == 0) && (A > 0)',
+ },
+ previous: {
+ tokens: "b a b a",
+ source:
+ '#import pred\nC = #[pred] "a"\nPair = "b" && (C > 0)\nPairs = # Pair\nAccept = Pairs > 0',
+ },
+};
+let trace,
+ selected = 0,
+ worker,
+ timer,
+ running = false,
+ lastRequest;
+function status(message, error = false) {
+ $("engine-status").textContent = message;
+ $("engine-status").classList.toggle("error", error);
+}
+function stop() {
+ worker?.terminate();
+ worker = undefined;
+ clearTimeout(timer);
+ running = false;
+ $("run").disabled = false;
+ $("stop").hidden = true;
+}
+function markStale() {
+ $("download-trace").disabled = true;
+ $("result").textContent = "Edits not evaluated";
+ $("result").className = "result";
+ $("acceptance-note").textContent =
+ "The trace below belongs to the previous input and program. Choose Run program to replace it.";
+ status("The table shows the previous run. Run the interpreter to evaluate your edits.");
+}
+// -----------------------------------------------------------------------------
+// Trace rendering and assignment explanations
+// -----------------------------------------------------------------------------
+function renderTrace(data) {
+ trace = data;
+ selected = Math.min(selected, data.rows.length - 1);
+ const table = $("trace");
+ table.replaceChildren();
+ const caption = table.createCaption();
+ caption.textContent = "Interpreter values by token position (zero-based)";
+ const head = table.createTHead().insertRow();
+ ["Assignment", ...data.tokens.map((t, i) => `${i}: ${t}`)].forEach((t) => {
+ const th = document.createElement("th");
+ th.textContent = t;
+ head.append(th);
+ });
+ const body = table.createTBody();
+ data.rows.forEach((row, ri) => {
+ const tr = body.insertRow();
+ tr.dataset.row = ri;
+ const th = document.createElement("th");
+ const b = document.createElement("button");
+ b.className = "secondary";
+ b.textContent = row.name;
+ b.onclick = () => selectRow(ri);
+ th.append(b);
+ tr.append(th);
+ row.values.forEach((value, ci) => {
+ const td = tr.insertCell();
+ td.textContent = typeof value === "boolean" ? (value ? "✓" : "·") : value;
+ td.tabIndex = 0;
+ const inspect = () => {
+ selectRow(ri);
+ table
+ .querySelectorAll(".active,.contributes")
+ .forEach((e) => e.classList.remove("active", "contributes"));
+ td.classList.add("active");
+ // Only infer a contributor explanation for the named, unedited Dyck example.
+ if (
+ lastRequest?.source === examples.dyck.source &&
+ ["C_open", "C_close"].includes(row.name)
+ ) {
+ const token = row.name === "C_open" ? "(" : ")";
+ [...head.cells].forEach((cell, k) => {
+ if (k > 0 && k - 1 <= ci && data.tokens[k - 1] === token)
+ cell.classList.add("contributes");
+ });
+ $("cell-note").textContent =
+ `${row.name}[${ci}] = ${value}: count “${token}” at positions 0 through ${ci}, inclusive. Highlighted column headings contribute.`;
+ } else
+ $("cell-note").textContent =
+ `${row.name}[${ci}] = ${value}. This is the value returned by the Python interpreter.`;
+ };
+ td.onclick = inspect;
+ td.onkeydown = (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ inspect();
+ }
+ };
+ });
+ });
+ selectRow(selected);
+ $("result").textContent = data.accepted ? "Accepted" : "Rejected";
+ $("result").className = `result ${data.accepted ? "accepted" : "rejected"}`;
+ $("download-trace").disabled = false;
+ $("acceptance-note").textContent =
+ `The final assignment is ${data.rows.at(-1).name}. Its value at the last token is ${data.accepted ? "true (✓), so this input is accepted." : "false (·), so this input is rejected."}`;
+}
+function selectRow(i) {
+ if (!trace) return;
+ selected = Math.max(0, Math.min(i, trace.rows.length - 1));
+ $("trace")
+ .querySelectorAll("[data-row]")
+ .forEach((tr) => tr.classList.toggle("selected", +tr.dataset.row === selected));
+ $("row-label").textContent =
+ `${selected + 1} / ${trace.rows.length} · ${trace.rows[selected].name}`;
+ explainRow();
+ $("prev-row").disabled = selected === 0;
+ $("next-row").disabled = selected === trace.rows.length - 1;
+}
+
+const rowDescriptions = {
+ C_open:
+ "Count opening parentheses from the start through each column, including the current token.",
+ C_close: "Count closing parentheses in the same inclusive prefix.",
+ V: "Mark a violation wherever the number of closes exceeds the number of opens. Equal final totals do not rule out an earlier violation.",
+ CV: "Count how many prefix violations have occurred so far. Once this is positive, an earlier invalid prefix remains recorded.",
+ M: "True if no invalid prefix has occurred up to this position: CV equals zero.",
+ B: "True where the opening and closing counts agree. This checks balance, but does not alone check earlier prefixes.",
+ D: "Combine M and B: no prefix violation and equal counts. The last cell decides acceptance for the whole input.",
+};
+function explainRow() {
+ const row = trace.rows[selected];
+ $("row-explanation").textContent =
+ lastRequest?.source === examples.dyck.source
+ ? rowDescriptions[row.name]
+ : `${row.name}: values produced by this assignment at each token position. Compare this row with the assignment in the program above.`;
+}
+function updateGuide() {
+ const k = $("example").value;
+ $("try-valid").hidden = $("try-invalid").hidden = k !== "dyck";
+ $("example-guide").textContent =
+ k === "dyck"
+ ? "Compare two inputs: both have three opens and three closes, but only one has valid prefixes. Load an input, then choose Run program."
+ : k === "anbn"
+ ? "This program accepts one or more a tokens followed by the same number of b tokens. Run “a a a b b b”, then try “a b a b”: equal totals alone do not ensure the correct order."
+ : "This program checks whether an a is immediately followed by b anywhere in the input. Run “b a b a”, then try “b a a”: the second input has both symbols but no adjacent ab.";
+}
+$("try-valid").onclick = () => {
+ $("tokens").value = "()(())";
+ markStale();
+};
+$("try-invalid").onclick = () => {
+ $("tokens").value = "())(()";
+ markStale();
+};
+$("try-invalid").textContent = "Load invalid input: ())(()";
+updateGuide();
+
+$("prev-row").onclick = () => selectRow(selected - 1);
+$("next-row").onclick = () => selectRow(selected + 1);
+// -----------------------------------------------------------------------------
+// Python worker lifecycle and execution controls
+// -----------------------------------------------------------------------------
+$("run").onclick = () => {
+ if (running) return;
+ let request;
+ try {
+ request = { source: $("program").value, tokens: tokenize($("tokens").value) };
+ if (request.source.length > 12000) throw new Error("Program is limited to 12,000 characters.");
+ } catch (e) {
+ status(e.message, true);
+ return;
+ }
+ running = true;
+ $("run").disabled = true;
+ $("stop").hidden = false;
+ $("download-trace").disabled = true;
+ status("Starting interpreter…");
+ worker ??= new Worker("runtime/python-worker.js");
+ timer = setTimeout(() => {
+ stop();
+ status(
+ "Stopped after 90 seconds. The first run needs internet access to load Python and the interpreter. You can retry.",
+ true,
+ );
+ }, 90000);
+ worker.onmessage = ({ data }) => {
+ if (data.status) status(data.status);
+ if (data.result || data.error) {
+ clearTimeout(timer);
+ running = false;
+ $("run").disabled = false;
+ $("stop").hidden = true;
+ if (data.error) {
+ status(data.error, true);
+ worker.terminate();
+ worker = undefined;
+ } else {
+ lastRequest = request;
+ renderTrace(data.result);
+ status("Evaluated by the pinned Python interpreter.");
+ if ($("program").value !== request.source || $("tokens").value !== request.inputText)
+ markStale();
+ }
+ }
+ };
+ worker.onerror = (e) => {
+ stop();
+ status(`Interpreter could not start: ${e.message}`, true);
+ };
+ request.inputText = $("tokens").value;
+ worker.postMessage(request);
+};
+$("stop").onclick = () => {
+ stop();
+ status("Execution stopped.");
+};
+$("program").oninput = markStale;
+$("tokens").oninput = markStale;
+function reset() {
+ stop();
+ const e = examples[$("example").value];
+ $("tokens").value = e.tokens;
+ $("program").value = e.source;
+ updateGuide();
+ markStale();
+}
+$("example").onchange = reset;
+$("reset").onclick = reset;
+// -----------------------------------------------------------------------------
+// Trace export and shareable program links
+// -----------------------------------------------------------------------------
+function download(name, data) {
+ const url = URL.createObjectURL(
+ new Blob([JSON.stringify(data, null, 2) + "\n"], { type: "application/json" }),
+ );
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+$("download-trace").onclick = () =>
+ download("crasp-trace.json", {
+ ...trace,
+ program: lastRequest.source,
+ interpreter_commit: "1e821d7279015ce0e77830faa7932575447c5164",
+ });
+$("share").onclick = async () => {
+ const url = new URL(location.href);
+ url.hash =
+ "program=" +
+ encodeURIComponent(JSON.stringify({ source: $("program").value, tokens: $("tokens").value }));
+ try {
+ await navigator.clipboard.writeText(url.href);
+ status("Program link copied. Shared links do not execute automatically.");
+ } catch {
+ history.replaceState(null, "", url);
+ status("Program saved in the address bar. Copy the URL to share it.");
+ }
+};
+// Initialize the saved trace and gallery; shared URLs never execute automatically.
+try {
+ const response = await fetch("data/default-trace.json");
+ if (!response.ok) throw new Error("Saved trace unavailable.");
+ lastRequest = { source: examples.dyck.source, tokens: tokenize(examples.dyck.tokens) };
+ renderTrace(await response.json());
+} catch (e) {
+ status(e.message, true);
+ $("download-trace").disabled = true;
+}
+if (location.hash.startsWith("#program=")) {
+ try {
+ const shared = JSON.parse(decodeURIComponent(location.hash.slice(9)));
+ if (
+ typeof shared.source !== "string" ||
+ typeof shared.tokens !== "string" ||
+ shared.source.length > 12000 ||
+ shared.tokens.length > 10000
+ )
+ throw new Error("Invalid shared program.");
+ $("program").value = shared.source;
+ $("tokens").value = shared.tokens;
+ markStale();
+ $("playground").scrollIntoView();
+ } catch (e) {
+ status(`Could not load shared program: ${e.message}`, true);
+ }
+}
diff --git a/tutorial_dual/static/web-assets/interactive/assets/lib.js b/tutorial_dual/static/web-assets/interactive/assets/lib.js
new file mode 100644
index 0000000..5b43324
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/lib.js
@@ -0,0 +1,61 @@
+export function tokenize(text) {
+ const s = text.trim();
+ if (!s) throw new Error("Enter at least one token.");
+ const tokens = /^[()]+$/.test(s) ? [...s] : s.split(/\s+/);
+ if (tokens.length > 128) throw new Error("Use at most 128 tokens.");
+ return tokens;
+}
+export function validateEvaluations(data) {
+ if (!data || data.schema_version !== 1 || !Array.isArray(data.runs))
+ throw new Error("Expected schema_version: 1 and a runs array.");
+ if (data.runs.length > 100) throw new Error("Import at most 100 runs.");
+ const ids = new Set();
+ for (const r of data.runs) {
+ for (const key of [
+ "id",
+ "task",
+ "model",
+ "positional_encoding",
+ "checkpoint",
+ "evaluated_at",
+ "source",
+ ]) {
+ if (typeof r[key] !== "string" || !r[key].trim())
+ throw new Error(`Each run needs a nonempty ${key}.`);
+ }
+ if (ids.has(r.id)) throw new Error(`Duplicate run id: ${r.id}`);
+ ids.add(r.id);
+ if (!Number.isInteger(r.seed) || r.seed < 0)
+ throw new Error("seed must be a nonnegative integer.");
+ if (r.metric !== "sequence_accuracy")
+ throw new Error(
+ "Only sequence_accuracy is supported; do not mix token and sequence accuracy.",
+ );
+ if (!Number.isSafeInteger(r.train_max_length) || r.train_max_length < 1)
+ throw new Error("train_max_length must be a positive integer.");
+ if (
+ r.selection_max_length !== undefined &&
+ (!Number.isSafeInteger(r.selection_max_length) || r.selection_max_length < r.train_max_length)
+ )
+ throw new Error("selection_max_length must be at least train_max_length.");
+ if (!Number.isFinite(Date.parse(r.evaluated_at)))
+ throw new Error("evaluated_at must be a valid timestamp.");
+ if (!Array.isArray(r.points) || !r.points.length || r.points.length > 10000)
+ throw new Error("Each run needs 1–10,000 evaluation points.");
+ const lengths = new Set();
+ for (const p of r.points) {
+ if (!Number.isSafeInteger(p.length) || p.length < 1 || lengths.has(p.length))
+ throw new Error("Evaluation lengths must be unique positive integers.");
+ lengths.add(p.length);
+ if (
+ !Number.isSafeInteger(p.total) ||
+ p.total < 1 ||
+ !Number.isSafeInteger(p.correct) ||
+ p.correct < 0 ||
+ p.correct > p.total
+ )
+ throw new Error("Each point needs integer counts with 0 ≤ correct ≤ total and total > 0.");
+ }
+ }
+ return data;
+}
diff --git a/tutorial_dual/static/web-assets/interactive/assets/notebook-engine.js b/tutorial_dual/static/web-assets/interactive/assets/notebook-engine.js
new file mode 100644
index 0000000..9fae65a
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/notebook-engine.js
@@ -0,0 +1,74 @@
+import { TYPES, validateProgram } from "./notebook-model.js";
+
+// Execute the typed block model directly. No parser, network, worker, or eval().
+// The public editor has at most 24 blocks and 16 tokens. Arithmetic is checked
+// rather than silently accepting loss of integer precision.
+export function evaluateBlocks(nodes, tokens, output) {
+ validateProgram(nodes);
+ if (
+ !Array.isArray(tokens) ||
+ tokens.length < 1 ||
+ tokens.length > 16 ||
+ tokens.some((t) => !["a", "b", "(", ")"].includes(t))
+ )
+ throw new Error("Use 1–16 tokens from a, b, (, ).");
+ if (output && !nodes.some((n) => n.id === output && TYPES[n.kind] === "bool"))
+ throw new Error("Choose a yes/no output.");
+ const env = new Map(),
+ rows = [];
+ const at = (name, i) => (name === "0" ? 0 : env.get(name)[i]);
+ for (const n of nodes) {
+ let total = 0;
+ const values = tokens.map((token, i) => {
+ switch (n.kind) {
+ case "token":
+ return token === n.token;
+ case "tokenCount":
+ total += Number(token === n.token);
+ return total;
+ case "count":
+ total += Number(at(n.input, i));
+ return total;
+ case "not":
+ return !at(n.input, i);
+ case "and":
+ return at(n.left, i) && at(n.right, i);
+ case "or":
+ return at(n.left, i) || at(n.right, i);
+ case "add": {
+ const sum = at(n.left, i) + at(n.right, i);
+ if (!Number.isSafeInteger(sum))
+ throw new Error("This value exceeds the exact integer range of this learning editor.");
+ return sum;
+ }
+ case "compare": {
+ const left = at(n.left, i),
+ right = at(n.right, i);
+ switch (n.op) {
+ case "<":
+ return left < right;
+ case "<=":
+ return left <= right;
+ case "==":
+ return left === right;
+ case ">=":
+ return left >= right;
+ case ">":
+ return left > right;
+ }
+ }
+ }
+ });
+ env.set(n.id, values);
+ rows.push({ name: n.id, values });
+ }
+ const result = output ? env.get(output) : tokens.map(() => true);
+ rows.push({ name: "Result", values: [...result] });
+ return {
+ tokens: [...tokens],
+ rows,
+ accepted: result.at(-1),
+ engine: "typed-block-evaluator",
+ index_base: 0,
+ };
+}
diff --git a/tutorial_dual/static/web-assets/interactive/assets/notebook-model.js b/tutorial_dual/static/web-assets/interactive/assets/notebook-model.js
new file mode 100644
index 0000000..bc8228d
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/notebook-model.js
@@ -0,0 +1,155 @@
+// A small typed subset of C-RASP for the visual editor. This compiles syntax;
+// all vector evaluation is performed by the original Python interpreter.
+export const TYPES = {
+ token: "bool",
+ tokenCount: "int",
+ count: "int",
+ compare: "bool",
+ and: "bool",
+ or: "bool",
+ not: "bool",
+ add: "int",
+};
+export const DYCK = [
+ { id: "Opens", kind: "tokenCount", token: "(" },
+ { id: "Closes", kind: "tokenCount", token: ")" },
+ { id: "Violation", kind: "compare", left: "Opens", op: "<", right: "Closes" },
+ { id: "Violations", kind: "count", input: "Violation" },
+ { id: "Safe", kind: "compare", left: "Violations", op: "==", right: "0" },
+ { id: "Balanced", kind: "compare", left: "Opens", op: "==", right: "Closes" },
+ { id: "Accept", kind: "and", left: "Safe", right: "Balanced" },
+];
+export const CONTAINS = [
+ { id: "A1", kind: "token", token: "a" },
+ { id: "A2", kind: "count", input: "A1" },
+ { id: "A3", kind: "compare", left: "A2", op: ">", right: "0" },
+];
+export function references(node) {
+ if (node.kind === "count" || node.kind === "not") return [node.input];
+ if (["compare", "and", "or", "add"].includes(node.kind))
+ return [node.left, node.right].filter((x) => x !== "0");
+ return [];
+}
+export function validateProgram(nodes) {
+ if (nodes.length > 24) throw new Error("This learning editor supports up to 24 blocks.");
+ const seen = new Map();
+ const need = (id, type) => {
+ if (id === "0" && type === "int") return;
+ if (seen.get(id) !== type)
+ throw new Error(
+ `Choose an earlier ${type === "bool" ? "yes/no" : "number"} variable for ${id}.`,
+ );
+ };
+ for (const n of nodes) {
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(n.id) || n.id === "Result" || seen.has(n.id))
+ throw new Error("Variable names must be unique and valid.");
+ if (!Object.hasOwn(TYPES, n.kind)) throw new Error("Unknown block.");
+ if (["token", "tokenCount"].includes(n.kind) && !["a", "b", "(", ")"].includes(n.token))
+ throw new Error("Choose a supported token.");
+ if (n.kind === "count" || n.kind === "not") need(n.input, "bool");
+ if (["compare", "add"].includes(n.kind)) {
+ need(n.left, "int");
+ need(n.right, "int");
+ }
+ if (["and", "or"].includes(n.kind)) {
+ need(n.left, "bool");
+ need(n.right, "bool");
+ }
+ if (n.kind === "compare" && !["<", "<=", "==", ">=", ">"].includes(n.op))
+ throw new Error("Choose a comparison.");
+ seen.set(n.id, TYPES[n.kind]);
+ }
+ return true;
+}
+export function expression(n) {
+ switch (n.kind) {
+ case "token":
+ return JSON.stringify(n.token);
+ case "tokenCount":
+ return `# ${JSON.stringify(n.token)}`;
+ case "count":
+ return `# ${n.input}`;
+ case "not":
+ return `! ${n.input}`;
+ case "compare":
+ return `${n.left} ${n.op} ${n.right}`;
+ case "and":
+ return `${n.left} && ${n.right}`;
+ case "or":
+ return `${n.left} || ${n.right}`;
+ case "add":
+ return `${n.left} + ${n.right}`;
+ }
+}
+export function compile(nodes, output) {
+ validateProgram(nodes);
+ if (output && !nodes.some((n) => n.id === output && TYPES[n.kind] === "bool"))
+ throw new Error("The decision must be a yes/no variable.");
+ // The bridge expects a Boolean final row. With no decision selected, Result
+ // is merely an execution sentinel and is never presented as recognition.
+ return [
+ ...nodes.map((n) => `${n.id} = ${expression(n)}`),
+ `Result = ${output || "(0 == 0)"}`,
+ ].join("\n");
+}
+export function moveBlock(nodes, from, to) {
+ const result = structuredClone(nodes),
+ [node] = result.splice(from, 1);
+ result.splice(to, 0, node);
+ validateProgram(result);
+ return result;
+}
+export const QUESTIONS = [
+ {
+ row: "Opens",
+ col: 3,
+ prompt: "How many opening parentheses occur through position 3, including that position?",
+ options: [1, 2, 3, 4],
+ why: "Positions 0, 2 and 3 contain an opening parenthesis. Prefix counts include the current token.",
+ },
+ {
+ row: "Closes",
+ col: 2,
+ prompt: "At position 2, how many closing parentheses have occurred?",
+ options: [0, 1, 2, 3],
+ why: "Only position 1 is a closing parenthesis in this prefix.",
+ },
+ {
+ row: "Balanced",
+ col: 3,
+ prompt: "Is the prefix ending at position 3 balanced?",
+ options: [true, false],
+ why: "There are 3 opens and 1 close, so the two counts are different.",
+ },
+ {
+ row: "Safe",
+ col: 4,
+ prompt: "At position 4, has every prefix so far avoided an excess of closing parentheses?",
+ options: [true, false],
+ why: "No prefix of ()(()) has more closes than opens. Safe is true even when the current counts are unequal.",
+ },
+ {
+ row: "Accept",
+ col: 5,
+ prompt: "Does the complete input satisfy both checks?",
+ options: [true, false],
+ why: "The final counts are equal and no earlier prefix had an excess of closes.",
+ },
+];
+
+// General-purpose starter programs, separate from the Dyck-1 lesson.
+export const BOTH_SYMBOLS = [
+ { id: "As", kind: "tokenCount", token: "a" },
+ { id: "Bs", kind: "tokenCount", token: "b" },
+ { id: "HasA", kind: "compare", left: "As", op: ">", right: "0" },
+ { id: "HasB", kind: "compare", left: "Bs", op: ">", right: "0" },
+ { id: "Accept", kind: "and", left: "HasA", right: "HasB" },
+];
+export const A_BEFORE_B = [
+ { id: "As", kind: "tokenCount", token: "a" },
+ { id: "SeenA", kind: "compare", left: "As", op: ">", right: "0" },
+ { id: "IsB", kind: "token", token: "b" },
+ { id: "Witness", kind: "and", left: "SeenA", right: "IsB" },
+ { id: "Witnesses", kind: "count", input: "Witness" },
+ { id: "Accept", kind: "compare", left: "Witnesses", op: ">", right: "0" },
+];
diff --git a/tutorial_dual/static/web-assets/interactive/assets/notebook.css b/tutorial_dual/static/web-assets/interactive/assets/notebook.css
new file mode 100644
index 0000000..2cdb62c
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/notebook.css
@@ -0,0 +1,656 @@
+/* Notebook comparisons: a focused working surface, separate from the article. */
+.notebook-shell {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 36px 28px 70px;
+}
+.notebook-intro {
+ max-width: 780px;
+ margin-bottom: 28px;
+}
+.notebook-intro h1 {
+ font-size: 3.3rem;
+ margin: 12px 0 20px;
+}
+.notebook-intro p {
+ margin-bottom: 12px;
+}
+.mode-nav {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 10px;
+ margin: 26px 0;
+}
+.mode-nav button {
+ text-align: left;
+ background: transparent;
+ color: var(--ink);
+ border-color: var(--line);
+ padding: 15px;
+ min-height: 100px;
+}
+.mode-nav button[aria-pressed="true"] {
+ background: var(--ink);
+ color: white;
+ border-color: var(--ink);
+}
+.mode-nav small {
+ display: block;
+ font-size: 0.75rem;
+ line-height: 1.4;
+ margin-top: 7px;
+}
+.mode-nav strong {
+ font-size: 0.9rem;
+}
+.experiment {
+ background: var(--white);
+ border: 1px solid var(--line);
+ padding: 28px;
+}
+.experiment-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ align-items: start;
+}
+.experiment-heading h2 {
+ margin: 10px 0 16px;
+}
+.experiment-heading button {
+ font-size: 0.8rem;
+}
+.mode-description {
+ max-width: 820px;
+}
+.interaction-message {
+ font-size: 0.85rem;
+ color: var(--teal);
+ margin: 8px 0;
+ min-height: 24px;
+}
+.interaction-message.error {
+ color: #a32a20;
+}
+.input-workbench {
+ background: var(--paper);
+ padding: 18px;
+ margin: 18px 0;
+}
+.input-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.input-heading h3 {
+ margin: 0;
+}
+.input-heading p {
+ margin: 0;
+}
+.token-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 16px 0;
+}
+.input-token {
+ background: white;
+ color: var(--ink);
+ border-color: var(--line);
+ min-width: 56px;
+ padding: 7px 11px;
+}
+.input-token .symbol {
+ font: 1.8rem var(--serif);
+ display: block;
+}
+.input-token small {
+ font-size: 0.65rem;
+ display: block;
+}
+.input-token[aria-pressed="true"] {
+ outline: 2px solid var(--teal);
+ outline-offset: 2px;
+}
+.input-token.contributor {
+ background: #d9e8df;
+}
+.input-token.outside {
+ opacity: 0.42;
+}
+.input-token .count-under {
+ font: 600 0.85rem var(--sans);
+ margin-top: 6px;
+ white-space: nowrap;
+}
+.toolbar {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+.toolbar button {
+ font-size: 0.8rem;
+}
+.toolbar label {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 0.85rem;
+}
+.toolbar select {
+ width: auto;
+}
+.step-copy {
+ background: #e9eee7;
+ padding: 20px;
+ margin: 18px 0;
+}
+.step-copy h3 {
+ margin: 0 0 10px;
+}
+.step-copy p:last-child {
+ margin: 0;
+}
+.step-progress {
+ display: flex;
+ gap: 6px;
+ margin: 14px 0;
+}
+.step-progress span {
+ height: 4px;
+ flex: 1;
+ background: var(--line);
+}
+.step-progress span.done {
+ background: var(--teal);
+}
+.result-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.result-header h3 {
+ margin: 20px 0 10px;
+}
+.decision {
+ font-weight: 600;
+ margin: 10px 0;
+}
+.decision.no {
+ color: var(--accent);
+}
+.decision.yes {
+ color: var(--teal);
+}
+.value-scroll {
+ overflow: auto;
+}
+.value-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+.value-table caption {
+ text-align: left;
+ margin-bottom: 8px;
+ color: var(--muted);
+}
+.value-table th,
+.value-table td {
+ padding: 9px;
+ text-align: center;
+ border-bottom: 1px solid var(--line);
+ white-space: nowrap;
+}
+.value-table th:first-child {
+ text-align: left;
+ min-width: 130px;
+}
+.value-table button {
+ font-size: 0.8rem;
+ padding: 6px 9px;
+ background: transparent;
+ color: var(--ink);
+ border-color: transparent;
+ min-width: 40px;
+}
+.value-table button:hover,
+.value-table button:focus-visible {
+ background: #e3eee7;
+}
+.value-table .focus-row {
+ background: #edf2ed;
+}
+.value-table .focus-cell {
+ outline: 2px solid var(--teal);
+ outline-offset: -2px;
+}
+.inspection {
+ font-size: 0.9rem;
+ padding: 14px 0;
+ min-height: 30px;
+ margin: 0;
+}
+.runtime-bar {
+ border-top: 1px solid var(--line);
+ padding: 16px 0;
+ font-size: 0.78rem;
+ color: var(--muted);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.runtime-bar button {
+ font-size: 0.8rem;
+}
+.code-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin: 12px 0;
+}
+.code-actions button {
+ font-size: 0.8rem;
+}
+#generated-code {
+ background: var(--paper);
+ padding: 20px;
+ font-size: 0.9rem;
+}
+#results[aria-busy="true"] .value-table,
+#results[aria-busy="true"] .decision {
+ visibility: hidden;
+}
+.builder-grid {
+ display: grid;
+ grid-template-columns: 210px 1fr;
+ gap: 20px;
+ align-items: start;
+}
+.palette {
+ background: var(--paper);
+ padding: 16px;
+}
+.palette h3 {
+ margin-top: 0;
+}
+.palette button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ font-size: 0.85rem;
+ margin: 8px 0;
+ background: white;
+ color: var(--ink);
+ border-color: var(--line);
+}
+.palette button small {
+ display: block;
+ color: var(--muted);
+ font-size: 0.73rem;
+ margin-top: 4px;
+}
+.palette button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+.block-stack {
+ min-width: 0;
+ min-height: 160px;
+}
+.program-block {
+ border: 1px solid var(--line);
+ border-left: 4px solid var(--teal);
+ padding: 14px;
+ margin-bottom: 10px;
+ background: white;
+}
+.program-block.number {
+ border-left-color: #ad672d;
+}
+.block-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+}
+.block-title strong {
+ font-size: 0.9rem;
+}
+.block-tools {
+ display: flex;
+ gap: 4px;
+}
+.block-tools button {
+ padding: 3px 8px;
+ background: transparent;
+ color: var(--muted);
+ border-color: var(--line);
+ font-size: 0.75rem;
+}
+.block-fields {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ font-size: 0.86rem;
+}
+.block-fields label {
+ display: flex;
+ gap: 5px;
+ align-items: center;
+}
+.block-fields select {
+ width: auto;
+ padding: 5px;
+ font-size: 0.83rem;
+}
+.block-values {
+ font:
+ 0.8rem/1.5 ui-monospace,
+ monospace;
+ margin-top: 12px;
+ display: flex;
+ gap: 5px;
+ flex-wrap: wrap;
+}
+.block-values span {
+ background: var(--pale);
+ padding: 3px 7px;
+}
+.drop-end {
+ border: 2px dashed #9aa9a7;
+ padding: 22px;
+ text-align: center;
+ font-size: 0.85rem;
+ color: var(--muted);
+}
+.drag-over {
+ outline: 3px solid var(--teal);
+ background: var(--pale);
+}
+.type-tag {
+ font-size: 0.68rem;
+ color: var(--muted);
+ font-weight: normal;
+}
+.builder-output {
+ margin: 15px 0;
+ padding: 14px;
+ background: var(--paper);
+}
+.builder-output label {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+.builder-output select {
+ width: auto;
+}
+.builder-output p {
+ margin: 10px 0 0;
+ font-size: 0.8rem;
+}
+.puzzle-rule {
+ padding: 20px;
+ background: var(--pale);
+ font-size: 1.1rem;
+ margin: 15px 0;
+}
+.puzzle-rule label {
+ display: block;
+ margin: 12px 0;
+}
+.puzzle-rule select {
+ display: inline-block;
+ width: auto;
+ max-width: 100%;
+ margin: 6px 8px;
+ font-size: 1rem;
+}
+.case-buttons {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.case-buttons button {
+ font-size: 0.85rem;
+ background: transparent;
+ color: var(--ink);
+ border-color: var(--line);
+}
+.case-buttons button[aria-pressed="true"] {
+ background: var(--ink);
+ color: white;
+}
+.puzzle-feedback {
+ padding: 16px;
+ border-left: 3px solid var(--accent);
+ background: var(--paper);
+ margin: 15px 0;
+}
+.puzzle-feedback p {
+ margin: 0;
+}
+.prediction {
+ padding: 20px;
+ background: var(--pale);
+ margin: 18px 0;
+}
+.prediction h3 {
+ margin-top: 0;
+}
+.prediction-options {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin: 15px 0;
+}
+.prediction-options button {
+ min-width: 65px;
+}
+.prediction-feedback {
+ min-height: 28px;
+ margin: 12px 0 0;
+}
+.design-notes {
+ padding-top: 36px;
+}
+.design-notes h2 {
+ font-size: 1.7rem;
+}
+.design-notes dl {
+ display: grid;
+ grid-template-columns: 210px 1fr;
+ gap: 8px 20px;
+ font-size: 0.9rem;
+}
+.design-notes dt {
+ font-weight: 600;
+}
+.design-notes dd {
+ margin: 0 0 14px;
+}
+.live-summary {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin: 18px 0;
+}
+.live-summary div {
+ border-top: 2px solid var(--teal);
+ padding: 12px;
+ background: var(--paper);
+}
+.live-summary strong {
+ display: block;
+ font: 1.8rem var(--serif);
+}
+.live-summary span {
+ font-size: 0.8rem;
+}
+.drag-help {
+ font-size: 0.78rem;
+ color: var(--muted);
+}
+@media (max-width: 800px) {
+ .mode-nav {
+ grid-template-columns: repeat(3, 1fr);
+ }
+ .builder-grid {
+ grid-template-columns: 1fr;
+ }
+ .palette {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+ .palette h3,
+ .palette p {
+ width: 100%;
+ margin: 0;
+ }
+ .palette button {
+ width: auto;
+ flex: 1 1 130px;
+ margin: 0;
+ }
+ .notebook-intro h1 {
+ font-size: 2.5rem;
+ }
+ .experiment {
+ padding: 18px;
+ }
+}
+@media (max-width: 500px) {
+ .notebook-shell {
+ padding: 22px 14px;
+ }
+ .mode-nav {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px;
+ }
+ .mode-nav button {
+ padding: 10px;
+ min-height: 90px;
+ }
+ .mode-nav button:last-child {
+ grid-column: span 2;
+ min-height: 65px;
+ }
+ .experiment-heading {
+ flex-direction: column;
+ }
+ .experiment-heading h2 {
+ font-size: 1.7rem;
+ }
+ .input-workbench {
+ padding: 12px;
+ }
+ .input-token {
+ min-width: 43px;
+ padding: 6px;
+ }
+ .design-notes dl {
+ display: block;
+ }
+ .design-notes dd {
+ margin-top: 5px;
+ }
+ .live-summary {
+ gap: 6px;
+ }
+ .live-summary div {
+ padding: 8px;
+ }
+ .live-summary span {
+ font-size: 0.72rem;
+ }
+}
+
+/* Article embeds retain all activity controls, without prototype navigation. */
+html[data-embed] body {
+ background: var(--white);
+}
+html[data-embed] header,
+html[data-embed] .notebook-intro,
+html[data-embed] .mode-nav,
+html[data-embed] .design-notes,
+html[data-embed] .skip {
+ display: none;
+}
+html[data-embed] .notebook-shell {
+ padding: 0;
+ margin: 0;
+ max-width: none;
+}
+html[data-embed] .experiment {
+ padding: 20px;
+ margin: 0;
+}
+html[data-embed] .experiment-heading h2 {
+ font-size: 1.55rem;
+}
+html[data-embed] .experiment-heading .eyebrow {
+ display: none;
+}
+html[data-embed] .builder-grid {
+ grid-template-columns: 175px minmax(0, 1fr);
+ gap: 12px;
+}
+@media (max-width: 700px) {
+ html[data-embed] .builder-grid {
+ grid-template-columns: 1fr;
+ }
+ html[data-embed] .experiment {
+ padding: 12px;
+ }
+}
+
+.input-token:hover {
+ background: #e3eee7;
+ color: var(--ink);
+}
+
+/* Embedded walkthroughs need a full-width surface at narrow host sizes. */
+html[data-embed] .notebook-shell,
+html[data-embed] .experiment {
+ width: 100%;
+ min-width: 0;
+}
+html[data-embed] .value-scroll {
+ max-width: 100%;
+ overflow-x: auto;
+}
+html[data-embed] .value-table {
+ min-width: 620px;
+}
+html[data-embed] .value-table caption {
+ white-space: normal;
+}
+@media (max-width: 600px) {
+ html[data-embed] .experiment {
+ padding: 12px 10px;
+ }
+ html[data-embed] .experiment-heading {
+ flex-direction: column;
+ }
+ html[data-embed] .toolbar {
+ gap: 8px;
+ }
+ html[data-embed] .step-copy {
+ padding: 14px;
+ }
+ html[data-embed] .input-workbench {
+ padding: 12px 8px;
+ }
+}
diff --git a/tutorial_dual/static/web-assets/interactive/assets/notebook.js b/tutorial_dual/static/web-assets/interactive/assets/notebook.js
new file mode 100644
index 0000000..4b6df8a
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/notebook.js
@@ -0,0 +1,885 @@
+import {
+ TYPES,
+ DYCK,
+ CONTAINS,
+ BOTH_SYMBOLS,
+ A_BEFORE_B,
+ QUESTIONS,
+ compile,
+ expression,
+ moveBlock,
+ references,
+} from "./notebook-model.js";
+import { evaluateBlocks } from "./notebook-engine.js";
+const $ = (id) => document.getElementById(id);
+const requestedEmbed = new URLSearchParams(location.search).get("embed");
+const embeddedMode = ["walk", "build"].includes(requestedEmbed) ? requestedEmbed : null;
+if (embeddedMode) document.documentElement.dataset.embed = embeddedMode;
+const clone = (x) => structuredClone(x);
+const MODES = [
+ {
+ id: "walk",
+ title: "01 · Walkthrough",
+ short: "One assignment at a time",
+ heading: "Follow the computation",
+ description:
+ "Start with one row, then add the next. Every column belongs to a token position. Select a token to inspect the prefix ending there; future tokens do not contribute to its count.",
+ },
+ {
+ id: "build",
+ title: "02 · Block builder",
+ short: "Assemble a typed program",
+ heading: "Build with operations",
+ description:
+ "Drag an operation onto the program, or click it to add it. Number operations accept number inputs; yes/no operations accept yes/no inputs. Menus offer only earlier compatible variables. Values update as you change a block.",
+ },
+ {
+ id: "repair",
+ title: "03 · Repair a rule",
+ short: "Learn from a counterexample",
+ heading: "What is missing from this rule?",
+ description:
+ "Equal final counts do not guarantee balanced parentheses. The starter rule accepts a string with an invalid prefix. Change the two choices and inspect the short test inputs to find out why.",
+ },
+ {
+ id: "tokens",
+ title: "04 · Token sandbox",
+ short: "Change symbols, watch values",
+ heading: "Edit the input, watch the counts",
+ description:
+ "Click a parenthesis to flip it. Add or remove tokens using the buttons. The program stays fixed: watch how a small input change affects this and later positions, but not earlier ones.",
+ },
+ {
+ id: "predict",
+ title: "05 · Predict a value",
+ short: "Think before revealing",
+ heading: "Predict one cell",
+ description:
+ "Look at the highlighted prefix and predict the requested value. The answer is computed from the same program used in the walkthrough. After choosing, reveal the relevant row and compare it with your reasoning.",
+ },
+];
+const explanations = {
+ Opens: "Count opening parentheses through each position, including that token.",
+ Closes:
+ "Count closing parentheses through each position. These counts share the same prefix as Opens.",
+ Violation:
+ "Yes means that this prefix has more closes than opens. One such prefix is enough to invalidate the input.",
+ Violations:
+ "Count the positions where Violation is yes. This remembers an earlier problem even if the counts later become equal.",
+ Safe: "Yes means no violation has occurred in any prefix so far.",
+ Balanced:
+ "Yes means the two counts at this position are equal. This does not check earlier prefixes.",
+ Accept:
+ "Both checks are needed: no invalid prefix and equal counts. The value at the final position decides the whole input.",
+};
+const cases = [
+ { tokens: "())(()", want: false, label: "Equal totals, bad prefix" },
+ { tokens: "()(())", want: true, label: "Balanced input" },
+ { tokens: "(()", want: false, label: "Unclosed input" },
+ { tokens: ")(", want: false, label: "Starts with a close" },
+];
+let mode = "walk",
+ tokens = [..."()(())"],
+ step = 0,
+ focus = 3,
+ question = 0,
+ nodes = clone(CONTAINS),
+ output = "A3",
+ nextId = 4,
+ repairOp = "==",
+ repairJoin = "or",
+ caseIndex = 0,
+ currentTrace = null,
+ drag = null;
+function el(tag, text, cls) {
+ const node = document.createElement(tag);
+ if (text !== undefined) node.textContent = text;
+ if (cls) node.className = cls;
+ return node;
+}
+function button(text, fn, cls = "secondary") {
+ const node = el("button", text, cls);
+ node.type = "button";
+ node.onclick = fn;
+ return node;
+}
+function say(text, error = false) {
+ $("interaction-message").textContent = text;
+ $("interaction-message").classList.toggle("error", error);
+}
+function choice(label, options, value, onChange) {
+ const wrap = el("label", label),
+ select = el("select");
+ for (const o of options) {
+ const [v, t] = Array.isArray(o) ? o : [o, o];
+ select.add(new Option(t, v));
+ }
+ select.value = value;
+ select.onchange = () => onChange(select.value);
+ wrap.append(select);
+ return wrap;
+}
+function fmt(value) {
+ return typeof value === "boolean" ? (value ? "yes" : "no") : String(value);
+}
+function currentNodes() {
+ if (mode === "build") return nodes;
+ const n = clone(DYCK);
+ if (mode === "repair") {
+ n[2].op = repairOp;
+ n[6].kind = repairJoin;
+ }
+ return n;
+}
+function currentOutput() {
+ return mode === "build" ? output : "Accept";
+}
+function source() {
+ return compile(currentNodes(), currentOutput());
+}
+function rowValue(name, col) {
+ return currentTrace?.rows.find((r) => r.name === name)?.values[col];
+}
+function schedule() {
+ // The small typed program runs synchronously: every displayed value belongs
+ // to the current controls, including during rapid edits or offline use.
+ currentTrace = null;
+ $("results").setAttribute("aria-busy", "true");
+ $("decision").textContent = "";
+ $("inspection").textContent = "";
+ $("source-code").textContent = source();
+ if (mode === "tokens") $("mode-workspace").replaceChildren();
+ try {
+ currentTrace = evaluateBlocks(currentNodes(), [...tokens], currentOutput());
+ $("results").setAttribute("aria-busy", "false");
+ $("runtime-status").textContent = "Live values · evaluated locally · no network needed";
+ renderResults();
+ } catch (error) {
+ $("runtime-status").textContent = error.message;
+ $("inspection").textContent = "No values are shown for this edit.";
+ document
+ .querySelectorAll(".block-values")
+ .forEach((node) => (node.textContent = "Unavailable"));
+ }
+}
+function setMode(id) {
+ mode = id;
+ tokens = [...(id === "build" ? "abba" : id === "repair" ? cases[caseIndex].tokens : "()(())")];
+ focus = Math.min(3, tokens.length - 1);
+ const config = MODES.find((m) => m.id === mode);
+ $("mode-kicker").textContent = config.short;
+ $("mode-title").textContent = config.heading;
+ $("mode-description").textContent = config.description;
+ document
+ .querySelectorAll("[data-mode]")
+ .forEach((b) => b.setAttribute("aria-pressed", b.dataset.mode === id));
+ $("mode-controls").replaceChildren();
+ $("mode-workspace").replaceChildren();
+ $("values").replaceChildren();
+ say("");
+ $("results").hidden = false;
+ $("result-intro").textContent =
+ "Select a value to inspect it. Positions are zero-based; prefix counts include the current token.";
+ if (mode === "walk") renderWalk();
+ if (mode === "build") renderBuilder();
+ if (mode === "repair") renderRepair();
+ if (mode === "predict") renderPrediction();
+ renderInput();
+ schedule();
+ history.replaceState(null, "", `#${id}`);
+}
+for (const m of MODES) {
+ const b = button("", () => setMode(m.id), "");
+ b.dataset.mode = m.id;
+ b.append(el("strong", m.title), el("small", m.short));
+ b.setAttribute("aria-pressed", "false");
+ $("mode-nav").append(b);
+}
+$("reset-design").onclick = () => {
+ if (mode === "walk") step = 0;
+ if (mode === "build") {
+ nodes = clone(CONTAINS);
+ output = "A3";
+ nextId = 4;
+ }
+ if (mode === "repair") {
+ repairOp = "==";
+ repairJoin = "or";
+ caseIndex = 0;
+ }
+ if (mode === "predict") question = 0;
+ setMode(mode);
+};
+function renderInput() {
+ const area = $("input-area");
+ area.replaceChildren();
+ const box = el("div", undefined, "input-workbench"),
+ head = el("div", undefined, "input-heading");
+ head.append(
+ el("h3", "Input sequence"),
+ el("p", `${tokens.length} tokens · positions 0–${tokens.length - 1}`, "small"),
+ );
+ box.append(head);
+ const strip = el("div", undefined, "token-buttons");
+ strip.id = "input-tokens";
+ const prediction = mode === "predict" ? QUESTIONS[question] : null;
+ tokens.forEach((t, i) => {
+ const b = button(
+ "",
+ () => {
+ if (mode === "tokens") {
+ tokens[i] = t === "(" ? ")" : "(";
+ focus = i;
+ renderInput();
+ schedule();
+ say(`Flipped position ${i}. Earlier prefix values stay unchanged.`);
+ } else if (mode === "build") {
+ tokens[i] = t === "a" ? "b" : t === "b" ? "a" : t === "(" ? ")" : "(";
+ renderInput();
+ schedule();
+ } else {
+ focus = i;
+ renderInput();
+ renderResults();
+ }
+ },
+ "input-token",
+ );
+ b.dataset.position = i;
+ b.setAttribute(
+ "aria-label",
+ `${mode === "tokens" || mode === "build" ? "Flip" : "Inspect"} token ${t} at position ${i}`,
+ );
+ b.setAttribute("aria-pressed", String(i === (prediction ? prediction.col : focus)));
+ b.append(el("span", t, "symbol"), el("small", `position ${i}`));
+ if (prediction) {
+ b.classList.toggle("outside", i > prediction.col);
+ b.disabled = true;
+ }
+ strip.append(b);
+ });
+ box.append(strip);
+ const hint =
+ mode === "tokens"
+ ? "Click any token to flip it. The numbers below each token are Opens / Closes."
+ : mode === "build"
+ ? "Click a token to flip a ↔ b or ( ↔ ). Try the same program on different inputs."
+ : mode === "predict"
+ ? "The outlined box is the queried position. Muted tokens lie outside its prefix."
+ : "Select a token to inspect the prefix ending there.";
+ box.append(el("p", hint, "small"));
+ const actions = el("div", undefined, "toolbar");
+ if (mode === "walk") {
+ actions.append(
+ button("Balanced: ()(())", () => changeInput("()(())")),
+ button("Counterexample: ())(()", () => changeInput("())(()")),
+ );
+ }
+ if (mode === "tokens" || mode === "build") {
+ const alphabet = mode === "tokens" ? ["(", ")"] : ["a", "b", "(", ")"];
+ for (const t of alphabet) {
+ const b = button(`Add ${t}`, () => {
+ tokens.push(t);
+ focus = tokens.length - 1;
+ renderInput();
+ schedule();
+ });
+ b.disabled = tokens.length >= 16;
+ actions.append(b);
+ }
+ const remove = button("Remove last", () => {
+ tokens.pop();
+ focus = Math.min(focus, tokens.length - 1);
+ renderInput();
+ schedule();
+ });
+ remove.disabled = tokens.length <= 1;
+ actions.append(remove);
+ if (mode === "tokens") actions.append(button("Restore ()(())", () => changeInput("()(())")));
+ }
+ box.append(actions);
+ area.append(box);
+}
+function changeInput(text) {
+ tokens = [...text];
+ focus = Math.min(focus, tokens.length - 1);
+ renderInput();
+ schedule();
+}
+function renderWalk() {
+ const controls = $("mode-controls");
+ controls.replaceChildren();
+ const toolbar = el("div", undefined, "toolbar");
+ const previous = button("← Previous assignment", () => {
+ step--;
+ renderWalk();
+ renderResults();
+ });
+ previous.disabled = step === 0;
+ const next = button("Add next assignment →", () => {
+ step++;
+ renderWalk();
+ renderResults();
+ });
+ next.disabled = step === DYCK.length - 1;
+ toolbar.append(previous, el("span", `${step + 1} of ${DYCK.length}`), next);
+ controls.append(toolbar);
+ const progress = el("div", undefined, "step-progress");
+ progress.setAttribute("aria-hidden", "true");
+ DYCK.forEach((_, i) => progress.append(el("span", undefined, i <= step ? "done" : "")));
+ controls.append(progress);
+ const copy = el("div", undefined, "step-copy");
+ copy.append(el("h3", `${step + 1}. ${DYCK[step].id}`), el("p", explanations[DYCK[step].id]));
+ controls.append(copy);
+}
+function renderTable(rows) {
+ const scroll = el("div", undefined, "value-scroll"),
+ table = el("table", undefined, "value-table");
+ table.append(
+ el("caption", "Each column is one token position. Yes/no values are Boolean predicates."),
+ );
+ const head = table.createTHead().insertRow();
+ ["Variable", ...tokens.map((t, i) => `${i}: ${t}`)].forEach((t) => {
+ const th = el("th", t);
+ th.scope = "col";
+ head.append(th);
+ });
+ const body = table.createTBody();
+ rows.forEach((row) => {
+ const tr = body.insertRow();
+ tr.dataset.variable = row.name;
+ if (mode === "walk" && row.name === DYCK[step].id) tr.className = "focus-row";
+ const label = el("th", row.name);
+ label.scope = "row";
+ tr.append(label);
+ row.values.forEach((v, i) => {
+ const td = tr.insertCell();
+ const b = button(fmt(v), () => inspect(row.name, i), "");
+ b.setAttribute("aria-label", `${row.name} at position ${i}: ${fmt(v)}`);
+ td.append(b);
+ if (i === focus && mode === "walk" && row.name === DYCK[step].id)
+ td.classList.add("focus-cell");
+ });
+ });
+ scroll.append(table);
+ return scroll;
+}
+function inspect(name, col) {
+ focus = col;
+ const value = rowValue(name, col);
+ let note = `${name} at position ${col} is ${fmt(value)}. `;
+ const node = currentNodes().find((n) => n.id === name);
+ const refs = node ? references(node) : [];
+ if (node?.kind === "tokenCount") {
+ const indices = tokens
+ .map((t, i) => (t === node.token && i <= col ? i : null))
+ .filter((i) => i !== null);
+ note += `Count “${node.token}” at ${indices.length ? "positions " + indices.join(", ") : "no positions"} in the prefix 0–${col}.`;
+ } else if (node?.kind === "count") {
+ const inputs = currentTrace.rows.find((r) => r.name === node.input).values;
+ note += `${node.input} is yes at ${inputs.slice(0, col + 1).filter(Boolean).length} positions in this prefix.`;
+ } else if (refs.length) {
+ note +=
+ refs.map((id) => `${id} = ${fmt(rowValue(id, col))}`).join("; ") +
+ `. The operation is ${expression(node)}.`;
+ } else note += "This value belongs to the current input, not to a later token.";
+ $("inspection").textContent = note;
+ document.querySelectorAll("#input-tokens button").forEach((b, i) => {
+ b.classList.toggle("outside", i > col);
+ b.classList.toggle(
+ "contributor",
+ node?.kind === "tokenCount" && i <= col && tokens[i] === node.token,
+ );
+ b.setAttribute("aria-pressed", String(i === col));
+ });
+}
+function renderResults() {
+ if (!currentTrace) return;
+ $("values").replaceChildren();
+ $("decision").className = "decision";
+ if (mode === "build") {
+ $("result-heading").textContent = "Program output";
+ $("result-intro").textContent =
+ "Each block above shows its vector in input order. The selected yes/no variable determines the decision at the last token.";
+ for (const node of nodes) {
+ const values = document.querySelector(`[data-values="${node.id}"]`);
+ if (values) {
+ values.replaceChildren();
+ currentTrace.rows
+ .find((r) => r.name === node.id)
+ .values.forEach((v, i) => {
+ const cell = el("span", `${i}: ${fmt(v)}`);
+ values.append(cell);
+ });
+ }
+ }
+ if (!output) {
+ $("decision").textContent = "No decision selected";
+ $("inspection").textContent =
+ "Explore number vectors, or select a yes/no variable to define acceptance.";
+ return;
+ }
+ } else if (mode === "predict") {
+ renderPrediction();
+ return;
+ } else {
+ let rows = currentTrace.rows.filter((r) => r.name !== "Result");
+ if (mode === "walk") rows = rows.slice(0, step + 1);
+ $("result-heading").textContent =
+ mode === "walk" ? "Assignments revealed so far" : "Values at each position";
+ $("values").append(renderTable(rows));
+ }
+ if (mode === "walk" && step < DYCK.length - 1) {
+ $("decision").textContent = "Decision introduced at step 7";
+ inspect(DYCK[step].id, focus);
+ } else {
+ $("decision").textContent = currentTrace.accepted
+ ? "Accepted at the last token"
+ : "Rejected at the last token";
+ $("decision").classList.add(currentTrace.accepted ? "yes" : "no");
+ }
+ if (mode === "repair") renderRepairFeedback();
+ if (mode === "tokens") renderLiveSummary();
+}
+// Block builder: all choices are typed, generated names are unique, and moves
+// are committed only after checking every dependency in the resulting order.
+const operations = [
+ ["token", "Is this token…?", "yes/no"],
+ ["tokenCount", "Count a token", "number"],
+ ["count", "Count yes values", "number"],
+ ["compare", "Compare numbers", "yes/no"],
+ ["and", "Both are yes", "yes/no"],
+ ["or", "Either is yes", "yes/no"],
+ ["not", "Reverse yes/no", "yes/no"],
+ ["add", "Add numbers", "number"],
+];
+function available(kind) {
+ return (
+ !["count", "and", "or", "not"].includes(kind) || nodes.some((n) => TYPES[n.kind] === "bool")
+ );
+}
+function addBlock(kind) {
+ if (nodes.length >= 24) {
+ say("The learning editor is limited to 24 blocks.", true);
+ return;
+ }
+ if (!available(kind)) {
+ say("First add a yes/no block, such as “Is this token…?”.", true);
+ return;
+ }
+ const bools = nodes.filter((n) => TYPES[n.kind] === "bool").map((n) => n.id),
+ ints = nodes.filter((n) => TYPES[n.kind] === "int").map((n) => n.id);
+ const n = { id: `A${nextId++}`, kind };
+ if (["token", "tokenCount"].includes(kind)) n.token = tokens.includes("a") ? "a" : "(";
+ if (["count", "not"].includes(kind)) n.input = bools.at(-1);
+ if (["and", "or"].includes(kind)) {
+ n.left = bools.at(-1);
+ n.right = bools.at(-2) || bools.at(-1);
+ }
+ if (["compare", "add"].includes(kind)) {
+ n.left = ints.at(-1) || "0";
+ n.right = "0";
+ if (kind === "compare") n.op = ">";
+ }
+ nodes.push(n);
+ if (TYPES[kind] === "bool") output = n.id;
+ renderBuilder();
+ schedule();
+ say(`Added ${n.id}. Change its inputs in the menus; values update automatically.`);
+}
+function reorder(from, to) {
+ try {
+ nodes = moveBlock(nodes, from, to);
+ renderBuilder();
+ schedule();
+ say("Moved the block. Dependencies remain defined before use.");
+ } catch {
+ say("That move would use a variable before it is defined. The order was kept unchanged.", true);
+ }
+}
+function drop(e, to) {
+ e.preventDefault();
+ document.querySelectorAll(".drag-over").forEach((n) => n.classList.remove("drag-over"));
+ if (!drag) return;
+ if (drag.kind) addBlock(drag.kind);
+ else reorder(drag.index, to);
+ drag = null;
+}
+function renderBuilder() {
+ const area = $("mode-workspace");
+ area.replaceChildren();
+ const tools = el("div", undefined, "toolbar");
+ tools.append(
+ button("Start empty", () => {
+ nodes = [];
+ output = "";
+ nextId = 1;
+ renderBuilder();
+ schedule();
+ say("Add “Is this token…?”, then “Count yes values”, then “Compare numbers”.");
+ }),
+ button("Load “contains a”", () => {
+ nodes = clone(CONTAINS);
+ output = "A3";
+ nextId = 4;
+ tokens = [..."abba"];
+ renderInput();
+ renderBuilder();
+ schedule();
+ }),
+ choice(
+ "More starters",
+ [
+ ["", "Choose a program"],
+ ["both", "Contains both a and b"],
+ ["before", "An a occurs before a b"],
+ ["dyck", "Balanced parentheses"],
+ ],
+ "",
+ (value) => {
+ if (!value) return;
+ nodes = clone(value === "both" ? BOTH_SYMBOLS : value === "before" ? A_BEFORE_B : DYCK);
+ output = "Accept";
+ tokens = [...(value === "dyck" ? "()(())" : "ab")];
+ nextId = 4;
+ renderInput();
+ renderBuilder();
+ schedule();
+ say(
+ value === "before"
+ ? "Try ab, then ba. The rule asks for order, not adjacency."
+ : value === "both"
+ ? "Try ab and ba, then aa. Does order matter for this rule?"
+ : "This is the complete program from the walkthrough.",
+ );
+ },
+ ),
+ );
+ area.append(tools);
+ area.append(
+ el(
+ "p",
+ "Click an operation or drag it to the dashed area. Drag the ↕ handle or use ↑ / ↓ to reorder. A block cannot move before a variable it uses.",
+ "drag-help",
+ ),
+ );
+ const grid = el("div", undefined, "builder-grid"),
+ palette = el("aside", undefined, "palette");
+ palette.setAttribute("aria-label", "Operation palette");
+ palette.append(el("h3", "Operations"));
+ for (const [kind, title, type] of operations) {
+ const b = button("", () => addBlock(kind), "");
+ b.dataset.operation = kind;
+ b.append(el("span", title), el("small", `Produces ${type}`));
+ b.disabled = !available(kind) || nodes.length >= 24;
+ b.draggable = !b.disabled;
+ b.ondragstart = (e) => {
+ drag = { kind };
+ e.dataTransfer.setData("text/plain", kind);
+ e.dataTransfer.effectAllowed = "copy";
+ };
+ b.ondragend = () => (drag = null);
+ palette.append(b);
+ }
+ grid.append(palette);
+ const stack = el("div", undefined, "block-stack");
+ nodes.forEach((n, index) => {
+ const card = el("div", undefined, `program-block ${TYPES[n.kind] === "int" ? "number" : ""}`);
+ card.dataset.block = n.id;
+ card.ondragover = (e) => {
+ e.preventDefault();
+ card.classList.add("drag-over");
+ };
+ card.ondragleave = () => card.classList.remove("drag-over");
+ card.ondrop = (e) => drop(e, index);
+ const header = el("div", undefined, "block-title"),
+ label = el("strong", `${index + 1}. ${n.id} `);
+ label.append(el("span", TYPES[n.kind] === "int" ? "number" : "yes/no", "type-tag"));
+ header.append(label);
+ const actions = el("div", undefined, "block-tools");
+ const handle = el("span", "↕");
+ handle.draggable = true;
+ handle.title = "Drag to reorder; use the arrow buttons with keyboard or touch";
+ handle.ondragstart = (e) => {
+ drag = { index };
+ e.dataTransfer.setData("text/plain", n.id);
+ e.dataTransfer.effectAllowed = "move";
+ };
+ handle.ondragend = () => (drag = null);
+ const up = button("↑", () => reorder(index, index - 1), ""),
+ down = button("↓", () => reorder(index, index + 1), "");
+ up.disabled = index === 0;
+ down.disabled = index === nodes.length - 1;
+ up.setAttribute("aria-label", `Move ${n.id} up`);
+ down.setAttribute("aria-label", `Move ${n.id} down`);
+ const remove = button(
+ "Remove",
+ () => {
+ if (nodes.some((other) => references(other).includes(n.id))) {
+ say(
+ `${n.id} is used by another block. Change or remove that dependent block first.`,
+ true,
+ );
+ return;
+ }
+ nodes.splice(index, 1);
+ if (output === n.id)
+ output = nodes.filter((x) => TYPES[x.kind] === "bool").at(-1)?.id || "";
+ renderBuilder();
+ schedule();
+ },
+ "",
+ );
+ remove.setAttribute("aria-label", `Remove ${n.id}`);
+ actions.append(handle, up, down, remove);
+ header.append(actions);
+ card.append(header);
+ const fields = el("div", undefined, "block-fields");
+ const earlier = nodes.slice(0, index);
+ const bools = earlier.filter((x) => TYPES[x.kind] === "bool").map((x) => x.id),
+ ints = ["0", ...earlier.filter((x) => TYPES[x.kind] === "int").map((x) => x.id)];
+ const field = (label, key, opts) =>
+ choice(label, opts, n[key], (v) => {
+ n[key] = v;
+ schedule();
+ });
+ if (n.kind === "token" || n.kind === "tokenCount")
+ fields.append(
+ field(n.kind === "token" ? "Token is" : "Count token", "token", ["a", "b", "(", ")"]),
+ );
+ if (n.kind === "count" || n.kind === "not")
+ fields.append(field(n.kind === "count" ? "Count yes in" : "Reverse", "input", bools));
+ if (["compare", "add", "and", "or"].includes(n.kind)) {
+ const opts = ["compare", "add"].includes(n.kind) ? ints : bools;
+ fields.append(field("Input", "left", opts));
+ if (n.kind === "compare")
+ fields.append(field("Comparison", "op", ["<", "<=", "==", ">=", ">"]));
+ else fields.append(el("span", { add: "plus", and: "and", or: "or" }[n.kind]));
+ fields.append(field("Input", "right", opts));
+ }
+ card.append(fields);
+ const values = el("div", "Updating…", "block-values");
+ values.dataset.values = n.id;
+ values.setAttribute("aria-label", `${n.id} values by position`);
+ card.append(values);
+ stack.append(card);
+ });
+ const zone = el(
+ "div",
+ nodes.length ? "Drop an operation here to append it" : "Drop your first operation here",
+ "drop-end",
+ );
+ zone.id = "block-drop";
+ zone.ondragover = (e) => {
+ e.preventDefault();
+ zone.classList.add("drag-over");
+ };
+ zone.ondragleave = () => zone.classList.remove("drag-over");
+ zone.ondrop = (e) => drop(e, Math.max(0, nodes.length - 1));
+ stack.append(zone);
+ grid.append(stack);
+ area.append(grid);
+ const decision = el("div", undefined, "builder-output");
+ decision.append(
+ choice(
+ "Decision at the last token",
+ [["", "No decision yet"], ...nodes.filter((n) => TYPES[n.kind] === "bool").map((n) => n.id)],
+ output,
+ (v) => {
+ output = v;
+ schedule();
+ },
+ ),
+ el(
+ "p",
+ "Only yes/no variables can decide acceptance. A number vector can still be inspected without selecting a decision.",
+ ),
+ );
+ area.append(decision);
+}
+function renderRepair() {
+ const area = $("mode-controls");
+ area.replaceChildren();
+ const rule = el("div", undefined, "puzzle-rule");
+ rule.append(
+ choice("A prefix is a violation when Opens", ["<", "==", ">"], repairOp, (v) => {
+ repairOp = v;
+ schedule();
+ }),
+ el("p", "Closes. Safe means the number of violations so far is zero.", "small"),
+ );
+ rule.append(
+ choice(
+ "Accept when Safe",
+ [
+ ["and", "AND"],
+ ["or", "OR"],
+ ],
+ repairJoin,
+ (v) => {
+ repairJoin = v;
+ schedule();
+ },
+ ),
+ el("p", "Balanced. Balanced means Opens equals Closes.", "small"),
+ );
+ area.append(rule);
+ const buttons = el("div", undefined, "case-buttons");
+ cases.forEach((c, i) => {
+ const b = button(
+ `${c.tokens} · should ${c.want ? "accept" : "reject"}`,
+ () => {
+ caseIndex = i;
+ renderRepair();
+ changeInput(c.tokens);
+ },
+ "",
+ );
+ b.setAttribute("aria-pressed", String(i === caseIndex));
+ buttons.append(b);
+ });
+ area.append(buttons);
+ const feedback = el("div", undefined, "puzzle-feedback");
+ feedback.id = "repair-feedback";
+ feedback.append(
+ el(
+ "p",
+ "Change one choice and inspect how the rows change. Passing these examples is not a proof for all inputs.",
+ ),
+ );
+ area.append(feedback);
+}
+function renderRepairFeedback() {
+ const expected = cases[caseIndex].want,
+ match = currentTrace.accepted === expected;
+ const box = $("repair-feedback");
+ box.replaceChildren();
+ let why =
+ repairJoin === "or"
+ ? "With OR, equal final counts can accept an input even when Safe is false."
+ : repairOp !== "<"
+ ? "Check the violation condition: an invalid prefix has fewer opens than closes."
+ : "The rule checks both the absence of invalid prefixes and equal final counts.";
+ box.append(
+ el(
+ "p",
+ `${match ? "Matches" : "Does not match"} the expected result for this input. ${why} Try the other inputs too; this small set is not an exhaustive proof.`,
+ ),
+ );
+}
+function renderLiveSummary() {
+ const area = $("mode-workspace");
+ area.replaceChildren();
+ const summary = el("div", undefined, "live-summary");
+ const violation = currentTrace.rows.find((r) => r.name === "Violation").values.findIndex(Boolean);
+ for (const [value, label] of [
+ [rowValue("Opens", tokens.length - 1), "opens in the whole input"],
+ [rowValue("Closes", tokens.length - 1), "closes in the whole input"],
+ [violation < 0 ? "none" : violation, "first invalid prefix (position)"],
+ ]) {
+ const box = el("div");
+ box.append(el("strong", value), el("span", label));
+ summary.append(box);
+ }
+ area.append(summary);
+ document.querySelectorAll("#input-tokens button").forEach((b, i) => {
+ b.querySelector(".count-under")?.remove();
+ b.append(el("span", `${rowValue("Opens", i)} / ${rowValue("Closes", i)}`, "count-under"));
+ });
+ $("inspection").textContent =
+ violation < 0
+ ? "No prefix has more closes than opens. Check the final counts separately."
+ : `The first invalid prefix ends at position ${violation}. Later tokens cannot erase that earlier violation.`;
+}
+function renderPrediction() {
+ const area = $("mode-controls");
+ area.replaceChildren();
+ const q = QUESTIONS[question],
+ card = el("div", undefined, "prediction");
+ card.append(
+ el("p", `Question ${question + 1} of ${QUESTIONS.length}`, "small"),
+ el("h3", q.prompt),
+ el("p", `Requested cell: ${q.row}[${q.col}]`),
+ );
+ const options = el("div", undefined, "prediction-options");
+ for (const value of q.options) {
+ const b = button(fmt(value), () => {
+ const actual = rowValue(q.row, q.col);
+ $("prediction-feedback").textContent =
+ `${value === actual ? "Correct." : "Not quite."} ${q.row}[${q.col}] = ${fmt(actual)}. ${q.why}`;
+ options.querySelectorAll("button").forEach((x) => (x.disabled = true));
+ $("values").replaceChildren(renderTable([currentTrace.rows.find((r) => r.name === q.row)]));
+ $("result-heading").textContent = "Revealed row";
+ $("result-intro").textContent = q.why;
+ $("decision").textContent = "";
+ next.disabled = question === QUESTIONS.length - 1;
+ });
+ b.disabled = !currentTrace;
+ options.append(b);
+ }
+ card.append(options);
+ const feedback = el(
+ "p",
+ currentTrace ? "Choose a value to reveal the row." : "Choose a value after the input is ready.",
+ "prediction-feedback",
+ );
+ feedback.id = "prediction-feedback";
+ feedback.setAttribute("role", "status");
+ card.append(feedback);
+ const next = button("Next question →", () => {
+ question++;
+ renderInput();
+ renderPrediction();
+ $("values").replaceChildren();
+ $("result-heading").textContent = "The answer is hidden until you choose";
+ $("result-intro").textContent = "";
+ });
+ next.disabled = true;
+ card.append(
+ next,
+ button("Start again", () => {
+ question = 0;
+ renderInput();
+ renderPrediction();
+ $("values").replaceChildren();
+ }),
+ );
+ area.append(card);
+ $("values").replaceChildren();
+ $("decision").textContent = "";
+ $("result-heading").textContent = "The answer is hidden until you choose";
+ $("result-intro").textContent = "";
+ $("inspection").textContent = "";
+}
+$("toggle-code").onclick = () => {
+ const hidden = !$("generated-code").hidden;
+ $("generated-code").hidden = hidden;
+ $("toggle-code").setAttribute("aria-expanded", String(!hidden));
+ $("toggle-code").textContent = hidden ? "View generated C-RASP" : "Hide generated C-RASP";
+};
+$("open-editor").onclick = () => {
+ (embeddedMode ? window.top : window).location.href =
+ "text-editor.html#program=" +
+ encodeURIComponent(JSON.stringify({ source: source(), tokens: tokens.join(" ") }));
+};
+setMode(
+ embeddedMode ||
+ (MODES.some((m) => m.id === location.hash.slice(1)) ? location.hash.slice(1) : "walk"),
+);
+if (embeddedMode) {
+ $("reset-design").textContent =
+ embeddedMode === "walk" ? "Restart walkthrough" : "Reset playground";
+ let previousHeight = 0;
+ const notifyHeight = () => {
+ const height = Math.ceil(
+ document.querySelector(".notebook-shell").getBoundingClientRect().height,
+ );
+ if (height !== previousHeight) {
+ previousHeight = height;
+ window.parent.postMessage({ type: "crasp-notebook-height", height }, location.origin);
+ }
+ };
+ new ResizeObserver(notifyHeight).observe(document.querySelector(".notebook-shell"));
+ window.addEventListener("load", notifyHeight);
+ notifyHeight();
+}
diff --git a/tutorial_dual/static/web-assets/interactive/assets/paper-languages.svg b/tutorial_dual/static/web-assets/interactive/assets/paper-languages.svg
new file mode 100644
index 0000000..9d0a29a
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/paper-languages.svg
@@ -0,0 +1,2216 @@
+
diff --git a/tutorial_dual/static/web-assets/interactive/assets/paper-main.svg b/tutorial_dual/static/web-assets/interactive/assets/paper-main.svg
new file mode 100644
index 0000000..ad2f81a
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/paper-main.svg
@@ -0,0 +1,3490 @@
+
diff --git a/tutorial_dual/static/web-assets/interactive/assets/style.css b/tutorial_dual/static/web-assets/interactive/assets/style.css
new file mode 100644
index 0000000..aaab8b3
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/assets/style.css
@@ -0,0 +1,761 @@
+:root {
+ --paper: #f8f6f0;
+ --ink: #172f39;
+ --muted: #52646c;
+ --line: #d8ded8;
+ --accent: #ad4828;
+ --teal: #246d66;
+ --pale: #e9eee7;
+ --white: #fffefb;
+ --serif: Georgia, "Times New Roman", serif;
+ --sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+* {
+ box-sizing: border-box;
+}
+html {
+ scroll-behavior: smooth;
+ scroll-padding-top: 90px;
+}
+body {
+ margin: 0;
+ background: var(--paper);
+ color: var(--ink);
+ font: 17px/1.7 var(--sans);
+}
+a {
+ color: var(--teal);
+ text-underline-offset: 4px;
+}
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+button,
+.button {
+ cursor: pointer;
+ border: 1px solid var(--ink);
+ border-radius: 4px;
+ padding: 0.55rem 0.95rem;
+ background: var(--ink);
+ color: white;
+ text-decoration: none;
+ line-height: 1.4;
+}
+button:hover,
+.button:hover {
+ background: var(--teal);
+}
+button.secondary,
+.button.secondary {
+ background: transparent;
+ color: var(--ink);
+ border-color: var(--line);
+}
+button:disabled {
+ opacity: 0.55;
+ cursor: wait;
+}
+button:focus-visible,
+a:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+select:focus-visible,
+summary:focus-visible {
+ outline: 3px solid var(--accent);
+ outline-offset: 4px;
+}
+header {
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 18px 5vw;
+ gap: 20px;
+}
+header .brand {
+ color: var(--ink);
+ text-decoration: none;
+ letter-spacing: 0.08em;
+ font-size: 0.8rem;
+ font-weight: 750;
+}
+header nav {
+ display: flex;
+ gap: 24px;
+ font-size: 0.85rem;
+}
+header nav a {
+ text-decoration: none;
+ color: var(--ink);
+}
+.skip {
+ position: absolute;
+ top: -100px;
+}
+.skip:focus {
+ top: 10px;
+ background: white;
+ z-index: 10;
+ padding: 10px;
+}
+.hero {
+ max-width: 1200px;
+ margin: auto;
+ padding: 80px 40px 64px;
+ display: grid;
+ grid-template-columns: 1.25fr 1fr;
+ align-items: center;
+ gap: 50px;
+}
+.eyebrow,
+.section-number {
+ font-size: 0.72rem;
+ font-weight: 750;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+h1 {
+ font: normal clamp(3rem, 5.1vw, 5.4rem) / 1.06 var(--serif);
+ letter-spacing: -0.04em;
+ margin: 22px 0 25px;
+}
+h1 em {
+ font-weight: normal;
+ color: var(--teal);
+}
+.dek {
+ font: 1.18rem/1.7 var(--serif);
+ max-width: 560px;
+}
+.meta {
+ font-size: 0.78rem;
+ color: var(--muted);
+ margin: 25px 0;
+}
+.hero-art {
+ border-top: 2px solid var(--ink);
+ border-bottom: 1px solid var(--line);
+ padding: 25px 0;
+}
+.hero-art svg {
+ width: 100%;
+ height: auto;
+}
+.figure-label {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.7rem;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+.layout {
+ display: grid;
+ grid-template-columns: 185px minmax(0, 850px);
+ gap: 55px;
+ max-width: 1190px;
+ padding: 0 35px;
+ margin: auto;
+}
+.toc {
+ position: sticky;
+ top: 35px;
+ align-self: start;
+ border-top: 1px solid var(--line);
+ padding-top: 20px;
+ font-size: 0.82rem;
+}
+.toc strong {
+ font-size: 0.68rem;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+}
+.toc a {
+ display: block;
+ margin: 13px 0;
+ text-decoration: none;
+ color: var(--muted);
+}
+.toc a:hover {
+ color: var(--accent);
+}
+article {
+ min-width: 0;
+}
+section {
+ padding: 45px 0;
+ border-top: 1px solid var(--line);
+}
+h2 {
+ font: normal 2.3rem/1.15 var(--serif);
+ letter-spacing: -0.025em;
+ margin: 12px 0 24px;
+}
+h3 {
+ font: 600 1.1rem/1.4 var(--sans);
+ margin: 25px 0 12px;
+}
+p {
+ margin: 0 0 20px;
+}
+article > section > p {
+ max-width: 720px;
+}
+.lede {
+ font: 1.3rem/1.6 var(--serif);
+}
+.badge {
+ display: inline-block;
+ padding: 2px 9px;
+ font-size: 0.66rem;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ color: var(--teal);
+ vertical-align: middle;
+}
+.callout {
+ border-left: 3px solid var(--accent);
+ padding: 15px 22px;
+ background: #f1ece1;
+ margin: 24px 0;
+}
+.callout p:last-child {
+ margin-bottom: 0;
+}
+.equation {
+ background: var(--pale);
+ padding: 22px;
+ text-align: center;
+ font: 1.25rem/1.6 var(--serif);
+ overflow: auto;
+}
+.equation small {
+ font: 0.8rem var(--sans);
+ display: block;
+ margin-top: 10px;
+}
+.three {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 20px;
+ margin: 28px 0;
+}
+.three > div {
+ border-top: 2px solid var(--teal);
+ padding-top: 12px;
+}
+.three h3 {
+ margin: 0 0 9px;
+}
+.three p {
+ font-size: 0.9rem;
+ color: var(--muted);
+}
+details {
+ border-block: 1px solid var(--line);
+ margin: 22px 0;
+ padding: 13px 0;
+}
+summary {
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 650;
+}
+details > div {
+ padding-top: 20px;
+}
+.lab {
+ border: 1px solid var(--line);
+ background: var(--white);
+ margin: 25px 0;
+}
+.lab-head {
+ padding: 18px 22px;
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ justify-content: space-between;
+ gap: 15px;
+ align-items: center;
+}
+.lab-head strong {
+ font-size: 0.9rem;
+}
+.lab-body {
+ padding: 22px;
+}
+.controls {
+ display: flex;
+ align-items: end;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+}
+.controls label {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ font-size: 0.77rem;
+ flex: 1;
+ min-width: 140px;
+}
+input[type="text"],
+select,
+textarea {
+ border: 1px solid #a4b0b1;
+ background: white;
+ color: var(--ink);
+ border-radius: 3px;
+ padding: 9px;
+ width: 100%;
+}
+textarea {
+ font:
+ 13px/1.65 ui-monospace,
+ SFMono-Regular,
+ Consolas,
+ monospace;
+ tab-size: 2;
+ min-height: 235px;
+ resize: vertical;
+}
+.code-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 22px;
+}
+.code-grid p {
+ font-size: 0.9rem;
+}
+.small,
+.caption {
+ font-size: 0.8rem;
+ color: var(--muted);
+ line-height: 1.6;
+}
+.status {
+ font-size: 0.85rem;
+ margin: 10px 0;
+ min-height: 27px;
+}
+.status.error {
+ color: #a32a20;
+}
+.table-scroll {
+ overflow: auto;
+ max-height: 420px;
+}
+.trace {
+ border-collapse: collapse;
+ width: 100%;
+ font:
+ 12px/1.4 ui-monospace,
+ monospace;
+}
+.trace th,
+.trace td {
+ padding: 10px 12px;
+ text-align: center;
+ border-bottom: 1px solid var(--line);
+ white-space: nowrap;
+}
+.trace th:first-child {
+ text-align: left;
+ position: sticky;
+ left: 0;
+ background: var(--white);
+}
+.trace tr.selected {
+ background: #e4eee8;
+}
+.trace td.active {
+ background: #cce1d8;
+ font-weight: bold;
+}
+.trace td.contributes {
+ box-shadow: inset 0 -3px var(--accent);
+}
+.trace td button {
+ background: transparent;
+ border: 0;
+ color: inherit;
+ font: inherit;
+ padding: 0;
+ min-width: 20px;
+}
+.result {
+ font-size: 0.85rem;
+ font-weight: bold;
+}
+.result.accepted {
+ color: var(--teal);
+}
+.result.rejected {
+ color: var(--accent);
+}
+.trace-nav {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+ margin: 15px 0;
+}
+.token-strip {
+ display: flex;
+ gap: 7px;
+ flex-wrap: wrap;
+ margin: 20px 0;
+}
+.token {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ min-width: 45px;
+ padding: 9px;
+ background: var(--pale);
+ border-bottom: 3px solid transparent;
+ font-family: ui-monospace, monospace;
+}
+.token.visible {
+ border-color: var(--teal);
+ background: #d9e8df;
+}
+.token.match {
+ background: #eed7c4;
+ border-color: var(--accent);
+}
+.token small {
+ font-size: 0.63rem;
+ color: var(--muted);
+}
+.wide-image {
+ width: 100%;
+ height: auto;
+ background: white;
+ border: 1px solid var(--line);
+ padding: 10px;
+}
+.figure-tabs {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 18px;
+}
+.plot {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+.empty {
+ padding: 25px;
+ border: 1px dashed #9aa9a7;
+ text-align: center;
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+.file-input {
+ max-width: 100%;
+ font-size: 0.8rem;
+}
+.run-meta {
+ font-size: 0.8rem;
+ background: var(--pale);
+ padding: 16px;
+ white-space: pre-wrap;
+}
+.refs {
+ font-size: 0.88rem;
+}
+.refs li {
+ margin: 15px 0;
+}
+.glossary dt {
+ font-weight: bold;
+}
+.glossary dd {
+ margin: 0 0 18px;
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+footer {
+ max-width: 1190px;
+ margin: 50px auto 0;
+ border-top: 1px solid var(--line);
+ padding: 25px 35px;
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.75rem;
+ color: var(--muted);
+}
+.download {
+ font-size: 0.8rem;
+}
+.noscript {
+ padding: 20px;
+ background: #eed7c4;
+}
+.slider {
+ accent-color: var(--teal);
+ width: 100%;
+}
+code {
+ font-size: 0.85em;
+ background: #e9ede8;
+ padding: 2px 5px;
+ border-radius: 3px;
+}
+pre {
+ overflow: auto;
+ line-height: 1.6;
+}
+.annotation {
+ font: italic 1.1rem var(--serif);
+ color: var(--accent);
+}
+[hidden] {
+ display: none !important;
+}
+@media (max-width: 950px) {
+ .layout {
+ grid-template-columns: 1fr;
+ max-width: 850px;
+ }
+ .toc {
+ position: static;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 15px 0;
+ }
+ .toc strong {
+ width: 100%;
+ }
+ .toc a {
+ margin: 0;
+ }
+ .hero {
+ gap: 25px;
+ padding-top: 45px;
+ }
+ .hero-art {
+ min-width: 0;
+ }
+ .three {
+ gap: 12px;
+ }
+}
+@media (max-width: 650px) {
+ body {
+ font-size: 16px;
+ }
+ header {
+ padding: 16px 22px;
+ }
+ header nav {
+ gap: 14px;
+ }
+ .hero {
+ grid-template-columns: 1fr;
+ padding: 35px 24px;
+ }
+ .hero-art {
+ max-width: 480px;
+ }
+ .layout {
+ padding: 0 22px;
+ gap: 15px;
+ }
+ h1 {
+ font-size: 3.3rem;
+ }
+ .three,
+ .code-grid {
+ grid-template-columns: 1fr;
+ }
+ .lab-head {
+ align-items: start;
+ flex-direction: column;
+ }
+ .lab-body {
+ padding: 15px;
+ }
+ .controls {
+ align-items: stretch;
+ }
+ .controls > button {
+ align-self: end;
+ }
+ h2 {
+ font-size: 2rem;
+ }
+ footer {
+ display: block;
+ }
+ .trace td,
+ .trace th {
+ padding: 8px;
+ }
+ .figure-tabs {
+ flex-wrap: wrap;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+}
+@media print {
+ header,
+ .toc,
+ .controls,
+ button,
+ .skip,
+ .file-input {
+ display: none !important;
+ }
+ .layout {
+ display: block;
+ max-width: none;
+ }
+ .hero {
+ display: block;
+ padding: 0;
+ }
+ h1 {
+ font-size: 32pt;
+ }
+ .hero-art {
+ display: none;
+ }
+ section {
+ break-inside: auto;
+ padding: 20px 0;
+ }
+ .lab {
+ break-inside: avoid;
+ }
+ body {
+ font-size: 10pt;
+ background: white;
+ }
+ details > div {
+ display: block;
+ }
+ .code-grid {
+ display: block;
+ }
+ textarea {
+ height: 200px;
+ }
+ .table-scroll {
+ max-height: none;
+ }
+ a {
+ color: inherit;
+ }
+ .wide-image {
+ max-width: 100%;
+ }
+}
+
+/* Explanatory material follows the article in normal reading order. */
+.explanation {
+ margin: 28px 0;
+ padding: 4px 0 4px 20px;
+ border-left: 2px solid var(--line);
+}
+.explanation h3 {
+ margin-top: 12px;
+}
+.explanation p {
+ max-width: 720px;
+}
+.trace th.contributes {
+ box-shadow: inset 0 -3px var(--accent);
+}
+
+.interaction-guide {
+ background: var(--pale);
+ padding: 14px 16px;
+ font-size: 0.9rem;
+ line-height: 1.65;
+}
+.token.masked {
+ opacity: 0.5;
+ background: transparent;
+ border: 1px dashed var(--line);
+}
+.token small {
+ max-width: 120px;
+ text-align: center;
+ font-family: var(--sans);
+ padding-top: 6px;
+}
+.trace-nav {
+ flex-wrap: wrap;
+}
+.token-strip .token {
+ gap: 4px;
+}
+
+/* Full article: inline notebook frames size themselves to their contents. */
+.notebook-frame {
+ display: block;
+ width: 100%;
+ border: 0;
+ margin: 24px 0 12px;
+ background: var(--white);
+}
+@media print {
+ .notebook-frame {
+ display: none;
+ }
+}
+
+/* Give the teaching notebooks the full reading surface. Keep prose narrower,
+ and put section navigation above the article instead of beside it. */
+.layout {
+ display: block;
+ max-width: 1200px;
+ padding-inline: 28px;
+}
+.toc {
+ position: static;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 22px;
+ padding: 18px 0 24px;
+}
+.toc strong {
+ width: 100%;
+}
+.toc a {
+ margin: 0;
+}
+article > section > h2,
+article > section > h3,
+article > section > p {
+ max-width: 760px;
+}
+.notebook-frame {
+ width: 100%;
+ min-width: 0;
+}
+@media (max-width: 650px) {
+ .layout {
+ padding-inline: 12px;
+ }
+ article > section > h2,
+ article > section > h3,
+ article > section > p {
+ margin-inline: 10px;
+ }
+ .toc {
+ padding-inline: 10px;
+ }
+}
diff --git a/tutorial_dual/static/web-assets/interactive/data/default-trace.json b/tutorial_dual/static/web-assets/interactive/data/default-trace.json
new file mode 100644
index 0000000..53ccb86
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/data/default-trace.json
@@ -0,0 +1,92 @@
+{
+ "tokens": [
+ "(",
+ ")",
+ "(",
+ "(",
+ ")",
+ ")"
+ ],
+ "rows": [
+ {
+ "name": "C_open",
+ "values": [
+ 1,
+ 1,
+ 2,
+ 3,
+ 3,
+ 3
+ ]
+ },
+ {
+ "name": "C_close",
+ "values": [
+ 0,
+ 1,
+ 1,
+ 1,
+ 2,
+ 3
+ ]
+ },
+ {
+ "name": "V",
+ "values": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ {
+ "name": "CV",
+ "values": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "M",
+ "values": [
+ true,
+ true,
+ true,
+ true,
+ true,
+ true
+ ]
+ },
+ {
+ "name": "B",
+ "values": [
+ false,
+ true,
+ false,
+ false,
+ false,
+ true
+ ]
+ },
+ {
+ "name": "D",
+ "values": [
+ false,
+ true,
+ false,
+ false,
+ false,
+ true
+ ]
+ }
+ ],
+ "accepted": true,
+ "engine": "coli-saar/c-rasp",
+ "index_base": 0
+}
diff --git a/tutorial_dual/static/web-assets/interactive/data/evaluation-schema.json b/tutorial_dual/static/web-assets/interactive/data/evaluation-schema.json
new file mode 100644
index 0000000..a39a859
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/data/evaluation-schema.json
@@ -0,0 +1,107 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Saved sequence accuracy evaluations",
+ "type": "object",
+ "required": [
+ "schema_version",
+ "runs"
+ ],
+ "properties": {
+ "schema_version": {
+ "const": 1
+ },
+ "runs": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "type": "object",
+ "required": [
+ "id",
+ "task",
+ "model",
+ "positional_encoding",
+ "seed",
+ "checkpoint",
+ "evaluated_at",
+ "source",
+ "train_max_length",
+ "metric",
+ "points"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1
+ },
+ "task": {
+ "type": "string",
+ "minLength": 1
+ },
+ "model": {
+ "type": "string",
+ "minLength": 1
+ },
+ "positional_encoding": {
+ "type": "string",
+ "minLength": 1
+ },
+ "checkpoint": {
+ "type": "string",
+ "minLength": 1
+ },
+ "evaluated_at": {
+ "type": "string",
+ "minLength": 1
+ },
+ "source": {
+ "type": "string",
+ "minLength": 1
+ },
+ "seed": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "train_max_length": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "selection_max_length": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "metric": {
+ "const": "sequence_accuracy"
+ },
+ "points": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 10000,
+ "items": {
+ "type": "object",
+ "required": [
+ "length",
+ "correct",
+ "total"
+ ],
+ "properties": {
+ "length": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "correct": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "total": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "description": "Additional runtime constraints: unique run ids and lengths; correct <= total; selection_max_length >= train_max_length; valid evaluated_at timestamp."
+}
diff --git a/tutorial_dual/static/web-assets/interactive/data/evaluations.json b/tutorial_dual/static/web-assets/interactive/data/evaluations.json
new file mode 100644
index 0000000..17d5863
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/data/evaluations.json
@@ -0,0 +1 @@
+{"schema_version": 1, "runs": []}
diff --git a/tutorial_dual/static/web-assets/interactive/data/figure-provenance.json b/tutorial_dual/static/web-assets/interactive/data/figure-provenance.json
new file mode 100644
index 0000000..0d29e69
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/data/figure-provenance.json
@@ -0,0 +1,24 @@
+[
+ {
+ "file": "assets/paper-main.svg",
+ "source_url": "https://arxiv.org/html/2410.02140v2/results_for_main_paper.svg",
+ "paper": "https://arxiv.org/html/2410.02140v2#S5.F1",
+ "authors": "Huang et al.",
+ "license": "CC BY 4.0",
+ "license_url": "https://creativecommons.org/licenses/by/4.0/",
+ "modifications": "none",
+ "sha256": "8031bb180f9d1d7d3d3c8533174d2903e06a4cf9eb2a7f54c046849ea09c753f",
+ "retrieved_at": "2026-10-19T12:48:34.219715+00:00"
+ },
+ {
+ "file": "assets/paper-languages.svg",
+ "source_url": "https://arxiv.org/html/2410.02140v2/formal_only.svg",
+ "paper": "https://arxiv.org/html/2410.02140v2#A5.F2",
+ "authors": "Huang et al.",
+ "license": "CC BY 4.0",
+ "license_url": "https://creativecommons.org/licenses/by/4.0/",
+ "modifications": "none",
+ "sha256": "d5d4dfe9fd3551b7bb75f61d62d4cf7c610382830f80eb004e84eef144a1992a",
+ "retrieved_at": "2026-10-19T12:48:34.323792+00:00"
+ }
+]
diff --git a/tutorial_dual/static/web-assets/interactive/data/interpreter.json b/tutorial_dual/static/web-assets/interactive/data/interpreter.json
new file mode 100644
index 0000000..6a65c07
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/data/interpreter.json
@@ -0,0 +1,9 @@
+{
+ "repository": "https://github.com/coli-saar/c-rasp",
+ "branch": "c-star-rasp",
+ "commit": "1e821d7279015ce0e77830faa7932575447c5164",
+ "raw_base": "https://raw.githubusercontent.com/coli-saar/c-rasp",
+ "files": ["program.py", "parser.py", "predefined.py", "tracing.py", "antlr/craspLexer.py", "antlr/craspParser.py", "antlr/craspVisitor.py"],
+ "antlr_version": "4.13.1",
+ "pyodide_version": "0.27.7"
+}
diff --git a/tutorial_dual/static/web-assets/interactive/index.html b/tutorial_dual/static/web-assets/interactive/index.html
new file mode 100644
index 0000000..666c613
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/index.html
@@ -0,0 +1,661 @@
+
+
+
+
+
+
+ C-RASP and transformer length generalization
+
+
+
+
+ Skip to article
+
+ C-RASP / LENGTH GENERALIZATION
+
+
+
+
+
+
Counting · Attention · Generalization
+
C-RASP and length generalization
+
+ This tutorial describes C-RASP computations, their representation by transformers, and
+ the assumptions under which an idealized learner can identify a computation from shorter
+ inputs. Interactive examples illustrate the constructions; published experiments provide
+ a separate empirical comparison.
+
+ A transformer trained to recognize balanced parentheses on short strings may be
+ evaluated on longer strings. Understanding this setting requires separating what the
+ model can represent, what training identifies, and where the resulting computation
+ remains correct.
+
+
+ One possibility is that it has discovered a computation whose instructions do not
+ depend on input length. C-RASP makes such computations explicit: a fixed sequence of
+ operations acts on a vector with one entry per input token. Longer inputs produce
+ longer vectors, not longer programs.
+
+
+
+
Representability
+
Does a transformer exist that computes the target?
+
+
+
Learning
+
Does training find a solution on the observed inputs?
+
+
+
Length generalization
+
Does that solution also work beyond the training lengths?
+
+
+
+ These are different questions. A construction answers the first; it does not, on its
+ own, answer the other two. The following sections discuss these questions separately.
+
+
+
+
+
+
02 / A worked example
+
Recognizing balanced parentheses with C-RASP
+
+ The language Dyck-1 consists of correctly balanced strings using one kind of
+ parenthesis. For a nonempty input, two conditions characterize membership: no prefix
+ contains more closing than opening parentheses, and the complete input has equally
+ many of each. The first condition checks the order; the second checks that every
+ opening parenthesis is eventually closed.
+
+
+ For example, ()(()) satisfies both conditions. The string
+ ())(() has the same final counts, but its prefix ()) already
+ contains too many closes. A test that only compares the totals would miss this
+ violation.
+
+
From a sequence to vectors
+
+ A C-RASP assignment produces a vector with one entry per token position. A Boolean
+ vector records a yes/no answer at each position; a number vector records an integer.
+ Operations combine entries at the same position, or count how often a Boolean
+ predicate is true in the prefix. A prefix includes the current position.
+
+
+ The walkthrough starts with Opens, the number of opening parentheses seen
+ so far. Its values on ()(()) are 1, 1, 2, 3, 3, 3. The
+ program then constructs the closing counts, detects invalid prefixes, and combines the
+ two acceptance conditions. All seven assignments apply at every input length.
+
+
+ Follow the computation. Select Add next assignment to reveal
+ one row at a time. Each column corresponds to an input position. Select a token or
+ value to inspect the prefix used there. After reaching the final row, switch to the
+ counterexample and look for the first violation. Rows are revealed gradually only in
+ this walkthrough; the complete generated code is available below it.
+
+
+
+ Open the walkthrough in its own tab. Position labels in the activities start at 0. They use nonempty inputs; this choice
+ is a scope limit of the interface, not a claim that Dyck-1 excludes the empty word.
+
+
Why the final check works
+
+ Violation is true wherever Opens < Closes. Counting those
+ positions produces Violations; checking that it is zero gives
+ Safe. This retains the history of an invalid prefix even if later tokens
+ make the totals equal. Separately, Balanced compares the two counts at
+ the current position. The final assignment is Safe AND Balanced.
+
+
+ At the last position, these checks express exactly the two conditions above. The
+ argument does not depend on a maximum input length. This establishes what the program
+ computes; it does not establish that training a transformer will discover it.
+
+
+
+
+
03 / Construct a program
+
Experiment with C-RASP operations
+
+ The same operations can express tasks other than parenthesis recognition. In the
+ playground below, the initial program asks whether an input contains an
+ a. The first block marks positions containing a; the second
+ counts those marks; the third checks whether the count is greater than zero. The
+ decision is read at the last token.
+
+
+ Build without writing syntax. Click or drag an operation from the
+ palette to add a block. Choose its inputs from the menus and read the resulting vector
+ directly beneath it. Only earlier variables of a compatible type are offered. A count
+ consumes yes/no values and produces numbers; a comparison consumes numbers and
+ produces yes/no values. Values update immediately when you edit the program or input.
+
+
+ Start by changing the token test from a to b. Then try the
+ starter that requires both symbols: which extra checks are needed? Finally, compare it
+ with the starter asking whether an a occurs before a b. Test
+ a b and b a. Both have the same totals, but prefix
+ information distinguishes their order. This starter does not require adjacency.
+
+
+
+ Open the block playground in its own tab. The interface provides a subset of C-RASP: token tests, prefix counts, comparisons,
+ Boolean combinations, and addition. Its limits of 24 blocks and 16 tokens are
+ interface limits, not bounds on the language. The
+ text editor offers the syntax supported by the original
+ Python implementation.
+
+
What a well-formed program tells us
+
+ The block editor prevents syntax errors, incompatible inputs, and references to
+ variables that have not yet been defined. These checks do not ensure that a program
+ solves the intended task. A program that only checks equal totals is well-formed, but
+ it does not recognize Dyck-1. Changing inputs and inspecting vectors helps find
+ counterexamples; a correctness argument is still needed for all lengths.
+
+
+ Full C-RASP also includes operations such as subtraction, min/max, and conditional
+ selection. The important feature for this tutorial is that a fixed finite program
+ specifies a sequence of vector operations independently of input length. The next
+ section explains how transformer layers can represent such operations.
+
+
+
+
+
04 / Counting with attention
+
Representing counts with uniform attention
+ Construction intuition
+
+ The walkthrough used exact integer counts. To connect this to a transformer
+ construction, consider a head whose attention scores are zero at every visible
+ position. Softmax then assigns equal weight to every position. If the values encode a
+ Boolean predicate, the result is its frequency in the prefix.
+
+
+ Attention(P)i = C(i) / (i + 1)With a BOS token and i ordinary input tokens; P is false at BOS.
+
+
+ Counts at the same position share a positive denominator, so their ordering is
+ preserved. A BOS-only indicator has sum 1: averaging it supplies
+ 1/(i + 1), the encoded integer constant 1.
+
+
+ Reading the attention illustration. The sequence is fixed to
+ ()(()). Move the slider to change the number of ordinary tokens visible
+ to attention. The marked prefix also includes BOS, so each visible position receives
+ weight 1/(i + 1). The two fractions show the normalized opening-parenthesis count and
+ the normalized constant 1. This illustrates the arithmetic of the construction; it is
+ not attention measured in a trained model.
+
+
+
+ Inspect a uniform-attention prefixExact arithmetic illustration
+
+
+
+
+ Each box shows its value for “is an opening parenthesis” and its attention weight.
+ Muted boxes are outside the visible prefix.
+
+
+
+
+
+ BOS contributes zero to the opening-parenthesis count and one to the constant
+ indicator. Tokens outside the prefix receive no weight.
+
+
+
+
+
Precision and LayerNorm
+
+ Adjacent normalized counts differ by 1/(i + 1). Resolving them through length n
+ motivates O(log n) fractional precision for these values. This is a resolution
+ argument, not a proof that rounding every intermediate activation preserves a
+ complete simulation.
+
+
+ Yang and Chiang account for LayerNorm using paired coordinates with opposite signs
+ and additional comparison machinery. The full construction is more than simply
+ applying an unscaled comparison to an attention output.
+
+ C-RASP[Φ, Ψ] adds unary predicates on positions and binary relations that restrict
+ counting. Throughout this article, C-RASP[Pos] abbreviates the
+ paper’s C-RASP[periodic, local]: periodic unary predicates, and translation-invariant
+ local binary relations. Unrestricted prefix counting remains available separately.
+
+
+
+
Periodic
+
+ A pattern repeats after a fixed number of positions. An infinite sequence can have
+ a finite description.
+
+
+
+
Translation-invariant
+
Shifting both positions equally does not change their relation.
+
+
+
Local
+
The additional positional relation is false beyond a fixed distance.
+
+
+
+ Comparing positional relations. The boxes represent positions 0
+ through 11. The arrow marks the query position i; highlighted boxes are keys j
+ selected by the chosen relation within the causal prefix j ≤ i. Change the query
+ position to see how the selected keys move. The preceding-token relation satisfies
+ both restrictions. The other options illustrate what fails when locality or
+ translation invariance is removed; they are not all allowed relations in C-RASP[Pos].
+
+
+
+ Which positions are selected?Positional predicate
+
+
+
+
+
+
+ Query = the position doing the counting. Selected = a position that contributes if
+ its token predicate is true. Masked = a later position excluded by causal
+ attention.
+
+
+
+
+
+
+
+ A preceding-token relation is both local and translation-invariant. Even distance is
+ translation-invariant but not local. These restrictions concern the additional
+ positional interaction; content-based attention can still aggregate over an entire
+ prefix.
+
+
+
Where limit transformers enter
+
+ Learned absolute positional embeddings occupy a finite table. To analyze growing
+ context lengths, Huang et al. introduce limit transformers: fixed-width mathematical
+ models defined at arbitrary lengths, with positional vectors and additional
+ positional attention functions.
+
+
+ For the theorem, the vectors are periodic and the additional functions are
+ translation-invariant and local. Every C-RASP[Pos] program can be compiled into a
+ limit transformer with these properties. The converse expressive equivalence is not
+ established.
+
+
+
+
+
+
06 / From programs to learning
+
Identification under an idealized inference procedure
+ Theorem · Idealized inference
+
+ For context length n, imagine choosing a transformer that matches the target on
+ every input of length at most n/2, at every valid offset. Among admissible
+ exact fits Un, choose one with nearly the smallest complexity regularizer:
+
+
ℛ(Tn) ≤ infT ∈ Uₙ ℛ(T) + 1/n
+
+ An exact minimum need not be attained. The vanishing allowance makes the selection
+ rule well-defined whenever suitable fits exist. The hypothesis class also restricts
+ positional parameter products to be offset-independent; regularization alone does not
+ impose that condition.
+
+
+ The main equivalence
+
+ Under the paper’s assumptions, representability by a periodic, local limit
+ transformer is equivalent to eventual length generalization with uniformly bounded
+ regularizer values for any sequence selected by its inference procedure. Both
+ bounded complexity and eventual generalization matter.
+
+
+
+ Intuitively, bounded complexity leaves finitely many underlying candidate algorithms.
+ Every candidate that disagrees with the target has a finite counterexample. Increasing
+ the training length eventually exposes those counterexamples.
+
+
+
Read the assumptions before applying the theorem
+
+ This is not a guarantee for SGD on sampled training data. The learner fits all
+ shorter inputs, approximately minimizes a specific regularizer, and operates in a
+ restricted hypothesis class. The paper’s model uses length-scaled attention,
+ specified rounding of logits and exponentials, and ReLU/Heaviside activations. Its
+ initial analysis allows infinite-precision activations.
+
+
+ “Matching” is defined for the paper’s output functions, not merely equal accuracy on
+ a finite test set. A Boolean recognition formulation requires a compatible output
+ encoding. The theorem is about identified computations, not convergence of learned
+ matrices to unique weights.
+
+ Expressibility is a theoretical property. Generalization accuracy is an observation
+ about a particular training setup. The figures below reproduce results reported by
+ Huang et al. (2025). The separate gallery supports inspection of additional saved
+ evaluations.
+
+
+ Reading the published plots. Each panel compares accuracy across
+ three length bins. Use the buttons to switch between the overview and the version
+ labeling individual regular languages; click the figure to view it at full size. Solid
+ and dotted lines distinguish positional-embedding settings. The caption below explains
+ the training and model-selection ranges and the meaning of the colors.
+
+
+
+
+
+
+
+ Huang et al. (2025), Figure 1. Published experiment results; not new runs. Open the
+ image for full detail.
+
+
+
+ Source:
+ A Formal Framework for Understanding Length Generalization in Transformers, arXiv v2, CC BY 4.0. Original SVGs, unchanged. Training lengths are ≤50;
+ hyperparameters were selected using performance through 100, so [101,150] is the later
+ generalization evaluation. Green indicates a program was found; red includes both
+ proved nonexpressibility and cases where no program was found. Accuracy requires all
+ predicted positions in an example to be correct. These plots do not prove
+ learnability.
+
+
Additional saved evaluations
+
+ To inspect a run, import a JSON file and select it from the run menu. The plot shows
+ whole-sequence accuracy against input length, with the training range shaded and, when
+ recorded, the model-selection range marked separately. The table gives the underlying
+ correct and total counts. Each curve corresponds to one checkpoint and seed;
+ connecting lines do not represent additional measurements. No additional runs are
+ included in this edition.
+
+
+ Import a JSON file matching the
+ evaluation schema. Files are read locally in
+ your browser. No training or evaluation jobs are started. Each curve represents one
+ run and its recorded checkpoint.
+
+
+
+
+
+
+ No local experiment results supplied yet. Missing evaluations are unavailable—not
+ zero accuracy.
+
+
+
+
+
+
+
+
+
+
08 / What remains open
+
Scope and limitations
+
+ C-RASP supplies concrete algorithms, a route to transformer constructions, and a
+ sufficient condition for the idealized identification theorem. It does not tell us
+ that ordinary training will discover the intended algorithm, nor that a successful
+ finite evaluation establishes correctness at all lengths.
+
+
+ There is also no general computable training-length bound in terms of description size
+ for unrestricted C-RASP—even at counting depth two. Yang et al. (2026) identify a
+ positive fragment, C-RASP₊, for which worst-case optimal exponential bounds do exist.
+ Its restricted count comparisons and the corresponding fixed-precision transformer
+ model matter to that result.
+
+
+ The examples above expose the computation specified by a program. Determining whether
+ a trained transformer implements that computation requires additional evidence beyond
+ agreement on the displayed inputs.
+
+ The number of positions satisfying a predicate, including the current position.
+
+
BOS
+
+ A beginning-of-sequence token. Its indicator supplies a reference for normalized
+ constants.
+
+
APE / NoPE
+
Learned absolute positional embeddings / no explicit positional embeddings.
+
C-RASP[Pos]
+
+ This article’s abbreviation for C-RASP[periodic, local], not unrestricted
+ positional access.
+
+
ℛ
+
+ The framework’s complexity regularizer. A uniform bound is independent of context
+ length.
+
+
+
+
+
+
+
+
+
+
diff --git a/tutorial_dual/static/web-assets/interactive/notebook.html b/tutorial_dual/static/web-assets/interactive/notebook.html
new file mode 100644
index 0000000..096a553
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/notebook.html
@@ -0,0 +1,113 @@
+
+
+
+
+
+ Five ways to explore C-RASP
+
+
+
+
+
+ Skip to notebook
+
+ C-RASP / LENGTH GENERALIZATIONBack to the tutorial
+
+
+
+
Notebook experiments · Five interaction designs
+
See a program take shape.
+
+ You do not need to know the syntax. A C-RASP program assigns a value to every token
+ position. Some values are numbers; others are yes/no answers. Try five ways of working
+ with those values below.
+
+
+ Start with the walkthrough, or try the block builder to construct a program. These are
+ alternative notebook designs for comparison, not five required lessons.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Values at each position
+
+
+
+
+
+
+
+ Preparing the local evaluator…
+
+
+
+
+
+ The code view translates your choices into syntax. You can complete every activity without
+ opening it.
+
+
+
+
+
What each design lets us test
+
+
01 · Walkthrough
+
+ Does revealing one assignment at a time make a vector computation easier to follow?
+
+
02 · Block builder
+
+ Can readers construct and modify well-formed programs by connecting typed operations?
+
+
03 · Repair a rule
+
Does a small counterexample explain why comparing final counts is insufficient?
+
04 · Token sandbox
+
+ Do immediate changes in prefix counts help connect a symbol sequence to its computation?
+
+
05 · Predict a value
+
Can readers infer a cell before seeing it, and explain their prediction?
+
+
+ These activities evaluate structured blocks directly in your browser, without a download
+ or server-side execution. The evaluator is cross-checked against the original
+ C-RASP Python interpreter
+ during development. This is a small typed subset, not all of C-RASP. It prevents malformed
+ expressions and forward references; a well-formed program can still compute the wrong
+ function. No transformer is trained here.
+
+
+ Position labels start at 0. Prefix counts include the current position. The learning
+ examples use nonempty inputs of at most 16 tokens; empty-word recognition is outside these
+ activities. Yes/no values are displayed as words rather than unexplained symbols.
+
+
+
+
+
diff --git a/tutorial_dual/static/web-assets/interactive/runtime/bridge.py b/tutorial_dual/static/web-assets/interactive/runtime/bridge.py
new file mode 100644
index 0000000..b117370
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/runtime/bridge.py
@@ -0,0 +1,50 @@
+"""JSON adapter for the pinned coli-saar/c-rasp interpreter; not a replacement evaluator."""
+import json
+from antlr4 import InputStream, CommonTokenStream
+from antlr4.error.ErrorListener import ErrorListener
+from parser import CRaspLexer, CRaspParser, MyCraspVisitor
+
+class ParseError(ErrorListener):
+ def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
+ raise ValueError(f"Line {line}, column {column + 1}: {msg}")
+
+class JSONTrace:
+ def __init__(self):
+ self.rows = []
+ def accept_tokens(self, tokens):
+ self.tokens = list(tokens)
+ def accept(self, variable, value):
+ self.rows.append({"name": variable, "values": list(value)})
+ def accept_result(self, result):
+ self.result = result
+
+def run_trace(source, tokens):
+ if not 1 <= len(tokens) <= 128:
+ raise ValueError("Use between 1 and 128 tokens. Empty-input acceptance is not specified here.")
+ if len(source) > 12000:
+ raise ValueError("Program is limited to 12,000 characters.")
+ lexer = CRaspLexer(InputStream(source))
+ lexer.removeErrorListeners()
+ lexer.addErrorListener(ParseError())
+ stream = CommonTokenStream(lexer)
+ parser = CRaspParser(stream)
+ parser.removeErrorListeners()
+ parser.addErrorListener(ParseError())
+ tree = parser.program()
+ stream.fill()
+ if stream.LA(1) != -1:
+ raise ValueError("Unexpected trailing input in program.")
+ program = MyCraspVisitor().visit(tree)
+ if not program.statements:
+ raise ValueError("Add at least one assignment.")
+ if len(program.statements) > 64:
+ raise ValueError("Program is limited to 64 assignments.")
+ trace = JSONTrace()
+ try:
+ result = program.execute(tokens, tracing=trace)
+ except SystemExit as exc:
+ raise ValueError("The interpreter could not evaluate this program; check variable names and types.") from exc
+ if type(result) is not bool:
+ raise ValueError("A recognition program must end in a Boolean-valued assignment.")
+ return {"tokens": trace.tokens, "rows": trace.rows, "accepted": result,
+ "engine": "coli-saar/c-rasp", "index_base": 0}
diff --git a/tutorial_dual/static/web-assets/interactive/runtime/python-worker.js b/tutorial_dual/static/web-assets/interactive/runtime/python-worker.js
new file mode 100644
index 0000000..2727f75
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/runtime/python-worker.js
@@ -0,0 +1,40 @@
+/* Runs the original Python interpreter in a disposable worker. */
+let ready;
+async function initialize() {
+ postMessage({ status: "Loading Python (first run downloads the runtime)…" });
+ importScripts("https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js");
+ const py = await loadPyodide();
+ await py.loadPackage("micropip");
+ await py.runPythonAsync(
+ "import micropip\nawait micropip.install('antlr4-python3-runtime==4.13.1')",
+ );
+ const manifest = await (await fetch("../data/interpreter.json")).json();
+ py.FS.mkdirTree("/crasp/antlr");
+ for (const path of manifest.files) {
+ const response = await fetch(`${manifest.raw_base}/${manifest.commit}/${path}`);
+ if (!response.ok)
+ throw new Error(`Could not load interpreter file ${path}: ${response.status}`);
+ py.FS.writeFile(`/crasp/${path}`, await response.text());
+ }
+ const response = await fetch("bridge.py");
+ if (!response.ok) throw new Error("Could not load interpreter adapter");
+ py.FS.writeFile("/crasp/bridge.py", await response.text());
+ await py.runPythonAsync(
+ "import sys\nsys.path.insert(0, '/crasp')\nfrom bridge import run_trace\nimport json",
+ );
+ return py;
+}
+onmessage = async ({ data }) => {
+ try {
+ ready ??= initialize();
+ const py = await ready;
+ postMessage({ status: "Running the Python interpreter…" });
+ py.globals.set("request_json", JSON.stringify(data));
+ const result = await py.runPythonAsync(
+ "request = json.loads(request_json)\njson.dumps(run_trace(request['source'], request['tokens']))",
+ );
+ postMessage({ result: JSON.parse(result) });
+ } catch (error) {
+ postMessage({ error: String(error.message || error) });
+ }
+};
diff --git a/tutorial_dual/static/web-assets/interactive/text-editor.html b/tutorial_dual/static/web-assets/interactive/text-editor.html
new file mode 100644
index 0000000..354e7e4
--- /dev/null
+++ b/tutorial_dual/static/web-assets/interactive/text-editor.html
@@ -0,0 +1,157 @@
+
+
+
+
+
+ C-RASP text editor
+
+
+
+
+
+ C-RASP / LENGTH GENERALIZATIONBack to the block playground
+
+
+
+
02 / A program you can run
+
A C-RASP program for balanced parentheses
+
+
+ In Dyck-1, every opening parenthesis must eventually close, and no prefix may contain more
+ closes than opens. Checking only the totals would incorrectly accept
+ ())(.
+
+
+ The program below counts opens and closes, marks invalid prefixes, and accepts only when
+ no violation has occurred and the final counts agree. The symbol
+ # counts inclusively through the current token.
+
+
+ Using the example. The initial table is a saved execution of the
+ displayed program on ()(()). Each assignment produces one value per token.
+ Select an assignment name or use the arrows to highlight a row; select a count cell to
+ inspect its prefix. Edit the tokens or program and choose
+ Run Python interpreter to update the table. For comparison, ())( has
+ equal final counts but is rejected because an earlier prefix contains too many closes. The
+ other examples are available in the menu.
+
+
+
+ C-RASP notebookExecutable program · Python interpreter
+
+
+
+
+
+
+ Start with the balanced input, then compare it with an input that has the same totals
+ but an invalid prefix.
+
+
+
+
+
+
+
+
+
+
Reading the trace
+
+ Each row in the trace is a vector. The last value of the final Boolean row
+ determines acceptance.
+
+
+ Separate tokens with spaces. Parentheses can also be entered without spaces. This
+ tool evaluates 1–128 tokens; it does not define empty-word acceptance.
+
+
+ Uses the original
+ coli-saar/c-rasp interpreter, pinned at 1e821d7. It evaluates programs; it does not construct or
+ train transformer weights. Positions in this implementation start at 0.
+
+
+
+
+
+
+
+ Showing a saved trace generated by the pinned Python interpreter. Run to evaluate
+ edits.
+
+
Execution trace
+
+ Read left to right across input positions, and top to bottom through assignments. ✓
+ means true; · means false. Highlighting a row explains it below; it does not execute
+ the program again.
+
+
+
+
+
+
+
+
+ Interpreter values by token position
+
+
+
+
+
+ Select a count cell to inspect its contributing prefix.
+
+
+
+
+
The language behind the example
+
+ C-RASP has Boolean vectors and integer-valued vectors. Assignments are evaluated in
+ order. Available operations include token predicates, Boolean operations, prefix
+ counting, comparisons, addition, subtraction, min/max, and conditional selection.
+ Equality and strict inequalities can be expressed using ≤ and Boolean operations.
+
+
+ A fixed finite program specifies the same operations at every length. The browser
+ playground uses the supplied Python implementation, including its syntax and zero-based
+ positional predicates. The theoretical examples in the paper are often indexed from 1.
+
Version 2 · Experimental · Separate from main tutorial
+
C-RASP and length generalization
+
An interactive introduction to length generalization and C-RASP: length-independent programs that transformers can implement, and why that is not the same as learning to generalize.
+
Version 2 — drag-and-drop program editing · Does not replace the main tutorial
+
+
+
+
+
+
+
+
+
+
+ 01 / The question
+ Length generalization and C-RASP
+
+
+
+
A model can look accurate on short strings and fail when inputs get longer. Length generalization means: fit on lengths up to N, then stay correct on lengths beyond N.
+
A C-RASP program is a fixed list of assignments. Each assignment builds a vector with one entry per input position—either a yes/no (Boolean) or an integer (count). The number of lines does not depend on the input length: longer inputs only make those vectors longer. Yang and Chiang (2024) show such programs can be implemented by transformers; that is about what can be represented, not what training will find.
+
Keep three questions separate: representability, learning, and length generalization. Writing a C-RASP program answers only the first.
+
+
+
+
+
+
+
+
+ 02 / What is C-RASP
+ Programs, commands, and what “in C-RASP” means
+
+
+
+
C-RASP is a tiny programming language. A program is a fixed list of assignments. Each assignment builds a vector with one value per input position—either a yes/no (Boolean) or an integer (count). Longer inputs make those vectors longer; they do not add new lines to the program.
+
+
What “in C-RASP” means
+
Saying a task is in C-RASP (or C-RASP-expressible) means: there exists a C-RASP program that solves it, using only the commands below. That is a statement about representability—a transformer can implement that program. It is not a claim that training will find the program, or that a trained model will length-generalize.
+
+
The commands
+
Every line of a program is one of these operations (Yang & Chiang). In the tool, an input marker looks like "("; a prefix count looks like # "(".
+
+
+
Boolean vectors
+
+
Input marker — is this position token a?
+
Negation — not
+
Conjunction — and
+
Count comparison — e.g. ≤ between two counts
+
Constant — always true
+
+
+
+
Count vectors
+
+
Prefix count — # how often a Boolean holds so far
+
Conditional — if–then–else on counts
+
Add / subtract
+
Min / max
+
Constant — the integer 1
+
+
+
+
Shorthand such as ==, <, and 0 is derived from these primitives (e.g. equality via two ≤ checks).
+
+
How a program decides
+
Assignments run in order. Each line may use earlier vectors. For recognition, the program ends with a Boolean vector; the input is accepted exactly when that vector’s last position is true.
+
+
Example: Dyck-1
+
Correctly nested () needs two conditions: (1) no prefix has more closes than opens, and (2) the string ends balanced. Both matter—())( has equal totals but already closed too many in the middle. The program below writes those conditions with prefix counts, comparisons, and &&. Step through it on ( ).
+
+
+
+ The C-RASP program · running on ( )
+ Guided example
+
+
+
This box is the program. The highlighted line is the one introduced in the current step.
+
+
+
+
+
+
+
Each row is the vector produced by one program line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 03 / Try it yourself
+ Run the interpreter
+
+
+
+
Pick an example, set the input, then Run. The program view highlights the active line and names the C-RASP command it uses. Edit only if you want—use the pencil.
+
+
+
+ C-RASP notebook
+ Executable program · Python interpreter
+
+
+
+
+
+ Input tokens
+
+
+
+
+
+
+
+
Whitespace-separated tokens (parentheses may be typed without spaces). 1–128 tokens.
+
+
+
+ Program
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Three columns: pick a command, build your program, watch the view update as text. Drag left → middle (or click a command to append). Use ← → to undo/redo, restore for the initial program, then Commit to check it is well-formed and leave the builder.
+
+
+
Commands
+
Drag into Your program →
+
+
+
+
+
+
+
+
Also in C-RASP (Advanced text): +-min/max, if–then–else.
+
+
+ drag
+ →
+
+
+
Your program
+
Drop commands here
+
+
+
+
+ View
+
+
+
+
+
+
Commit checks syntax and that the interpreter can evaluate the program on the current input (last line must be Boolean).
+ Nothing has run yet. Press play to fill this area with the execution trace.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 04 / Related work
+ Papers if you want to dig in
+
+
+
+
These are the main references behind this page. Start with Yang & Chiang for C-RASP itself; Huang et al. for the length-generalization framework and experiments; Yang et al. (2026) for length-complexity bounds.
Andy Yang, Pascal Bergsträßer, Georg Zetzsche, David Chiang, and Anthony W. Lin (2026). Length Generalization Bounds for Transformers. Computability of length bounds for C-RASP and the positive fragment C-RASP₊.