diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..47ab6da --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,14 @@ +name: Check tutorial +on: [push, pull_request, workflow_dispatch] +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: node scripts/check.mjs + - run: node --test tests/*.test.mjs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..776ced7 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,28 @@ +name: Publish tutorial to Pages +on: workflow_dispatch +permissions: + contents: read + pages: write + id-token: write +concurrency: + group: pages + cancel-in-progress: false +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: node scripts/check.mjs + - run: node --test tests/*.test.mjs + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: site + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11eedd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +__pycache__/ +*.pyc +.venv/ +node_modules/ +test-results/ +playwright-report/ + +# Typst/Tola local tools and generated exports +.tools/ +tmp/ +/draft_2.pdf + +# Local historical copies; never part of the current build +/archive/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..668358b --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +site/assets/paper-*.svg diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..89847b9 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "htmlWhitespaceSensitivity": "css" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0341ea1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Working on this tutorial + +- Work in this repository root, not the former `.tools/repository-export/checkout/` location. +- Edit article prose only in `tutorial_dual/manuscript.typ`. Both PDF and HTML include this file. +- `draft_2.typ` controls PDF layout; `tutorial_dual/components.typ` contains shared helpers. +- Rebuild using `./build_tutorial.sh both`; validate using `./build_tutorial.sh check`. +- `site/` is the standalone interactive companion, not the current article prose. +- `archive/` contains historical copies. Do not edit or build them unless explicitly requested. +- Preserve existing uncommitted work. diff --git a/README.md b/README.md index 6e55061..bea55c2 100644 --- a/README.md +++ b/README.md @@ -1 +1,39 @@ -# Length_Generalization_Tutorial \ No newline at end of file +# Length Generalization in Transformers + +This directory is the current Git working copy. Edit **`tutorial_dual/manuscript.typ`** for article content; it supplies both the PDF and HTML editions. + +## Current files + +| Path | Purpose | +| --- | --- | +| `tutorial_dual/manuscript.typ` | Single source for the article text | +| `tutorial_dual/components.typ` | Shared layout and interactive helpers | +| `draft_2.typ` | PDF layout; includes the manuscript | +| `draft_2.pdf` | Current generated PDF | +| `tutorial_dual/public/` | Current generated HTML site | +| `site/` | Standalone interactive companion and drag-and-drop editor | +| `.tools/` | Local Typst, Tola and Node binaries and downloads | +| `archive/` | Older drafts, snapshots and previous preview artifacts; not used by builds | + +## Build and preview + +Run from this directory: + +```sh +./build_tutorial.sh both # rebuild PDF and HTML +./build_tutorial.sh check # validate the HTML build +./build_tutorial.sh preview # serve the current article with live rebuilding +``` + +See [the article build guide](tutorial_dual/README.md) for details. The standalone editor has its own [companion documentation](docs/interactive-companion.md); its HTML is not the article source. `npm run preview` serves that companion, while `./build_tutorial.sh preview` serves the current article. + +## Older material + +The consolidation snapshot is in `archive/2026-11-04-before-consolidation/`: + +- `previous-top-level/`: older top-level drafts, manuscript, exports, figures and temporary PDF previews. +- `original-interactive-snapshot/`: original imported interactive tutorial. +- `repository-export-metadata/`: previous export metadata. +- `migration-record.json`: original checkout location, Git HEAD, change list and file hashes. + +The former `.tools/repository-export/checkout/` was moved here with its Git history and uncommitted changes. It is no longer a working location. Archives are kept locally and excluded from Git by default. diff --git a/build_tutorial.sh b/build_tutorial.sh new file mode 100755 index 0000000..e805841 --- /dev/null +++ b/build_tutorial.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$ROOT" + +# Prefer explicit overrides, then tools on PATH, then workspace-local binaries. +# Searching parents also supports a checkout nested below the workspace's .tools/. +resolve_tool() { + tool_name=$1 + tool_override=$2 + if [ -n "$tool_override" ]; then + if command -v "$tool_override" >/dev/null 2>&1; then + command -v "$tool_override" + return + fi + echo "Cannot execute $tool_name override: $tool_override" >&2 + return 1 + fi + if command -v "$tool_name" >/dev/null 2>&1; then + command -v "$tool_name" + return + fi + tool_dir=$ROOT + while :; do + for tool_candidate in "$tool_dir/.tools/$tool_name" "$tool_dir"/.tools/"$tool_name"-*/"$tool_name"; do + if [ -x "$tool_candidate" ] && [ ! -d "$tool_candidate" ]; then + echo "$tool_candidate" + return + fi + done + [ "$tool_dir" != / ] || break + tool_dir=$(dirname -- "$tool_dir") + done + echo "Cannot find $tool_name. Install it on PATH or set its binary path with TYPST_BIN / TOLA_BIN." >&2 + return 1 +} + +build_pdf() { + TUTORIAL_TYPST=$(resolve_tool typst "${TYPST_BIN:-}") + "$TUTORIAL_TYPST" compile draft_2.typ draft_2.pdf +} +run_tola() { + TUTORIAL_TOLA=$(resolve_tool tola "${TOLA_BIN:-}") + "$TUTORIAL_TOLA" "$@" -C tutorial_dual/tola.toml --offline +} +case "${1:-both}" in + pdf) build_pdf ;; + html) run_tola build ;; + both) build_pdf; run_tola build ;; + preview) run_tola dev ;; + check) run_tola check ;; + *) echo 'Usage: ./build_tutorial.sh [pdf|html|both|preview|check]' >&2; exit 2 ;; +esac diff --git a/docs/interactive-companion.md b/docs/interactive-companion.md new file mode 100644 index 0000000..783ecd3 --- /dev/null +++ b/docs/interactive-companion.md @@ -0,0 +1,120 @@ +# C-RASP and transformer length generalization + +An editable, interactive companion to the C-RASP tutorial. Open `site/index.html` through a local web server; all deployable files are in `site/`. + +## Preview and edit + +```sh +python3 -m http.server 8000 --directory site +``` + +Visit http://localhost:8000 for the main tutorial, or http://localhost:8000/version2/ for the separately developed experimental version. Run the server from the repository root. `npm run preview` starts the same server. Edit article prose in `site/index.html`, styling in `site/assets/style.css`, and article interactions in `site/assets/article.js`. This first edition uses semantic HTML, CSS and JavaScript with no build step or Node dependencies. It can live in a GitHub Pages project subdirectory or on the chair's web server. The paper's Typst source remains separate; this is an editorial adaptation, not an automatically synchronized export. + +The page has visible formal explanations, anchored sections, keyboard-accessible trace inspection, editable examples, program-sharing URLs, trace downloads, attention and positional-relation illustrations, published figures, and a saved-evaluation gallery. It is responsive and has a print stylesheet. + +## Integrated article + +The main `index.html` draft embeds two independent activities in the reading sequence: a Dyck-1 walkthrough and a general block playground. The latter starts with “contains a” and offers programs for both-symbol presence and a-before-b order. It also supports starting empty. Both activities run locally without a Python download. The article proceeds from those examples to attention constructions, positional information, idealized length generalization, empirical evidence, and limitations. + +The embedded views reuse `notebook.html?embed=walk` and `notebook.html?embed=build`. Their same-origin resize messages let the article display each activity at its full height, including after edits. `article.js` handles the article widgets; `editor.js` handles the separate optional text editor. Older shared-program links to `index.html#program=…` redirect to that editor. + +## The optional text editor and Python implementation + +The separate `text-editor.html` page runs the original **interpreter** from [coli-saar/c-rasp, c-star-rasp](https://github.com/coli-saar/c-rasp/tree/c-star-rasp), commit `1e821d7279015ce0e77830faa7932575447c5164`. It does not compile transformer weights or train a model. It uses ordinary C-RASP execution, not the optional C*-RASP mode. Upstream source is fetched by immutable commit at runtime rather than copied into this repository. The manifest is `site/data/interpreter.json`. + +Python runs in a disposable Web Worker with Pyodide 0.27.7 and antlr4-python3-runtime 4.13.1. The first Run needs internet access to jsDelivr, PyPI and raw.githubusercontent.com and can take several seconds. Program/input contents stay in the browser; only dependency downloads are sent to those hosts. Sharing a link deliberately embeds the program and input in its URL fragment. No program auto-runs from a shared link. The initial example trace was generated by the pinned interpreter and works without loading Python. + +The UI limits execution to 128 tokens and 64 assignments and stops the worker after 90 seconds. Positions are zero-based, prefix counts inclusive, and a recognition program must end in a Boolean vector. The last position determines the interpreter result. This is an implementation convention, not a claimed treatment of the empty word in the papers. + +## Saved experiments + +Published figures 1 and 2 from Huang et al. are included unchanged, with URLs, license attribution, retrieval dates and SHA-256 checksums in `site/data/figure-provenance.json`. + +`site/data/evaluations.json` intentionally starts empty. Add real saved evaluations there, or use the browser's local JSON importer. The schema is `site/data/evaluation-schema.json`. Each run records its task, model, positional encoding, seed, checkpoint, evaluation timestamp, source, training-length maximum, and optional maximum length used for model selection. Each point stores `length`, `correct`, and `total`; accuracy is derived from those counts. The only supported metric is whole-sequence accuracy. Missing evaluations must be omitted, never filled with zero. Curves are displayed individually without aggregating seeds or mixing checkpoints. Lines between measured points are visual guides, not additional measurements. + +Importing does not upload files or start experiments. Do not include unpublished or private data in the tracked JSON unless intended for the site's audience. + +## Checks + +Node 20 or newer, no npm install: + +```sh +node scripts/check.mjs +node --test tests/*.test.mjs +``` + +The checks validate local assets and anchors, figure checksums, data integrity, and rejection of missing/inconsistent evaluation counts. To validate the actual interpreter adapter against all 254 nonempty parenthesis strings through length 7: + +```sh +python3 -m venv .venv +.venv/bin/pip install antlr4-python3-runtime==4.13.1 +# Supply a local checkout of coli-saar/c-rasp at the pinned commit: +.venv/bin/python scripts/check_interpreter.py /path/to/c-rasp +``` + +## Hosting + +For GitHub Pages, push this repository to the chosen destination, choose **Settings → Pages → Source → GitHub Actions**, then manually run **Publish tutorial to Pages**. Deployment is deliberately manual while the article is a working draft. The separate check workflow runs on pushes and pull requests. No hosting has been activated by preparing these files. + +For the chair's website, copy the contents of `site/` into the desired directory. Relative asset paths support subdirectory hosting. Serve `.js` with a JavaScript MIME type and `.svg` as `image/svg+xml`. The host's content-security policy must permit the worker and Pyodide's WebAssembly/runtime dependencies; test the playground after deployment. The article and saved figures do not need a backend. + +## Attribution and editorial status + +This is a working adaptation of the author's tutorial, not a verbatim paper reproduction. Statements about constructions, idealized inference, and empirical accuracy are distinguished. The full paper remains the authority on formal assumptions. + +The original paper figures are CC BY 4.0, credited in the page and provenance file. Upstream interpreter rights remain with its authors; the checkout inspected for this edition did not include an explicit license file. No license for the tutorial's original prose or code has been selected here; choose one before wider reuse. No blanket license is asserted over third-party material. + +## Five notebook experiments + +Visit `notebook.html` to compare five alternative learning interfaces: + +1. **Walkthrough:** reveals one assignment at a time and highlights contributing prefix tokens. +2. **Block builder:** drag or click typed operations, select compatible earlier variables, and read vectors directly on each block. Invalid reorderings and removal of dependencies are rejected. Arrow buttons provide a keyboard/touch alternative to dragging. +3. **Repair a rule:** change a comparison and a Boolean connective, using counterexamples to understand the two conditions for balanced parentheses. +4. **Token sandbox:** flip, add, or remove symbols and inspect the changes in counts, the first invalid prefix, and the final decision. +5. **Predict a value:** predict a selected cell, then reveal it with an explanation. + +These are comparative prototypes, not a required five-step course. The original text editor remains available. For a novice-facing version, a promising sequence is the walkthrough followed by the block builder; that is a design hypothesis, not a tested learning outcome. + +The new page uses a synchronous JavaScript evaluator over a typed block representation. It needs no Python download or network access after the static assets load. Source generation is separate from evaluation, and the page can transfer a program into the original Python-backed text editor. The builder is limited to 24 blocks and 16 nonempty input positions; it is deliberately a subset of C-RASP. Unselected output is an execution sentinel and is never shown as an acceptance decision. + +Source layout: + +- `site/assets/notebook-model.js`: types, templates, validation, and code generation. +- `site/assets/notebook-engine.js`: pure block evaluation, with checked integer arithmetic. +- `site/assets/notebook.js`: the five interfaces and explanatory feedback. +- `site/assets/notebook.css`: layout for the notebook experiments. + +`node --test tests/*.test.mjs` checks types, dependency ordering, and causal counting. For a differential check of complete JavaScript and Python traces, use an environment with `antlr4-python3-runtime==4.13.1` and the pinned interpreter checkout: + +```sh +.venv/bin/python scripts/check_notebook_interpreter.py /path/to/c-rasp /path/to/node +``` + +This checks 443 traces, including deterministic random programs and every nonempty parenthesis input through length 7. It does not prove correctness of arbitrary programs. + +## Version 2 (separate remote contribution) + +The GitHub update adds `site/version2/`, with a chapter-based tutorial and drag-and-drop program editor. It remains self-contained and is linked from the main page header and footer. It does not replace the integrated main draft. See `site/version2/README.md` for its instructions. + + +## Typst/Tola manuscript (PDF and HTML) + +The alternative authoring setup is in [`tutorial_dual/`](tutorial_dual/README.md). +Edit [`tutorial_dual/manuscript.typ`](tutorial_dual/manuscript.typ) for the shared +article text. Both exports include this single source; [`draft_2.typ`](draft_2.typ) +contains only the PDF layout settings. + +With Typst 0.15.1 and Tola 0.8.1 on PATH: + +```sh +./build_tutorial.sh both +./build_tutorial.sh check +./build_tutorial.sh preview +node --test tutorial_dual/tests/*.test.mjs +``` + +The build writes `draft_2.pdf` and `tutorial_dual/public/`; these generated outputs +are not tracked. The existing `site/` tutorial and its Pages deployment remain +unchanged. The Tola version embeds a local copy of the existing notebook after the +Dyck-1 example and uses expandable explanations in HTML and fully visible text in PDF. diff --git a/draft_2.typ b/draft_2.typ new file mode 100644 index 0000000..fce2b76 --- /dev/null +++ b/draft_2.typ @@ -0,0 +1,17 @@ +// PDF entry point. The HTML entry point includes the same manuscript. +#set document(title: "Length Generalization in Transformers") +#set page(paper: "a4", margin: (x: 23mm, y: 22mm), numbering: "1") +#set text(font: "Libertinus Serif", size: 11pt, lang: "en") +#set par(justify: true, leading: 0.65em) +#set heading(numbering: "1.1") +#show heading.where(level: 4): set heading(numbering: none) +#set table(inset: 5pt, stroke: 0.4pt + luma(200)) +#show table: it => block(breakable: false, it) +#align(center)[#text(size: 23pt, weight: "bold")[Length Generalization in Transformers]] +#v(1em) +#text(size: 9pt, fill: luma(100))[ + Green-tinted boxes contain the explanations that can be expanded in the HTML edition. +] +#outline(title: [Contents], depth: 2) +#pagebreak() +#include "tutorial_dual/manuscript.typ" diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a20d3f --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "crasp-explorable-tutorial", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/*.test.mjs", + "check": "node scripts/check.mjs", + "preview": "python3 -m http.server 8000 --directory site" + }, + "engines": { + "node": ">=20" + } +} diff --git a/scripts/check.mjs b/scripts/check.mjs new file mode 100644 index 0000000..aaa8193 --- /dev/null +++ b/scripts/check.mjs @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import path from "node:path"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { validateEvaluations } from "../site/assets/lib.js"; +const root = new URL("../site/", import.meta.url); +for (const page of ["index.html", "notebook.html", "text-editor.html"]) { + const html = fs.readFileSync(new URL(page, root), "utf8"); + const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]); + assert.equal(new Set(ids).size, ids.length, "Duplicate HTML IDs"); + for (const [, ref] of html.matchAll(/(?:src|href)="([^"]+)"/g)) { + if (ref.startsWith("#")) { + if (ref.length > 1) assert(ids.includes(ref.slice(1)), `Missing anchor ${ref}`); + } else if (!/^[a-z]+:/i.test(ref)) + assert(fs.existsSync(new URL(ref, root)), `Missing local asset ${ref}`); + } +} +validateEvaluations(JSON.parse(fs.readFileSync(new URL("data/evaluations.json", root)))); +for (const f of JSON.parse(fs.readFileSync(new URL("data/figure-provenance.json", root)))) + assert.equal( + createHash("sha256") + .update(fs.readFileSync(new URL(f.file, root))) + .digest("hex"), + f.sha256, + ); +const trace = JSON.parse(fs.readFileSync(new URL("data/default-trace.json", root))); +assert.equal(trace.accepted, true); +assert(trace.rows.every((r) => r.values.length === trace.tokens.length)); +console.log( + "Local links, anchors, evaluation data, trace shape and original figure checksums pass.", +); diff --git a/scripts/check_interpreter.py b/scripts/check_interpreter.py new file mode 100644 index 0000000..67ad251 --- /dev/null +++ b/scripts/check_interpreter.py @@ -0,0 +1,22 @@ +"""Run against an explicitly supplied checkout; never downloads or trains anything.""" +import itertools, json, pathlib, sys +root = pathlib.Path(__file__).resolve().parents[1] +sys.path[:0] = [str(root / 'site/runtime'), str(pathlib.Path(sys.argv[1]).resolve())] +from bridge import run_trace +source = 'C_open = # "("\nC_close = # ")"\nV = C_open < C_close\nCV = # V\nM = (CV == 0)\nB = (C_open == C_close)\nD = M && B' +def dyck(tokens): + balance = 0 + for t in tokens: + balance += 1 if t == '(' else -1 + if balance < 0: return False + return balance == 0 +for n in range(1, 8): + for tokens in itertools.product('()', repeat=n): + assert run_trace(source, list(tokens))['accepted'] == dyck(tokens) +expected = json.loads((root / 'site/data/default-trace.json').read_text()) +assert run_trace(source, list('()(())')) == expected +for bad in ['', 'A = #', 'A = ???']: + try: run_trace(bad, ['a']) + except (ValueError, TypeError): pass + else: raise AssertionError('Invalid program accepted') +print('254 parenthesis inputs match an independent Dyck recognizer; saved trace and malformed-input checks pass.') diff --git a/scripts/check_notebook_browser.cjs b/scripts/check_notebook_browser.cjs new file mode 100644 index 0000000..4c1248a --- /dev/null +++ b/scripts/check_notebook_browser.cjs @@ -0,0 +1,88 @@ +const assert = require("node:assert/strict"); +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || "playwright"); +(async () => { + const b = await chromium.launch({ + headless: true, + ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}), + }); + const p = await b.newPage({ viewport: { width: 1440, height: 1100 } }); + const errors = []; + p.on("pageerror", (e) => errors.push(e.message)); + const ready = () => + p.waitForFunction( + () => document.getElementById("results").getAttribute("aria-busy") === "false", + null, + { timeout: 90000 }, + ); + try { + await p.goto(process.env.PREVIEW_URL || "http://localhost:8765/notebook.html"); + await ready(); + await p.context().setOffline(true); + assert.equal(await p.locator(".value-table tbody tr").count(), 1); + await p.getByRole("button", { name: "Add next assignment →" }).click(); + assert.equal(await p.locator(".value-table tbody tr").count(), 2); + await p.locator('[data-mode="build"]').click(); + await ready(); + assert.match(await p.locator("#decision").textContent(), /Accepted/); + await p.getByRole("button", { name: "Start empty", exact: true }).click(); + await ready(); + await p.locator('[data-operation="token"]').dragTo(p.locator("#block-drop")); + await ready(); + assert.equal(await p.locator("[data-block]").count(), 1); + await p.locator('[data-operation="count"]').click(); + await ready(); + await p.locator('[data-operation="compare"]').click(); + await ready(); + assert.equal(await p.locator("[data-block]").count(), 3); + await p.getByRole("button", { name: "Move A2 up", exact: true }).click(); + assert.match(await p.locator("#interaction-message").textContent(), /before it is defined/); + assert.equal(await p.locator("[data-block]").first().getAttribute("data-block"), "A1"); + await p.getByRole("button", { name: "Remove A1", exact: true }).click(); + assert.match(await p.locator("#interaction-message").textContent(), /used by another/); + await p.locator('[data-mode="repair"]').click(); + await ready(); + assert.match(await p.locator("#repair-feedback").textContent(), /Does not match/); + await p.locator(".puzzle-rule select").nth(0).selectOption("<"); + await p.locator(".puzzle-rule select").nth(1).selectOption("and"); + await ready(); + assert.match(await p.locator("#decision").textContent(), /Rejected/); + assert.match(await p.locator("#repair-feedback").textContent(), /Matches/); + await p.getByRole("button", { name: "()(()) · should accept", exact: true }).click(); + assert.match(await p.locator("#repair-feedback").textContent(), /Matches/); + await p.locator('[data-mode="tokens"]').click(); + await ready(); + await p.locator('[data-position="0"]').click(); + await ready(); + assert.match(await p.locator("#decision").textContent(), /Rejected/); + assert.equal(await p.locator(".live-summary strong").nth(2).textContent(), "0"); + await p.locator('[data-mode="predict"]').click(); + await ready(); + assert.equal(await p.locator(".value-table").count(), 0); + await p.locator(".prediction-options").getByRole("button", { name: "3", exact: true }).click(); + assert.match(await p.locator("#prediction-feedback").textContent(), /Correct/); + assert.equal(await p.locator(".value-table").count(), 1); + await p.getByRole("button", { name: "Next question →" }).click(); + assert.equal(await p.locator(".value-table").count(), 0); + for (const id of ["walk", "build", "repair", "tokens", "predict"]) { + await p.locator(`[data-mode="${id}"]`).click(); + await ready(); + await p.setViewportSize({ width: 390, height: 844 }); + assert.equal( + await p.evaluate(() => document.documentElement.scrollWidth > innerWidth), + false, + `${id} mobile overflow`, + ); + } + await p.locator('[data-mode="build"]').click(); + await ready(); + assert.deepEqual(errors, []); + console.log( + "PASS: five modes, local evaluator, drag/drop, invalid move/removal protection, repair, live edits, prediction and mobile widths.", + ); + } finally { + await b.close(); + } +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/check_notebook_interpreter.py b/scripts/check_notebook_interpreter.py new file mode 100644 index 0000000..69f684e --- /dev/null +++ b/scripts/check_notebook_interpreter.py @@ -0,0 +1,39 @@ +"""Differential check: browser block semantics versus the pinned Python interpreter. +Usage: python scripts/check_notebook_interpreter.py /path/to/c-rasp /path/to/node +No network or training is performed by this script. +""" +import json, pathlib, subprocess, sys +root = pathlib.Path(__file__).resolve().parents[1] +sys.path[:0] = [str(root / 'site/runtime'), str(pathlib.Path(sys.argv[1]).resolve())] +from bridge import run_trace +js = ''' +import {DYCK,CONTAINS,TYPES,compile} from './site/assets/notebook-model.js'; +import {evaluateBlocks} from './site/assets/notebook-engine.js'; +const variants=[[],CONTAINS,DYCK]; +let seed=42; +function pick(xs){seed=(Math.imul(seed,1664525)+1013904223)>>>0;return xs[seed%xs.length];} +// Typed random programs exercise every primitive and combinations of operations. +for(let k=0;k<60;k++){ + const nodes=structuredClone(CONTAINS); + for(let j=0;j<9;j++){ + const bools=nodes.filter(n=>TYPES[n.kind]==='bool').map(n=>n.id); + const nums=['0',...nodes.filter(n=>TYPES[n.kind]==='int').map(n=>n.id)]; + const kind=pick(['token','tokenCount','and','or','not','add','compare','count']); + const choices=['add','compare'].includes(kind)?nums:bools; + nodes.push({id:'Extra'+j,kind,left:pick(choices),right:pick(choices),input:pick(bools),op:pick(['<','<=','==','>=','>']),token:pick(['a','b','(',')'])}); + } + variants.push(nodes); +} +const cases=[]; +function add(nodes,tokens){const output=nodes.filter(n=>TYPES[n.kind]==='bool').at(-1)?.id;cases.push({source:compile(nodes,output),tokens,expected:evaluateBlocks(nodes,tokens,output)});} +for(const nodes of variants)for(const tokens of [['a'],['b','a','b'],[...'()(())']])add(nodes,tokens); +for(let n=1;n<=7;n++)for(let mask=0;mask<2**n;mask++)add(DYCK,Array.from({length:n},(_,i)=>(mask>>i)&1?'(':')')); +console.log(JSON.stringify(cases)); +''' +cases=json.loads(subprocess.check_output([sys.argv[2],'--input-type=module','-e',js],cwd=root,text=True)) +for case in cases: + actual=run_trace(case['source'],case['tokens']) + expected=case['expected'] + for key in ['tokens','rows','accepted','index_base']: + assert actual[key]==expected[key],(key,case['source'],case['tokens'],actual[key],expected[key]) +print(f'{len(cases)} complete traces match Python, including randomized typed programs and all 254 parenthesis inputs through length 7.') diff --git a/site/.nojekyll b/site/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/site/assets/app.js b/site/assets/app.js new file mode 100644 index 0000000..2791db1 --- /dev/null +++ b/site/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/site/assets/article.js b/site/assets/article.js new file mode 100644 index 0000000..1294d45 --- /dev/null +++ b/site/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/site/assets/editor.js b/site/assets/editor.js new file mode 100644 index 0000000..3c1af25 --- /dev/null +++ b/site/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/site/assets/lib.js b/site/assets/lib.js new file mode 100644 index 0000000..5b43324 --- /dev/null +++ b/site/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/site/assets/notebook-engine.js b/site/assets/notebook-engine.js new file mode 100644 index 0000000..9fae65a --- /dev/null +++ b/site/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/site/assets/notebook-model.js b/site/assets/notebook-model.js new file mode 100644 index 0000000..bc8228d --- /dev/null +++ b/site/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/site/assets/notebook.css b/site/assets/notebook.css new file mode 100644 index 0000000..2cdb62c --- /dev/null +++ b/site/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/site/assets/notebook.js b/site/assets/notebook.js new file mode 100644 index 0000000..4b6df8a --- /dev/null +++ b/site/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/site/assets/paper-languages.svg b/site/assets/paper-languages.svg new file mode 100644 index 0000000..9d0a29a --- /dev/null +++ b/site/assets/paper-languages.svg @@ -0,0 +1,2216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/assets/paper-main.svg b/site/assets/paper-main.svg new file mode 100644 index 0000000..ad2f81a --- /dev/null +++ b/site/assets/paper-main.svg @@ -0,0 +1,3490 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/assets/style.css b/site/assets/style.css new file mode 100644 index 0000000..aaab8b3 --- /dev/null +++ b/site/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/site/data/default-trace.json b/site/data/default-trace.json new file mode 100644 index 0000000..53ccb86 --- /dev/null +++ b/site/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/site/data/evaluation-schema.json b/site/data/evaluation-schema.json new file mode 100644 index 0000000..a39a859 --- /dev/null +++ b/site/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/site/data/evaluations.json b/site/data/evaluations.json new file mode 100644 index 0000000..17d5863 --- /dev/null +++ b/site/data/evaluations.json @@ -0,0 +1 @@ +{"schema_version": 1, "runs": []} diff --git a/site/data/figure-provenance.json b/site/data/figure-provenance.json new file mode 100644 index 0000000..0d29e69 --- /dev/null +++ b/site/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/site/data/interpreter.json b/site/data/interpreter.json new file mode 100644 index 0000000..6a65c07 --- /dev/null +++ b/site/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/site/index.html b/site/index.html new file mode 100644 index 0000000..666c613 --- /dev/null +++ b/site/index.html @@ -0,0 +1,661 @@ + + + + + + + C-RASP and transformer length generalization + + + + + +
+ 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. +

+

+ Interactive tutorial draft · C-RASP, transformer constructions, and length + generalization +

+ Start with the Dyck-1 walkthrough +
+
+
Example: prefix balanceDyck-1
+ + Prefix balance for ()(()) is 1, 0, 1, 2, 1, 0 + + + + + + + + + + + + ( + ) + ( + ( + ) + ) + + + 2 + 1 + 0 + open brackets − closed brackets + + +
+ No negative prefixFinal balance = 0 ✓ +
+
+
+
+ +
+ + +
+
01 / The question
+

Three questions about length generalization

+

+ 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. +

+

+ Construction: §5 and Appendix B. +

+
+
+ +
+
05 / When positions matter
+

Positional information and its restrictions

+

+ 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. +

+

+ Huang et al. (2025), Definitions 4–6 and Theorem 7. +

+
+
+ +
+
07 / What experiments show
+

Empirical length-generalization results

+

+ 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. Figure 1: accuracy in three length bins, comparing APE and NoPE transformers across algorithmic tasks and formal languages. +
+ 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. +

+
+ +
+
Read further
+

Sources & notation

+
    +
  1. + Andy Yang and David Chiang (2024). + Counting Like Transformers: Compiling Temporal Counting Logic Into Softmax + Transformers. + COLM. +
  2. +
  3. + Xinting Huang et al. (2025). + A Formal Framework for Understanding Length Generalization in Transformers. + ICLR. +
  4. +
  5. + Andy Yang, Pascal Bergsträßer, Georg Zetzsche, David Chiang, and Anthony W. Lin + (2026). + Length Generalization Bounds for Transformers. +
  6. +
  7. + Hattie Zhou et al. (2024). + What Algorithms Can Transformers Learn? A Study in Length Generalization. + ICLR. Empirical motivation and the RASP-L conjecture. +
  8. +
+
+

Glossary

+
+
Predicate
+
A Boolean value at each position.
+
Prefix count
+
+ 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 + + + + + + +
+ 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. +

+

Yang & Chiang (2024), §3

+
+
+
+ + diff --git a/site/version2/README.md b/site/version2/README.md new file mode 100644 index 0000000..4170a16 --- /dev/null +++ b/site/version2/README.md @@ -0,0 +1,18 @@ +# Version 2 (experimental) + +Self-contained alternate tutorial with drag-and-drop C-RASP program editing. + +This directory does **not** replace the main tutorial at `site/index.html`. + +## How to start + +From the repository root: + +```sh +python3 -m http.server 8000 --directory site +``` + +Then open: + +- Main tutorial: http://localhost:8000/ +- Version 2: http://localhost:8000/version2/ diff --git a/site/version2/assets/app.js b/site/version2/assets/app.js new file mode 100644 index 0000000..41cae1e --- /dev/null +++ b/site/version2/assets/app.js @@ -0,0 +1,1337 @@ +import {tokenize} from './lib.js'; +const $ = id => document.getElementById(id); + +/* ---- Section accordion (one open at a time) ---- */ +const chapters = [...document.querySelectorAll('details.chapter')]; + +function setTocActive(id) { + document.querySelectorAll('[data-open-section]').forEach(a => { + a.classList.toggle('is-active', a.dataset.openSection === id); + }); +} + +function openSection(id, {scroll = true} = {}) { + const target = document.getElementById(id); + if (!target || !target.classList.contains('chapter')) return; + chapters.forEach(ch => { ch.open = ch === target; }); + setTocActive(id); + if (scroll) { + requestAnimationFrame(() => { + target.scrollIntoView({behavior: 'smooth', block: 'start'}); + }); + } + history.replaceState(null, '', `#${id}`); +} + +chapters.forEach(ch => { + ch.addEventListener('toggle', () => { + if (!ch.open) return; + chapters.forEach(other => { + if (other !== ch) other.open = false; + }); + setTocActive(ch.id); + }); +}); + +document.querySelectorAll('[data-open-section]').forEach(el => { + el.addEventListener('click', e => { + e.preventDefault(); + openSection(el.dataset.openSection); + }); +}); + +document.querySelectorAll('.next-chapter').forEach(btn => { + btn.addEventListener('click', () => openSection(btn.dataset.next)); +}); + +const initialHash = location.hash.replace(/^#/, ''); +if (initialHash && document.getElementById(initialHash)?.classList.contains('chapter')) { + openSection(initialHash, {scroll: false}); +} else { + setTocActive(chapters.find(ch => ch.open)?.id || 'question'); +} + + +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'} +}; + +/* ---- Guided walkthrough on ( ) ---- */ +const walkTokens = ['(', ')']; +const walkProgramLines = [ + 'C_open = # "("', + 'C_close = # ")"', + 'V = C_open < C_close', + 'CV = # V', + 'M = (CV == 0)', + 'B = (C_open == C_close)', + 'D = M && B', +]; +const walkRows = [ + {name: 'C_open', values: [1, 1]}, + {name: 'C_close', values: [0, 1]}, + {name: 'V', values: [false, false]}, + {name: 'CV', values: [0, 0]}, + {name: 'M', values: [true, true]}, + {name: 'B', values: [false, true]}, + {name: 'D', values: [false, true]}, +]; +const walkSteps = [ + { + reveal: -1, + line: -1, + caption: 'Each column is one input position. Each program line will fill a whole row (a vector). We start from token markers and build the two Dyck conditions out of counts.', + verdict: '', + }, + { + reveal: 0, + line: 0, + caption: 'C_open = # "(" — prefix count of opens. Under “(” the count is 1; under “)” it stays 1. This is the running open-count both Dyck conditions need.', + verdict: '', + }, + { + reveal: 1, + line: 1, + caption: 'C_close = # ")" — same for closes: 0, then 1. Now every prefix has a pair of numbers we can compare.', + verdict: '', + }, + { + reveal: 2, + line: 2, + caption: 'V marks a prefix violation: more closes than opens (C_open < C_close). Here both entries are false. On ())( this row would turn true in the middle.', + verdict: '', + }, + { + reveal: 3, + line: 3, + caption: 'CV = # V — how many violations so far. Once this leaves zero, a bad prefix is remembered for the rest of the string. Here it stays 0.', + verdict: '', + }, + { + reveal: 4, + line: 4, + caption: 'M means “no violation yet”: CV == 0. That is condition (1) as a Boolean at each position.', + verdict: '', + }, + { + reveal: 5, + line: 5, + caption: 'B means equal open and close counts—condition (2). False after “(” (1≠0), true after “)” (1=1). Alone, B would wrongly accept ())( ; that is why M is required too.', + verdict: '', + }, + { + reveal: 6, + line: 6, + caption: 'D = M && B joins both conditions. Accept iff the last entry of D is true. Here it is → accept. Same program, longer inputs: only the vectors grow.', + verdict: 'Accepted', + }, +]; +let walkIndex = 0; + +function fmt(v) { + if (typeof v === 'boolean') return v ? '✓' : '·'; + return String(v); +} + +function renderWalkProgram(activeLine) { + const pre = $('walk-program'); + pre.replaceChildren(); + walkProgramLines.forEach((text, i) => { + const line = document.createElement('div'); + line.className = 'program-line' + (i === activeLine ? ' active' : ''); + line.textContent = text; + pre.append(line); + }); +} + +function renderWalk() { + const step = walkSteps[walkIndex]; + renderWalkProgram(step.line); + const table = $('walk-table'); + table.replaceChildren(); + const head = table.createTHead().insertRow(); + ['', ...walkTokens].forEach((t, i) => { + const th = document.createElement('th'); + th.textContent = i === 0 ? 'Assignment' : `${i - 1}: ${t}`; + head.append(th); + }); + const body = table.createTBody(); + walkRows.forEach((row, ri) => { + const tr = body.insertRow(); + if (ri === step.reveal) tr.classList.add('reveal'); + const th = document.createElement('th'); + th.textContent = row.name; + tr.append(th); + row.values.forEach(v => { + const td = tr.insertCell(); + if (ri > step.reveal) { + td.textContent = '·'; + td.classList.add('dim'); + } else { + td.textContent = fmt(v); + } + }); + }); + $('walk-caption').textContent = step.caption; + $('walk-step-label').textContent = `Step ${walkIndex + 1} / ${walkSteps.length}`; + $('walk-prev').disabled = walkIndex === 0; + $('walk-next').disabled = walkIndex === walkSteps.length - 1; + const verdict = $('walk-verdict'); + verdict.textContent = step.verdict; + verdict.className = step.verdict ? 'result accepted' : 'result'; +} + +$('walk-prev').onclick = () => { walkIndex = Math.max(0, walkIndex - 1); renderWalk(); }; +$('walk-next').onclick = () => { walkIndex = Math.min(walkSteps.length - 1, walkIndex + 1); renderWalk(); }; +$('walk-reset').onclick = () => { walkIndex = 0; renderWalk(); }; +renderWalk(); + +/* ---- Interactive interpreter ---- */ +let trace, selected = 0, worker, timer, running = false, lastRequest; +let playIndex = -1; +let playTimer = null; +const PLAY_MS = 650; + +function status(message, error = false) { + $('engine-status').textContent = message; + $('engine-status').classList.toggle('error', error); +} +function setRunMode(mode) { + const btn = $('run'); + const pausing = mode === 'stop'; + btn.dataset.mode = pausing ? 'stop' : 'play'; + const label = pausing ? 'Pause' : (canResumePlayback() ? 'Resume' : 'Run program'); + btn.title = label; + btn.setAttribute('aria-label', label); +} +function isBusy() { + return running || playTimer !== null; +} +function inputUnchanged() { + if (!lastRequest) return false; + const tokens = (() => { + try { return tokenize($('tokens').value); } catch { return null; } + })(); + if (!tokens) return false; + return lastRequest.source === currentProgramSource() + && tokens.length === lastRequest.tokens.length + && tokens.every((t, i) => t === lastRequest.tokens[i]); +} +function canResumePlayback() { + return Boolean(trace && !running && playTimer === null + && playIndex >= 0 && playIndex < trace.rows.length - 1 + && inputUnchanged()); +} +function syncRunButton() { + setRunMode(isBusy() ? 'stop' : 'play'); +} +function stop() { + worker?.terminate(); + worker = undefined; + clearTimeout(timer); + running = false; + syncRunButton(); +} +function stopPlayback() { + clearInterval(playTimer); + playTimer = null; + syncRunButton(); +} +function advancePlaybackTick() { + if (!trace) return; + if (playIndex >= trace.rows.length - 1) { + stopPlayback(); + status('Evaluated by the pinned Python interpreter.'); + return; + } + playIndex += 1; + paintTrace(); + if (playIndex >= trace.rows.length - 1) { + stopPlayback(); + status('Evaluated by the pinned Python interpreter.'); + } +} +function resumePlayback() { + if (!trace || playIndex >= trace.rows.length - 1) return; + status('Resumed…'); + playTimer = setInterval(advancePlaybackTick, PLAY_MS); + syncRunButton(); +} +function markStale() { + stopPlayback(); + $('download-trace').disabled = true; + $('result').textContent = 'Edits not evaluated'; + $('result').className = 'result'; + $('acceptance-note').textContent = 'Edits not evaluated yet — choose Run program.'; + status('Program or input changed. Run to update the trace.'); + renderExecProgram(null); + showTraceUI(false); + trace = null; + playIndex = -1; + $('row-label').textContent = '—'; + $('result').textContent = ''; + $('result').className = 'result'; + $('acceptance-note').textContent = ''; + $('command-link').textContent = ''; +} + +function lineIndexForAssignment(source, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`^\\s*${escaped}\\s*=`); + return source.split('\n').findIndex(line => re.test(line)); +} + +function currentProgramSource() { + return $('program').value; +} + +function renderExecProgram(activeName) { + const pre = $('exec-program'); + const source = (playIndex >= 0 && lastRequest?.source) ? lastRequest.source : currentProgramSource(); + pre.replaceChildren(); + const active = activeName ? lineIndexForAssignment(source, activeName) : -1; + source.split('\n').forEach((text, i) => { + const line = document.createElement('div'); + line.className = 'program-line' + (i === active ? ' active' : ''); + line.textContent = text || ' '; + pre.append(line); + }); +} + +/* ---- Block builder (pencil → drag & drop, not free typing) ---- */ +let builderLines = []; +let builderDragType = null; +let builderReorderFrom = null; +let builderCommitted = false; +let builderBaseline = ''; // program text when builder was opened / last restored +let builderHistory = []; +let builderHistoryIndex = -1; +let builderHistoryLock = false; +let builderHistoryTimer = 0; +let builderSelectedIndex = -1; +let checkWorker; + +function freshName(prefix = 'P') { + const used = new Set(builderLines.map(l => l.name)); + let i = 1; + while (used.has(`${prefix}${i}`)) i += 1; + return `${prefix}${i}`; +} + +function defaultBlock(type) { + switch (type) { + case 'count_token': + return {type, name: freshName('C'), token: '('}; + case 'count_pred': + return {type, name: freshName('C'), pred: 'true', negate: false}; + case 'marker': + return {type, name: freshName('T'), token: 'a'}; + case 'compare': + return {type, name: freshName('P'), left: 'C1', op: '<', right: 'C2'}; + case 'and': + return {type, name: freshName('P'), left: 'P1', right: 'P2'}; + case 'or': + return {type, name: freshName('P'), left: 'P1', right: 'P2'}; + case 'not': + return {type, name: freshName('P'), arg: 'P1'}; + default: + return {type: 'raw', name: freshName('X'), text: 'true'}; + } +} + +function serializeBlock(b) { + const n = (b.name || 'P').trim() || 'P'; + switch (b.type) { + case 'count_token': + return `${n} = # "${(b.token || '').replace(/"/g, '')}"`; + case 'count_pred': { + const pred = (b.pred || 'true').trim() || 'true'; + return `${n} = # ${b.negate ? `!${pred}` : pred}`; + } + case 'marker': + return `${n} = "${(b.token || '').replace(/"/g, '')}"`; + case 'compare': { + const left = (b.left || '0').trim(); + const right = (b.right || '0').trim(); + const op = b.op || '=='; + return `${n} = (${left} ${op} ${right})`; + } + case 'and': + return `${n} = ${(b.left || 'P').trim()} && ${(b.right || 'Q').trim()}`; + case 'or': + return `${n} = ${(b.left || 'P').trim()} || ${(b.right || 'Q').trim()}`; + case 'not': + return `${n} = !${(b.arg || 'P').trim()}`; + case 'raw': + return `${n} = ${(b.text || 'true').trim()}`; + default: + return `${n} = true`; + } +} + +function serializeBuilder() { + return builderLines.map(serializeBlock).join('\n'); +} + +function setCommitStatus(text, kind = '') { + const el = $('builder-commit-status'); + if (!el) return; + el.textContent = text || ''; + el.className = 'builder-commit-status' + (kind ? ` is-${kind}` : ''); +} + +function updateBuilderSource() { + const src = serializeBuilder(); + const pre = $('builder-source'); + if (pre) { + pre.replaceChildren(); + if (!builderLines.length) { + // empty ::before handles placeholder + } else { + builderLines.forEach((block, i) => { + const line = document.createElement('div'); + line.className = 'builder-source-line' + (i === builderSelectedIndex ? ' is-active' : ''); + line.dataset.index = String(i); + line.textContent = serializeBlock(block); + line.addEventListener('click', () => selectBuilderLine(i)); + pre.append(line); + }); + } + } + if ($('program')) $('program').value = src; + highlightBuilderLinks(); +} + +function selectBuilderLine(index) { + if (index < 0 || index >= builderLines.length) { + builderSelectedIndex = -1; + } else { + builderSelectedIndex = index; + } + document.querySelectorAll('.builder-card').forEach((card, i) => { + card.classList.toggle('is-selected', i === builderSelectedIndex); + }); + document.querySelectorAll('.builder-source-line').forEach(line => { + line.classList.toggle('is-active', Number(line.dataset.index) === builderSelectedIndex); + }); + highlightBuilderLinks(); + const activeLine = document.querySelector('.builder-source-line.is-active'); + activeLine?.scrollIntoView({block: 'nearest', behavior: 'smooth'}); + const activeCard = document.querySelector('.builder-card.is-selected'); + activeCard?.scrollIntoView({block: 'nearest', behavior: 'smooth'}); +} + +function highlightBuilderLinks() { + const type = builderSelectedIndex >= 0 ? builderLines[builderSelectedIndex]?.type : null; + document.querySelectorAll('.palette-block').forEach(btn => { + const match = type && btn.dataset.block === type; + btn.classList.toggle('is-linked', Boolean(match)); + }); +} + +function snapshotBuilder() { + return JSON.parse(JSON.stringify(builderLines)); +} + +function pushBuilderHistory({immediate = false} = {}) { + if (builderHistoryLock) return; + const doPush = () => { + const snap = snapshotBuilder(); + const serialized = JSON.stringify(snap); + if (builderHistoryIndex >= 0 && JSON.stringify(builderHistory[builderHistoryIndex]) === serialized) { + syncBuilderNavButtons(); + return; + } + builderHistory = builderHistory.slice(0, builderHistoryIndex + 1); + builderHistory.push(snap); + if (builderHistory.length > 80) { + builderHistory.shift(); + builderHistoryIndex = builderHistory.length - 1; + } else { + builderHistoryIndex = builderHistory.length - 1; + } + syncBuilderNavButtons(); + }; + if (immediate) { + clearTimeout(builderHistoryTimer); + doPush(); + return; + } + clearTimeout(builderHistoryTimer); + builderHistoryTimer = setTimeout(doPush, 280); +} + +function applyBuilderSnapshot(snap) { + builderHistoryLock = true; + builderLines = JSON.parse(JSON.stringify(snap)); + renderBuilder(); + builderCommitted = false; + setCommitStatus('Uncommitted changes', 'pending'); + updateBuilderSource(); + markStale(); + document.querySelector('.program-panel')?.classList.add('editing'); + $('program-builder').hidden = false; + builderHistoryLock = false; + syncBuilderNavButtons(); +} + +function syncBuilderNavButtons() { + const back = $('builder-back'); + const forth = $('builder-forth'); + if (back) back.disabled = builderHistoryIndex <= 0; + if (forth) forth.disabled = builderHistoryIndex < 0 || builderHistoryIndex >= builderHistory.length - 1; +} + +function builderUndo() { + if (builderHistoryIndex <= 0) return; + builderHistoryIndex -= 1; + applyBuilderSnapshot(builderHistory[builderHistoryIndex]); +} + +function builderRedo() { + if (builderHistoryIndex >= builderHistory.length - 1) return; + builderHistoryIndex += 1; + applyBuilderSnapshot(builderHistory[builderHistoryIndex]); +} + +function builderRestoreInitial() { + builderLines = parseProgramToBlocks(builderBaseline); + renderBuilder(); + pushBuilderHistory({immediate: true}); + builderCommitted = false; + setCommitStatus('Restored initial program', 'pending'); + updateBuilderSource(); + markStale(); + document.querySelector('.program-panel')?.classList.add('editing'); + $('program-builder').hidden = false; + status('Restored the program from when you opened the builder.'); +} + +function syncBuilderToProgram({immediateHistory = false} = {}) { + builderCommitted = false; + setCommitStatus('Uncommitted changes', 'pending'); + updateBuilderSource(); + pushBuilderHistory({immediate: immediateHistory}); + markStale(); + // Keep builder visible after markStale clears trace + document.querySelector('.program-panel')?.classList.add('editing'); + $('program-builder').hidden = false; +} + +function localWellFormedCheck(source) { + const issues = []; + const lines = source.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('//')); + if (!lines.length) issues.push('Add at least one assignment.'); + const seen = new Set(); + const defined = new Set(); + lines.forEach((line, i) => { + if (line.startsWith('#import')) { + const imp = line.match(/^#import\s+([A-Za-z_][A-Za-z0-9_]*)/); + if (imp) defined.add(imp[1]); + return; + } + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/); + if (!m) { + issues.push(`Line ${i + 1}: expected Name = expression.`); + return; + } + const [, name, rhs] = m; + if (seen.has(name)) issues.push(`Duplicate name “${name}”.`); + seen.add(name); + const stripped = rhs.replace(/"(?:\\.|[^"\\])*"/g, '""'); + for (const ref of stripped.match(/[A-Za-z_][A-Za-z0-9_]*/g) || []) { + if (['true', 'false', 'min', 'max', 'if', 'else'].includes(ref)) continue; + if (!defined.has(ref)) { + issues.push(`“${ref}” is used before it is defined (or is unknown).`); + } + } + defined.add(name); + }); + return [...new Set(issues)]; +} + +function parseProgramToBlocks(source) { + const lines = []; + for (const raw of source.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('//') || line.startsWith('#import')) continue; + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (!m) continue; + const name = m[1]; + let rhs = m[2].trim(); + // strip outer parens once + if (rhs.startsWith('(') && rhs.endsWith(')')) { + const inner = rhs.slice(1, -1).trim(); + if (!inner.includes('(')) rhs = inner; + } + let block = null; + let mm; + if ((mm = rhs.match(/^#\s*"([^"]*)"\s*$/))) { + block = {type: 'count_token', name, token: mm[1]}; + } else if ((mm = rhs.match(/^#\s*!?\s*([A-Za-z_][A-Za-z0-9_]*|true|false)\s*$/))) { + block = {type: 'count_pred', name, pred: mm[1], negate: /^#\s*!/.test(rhs)}; + } else if ((mm = rhs.match(/^"([^"]*)"\s*$/))) { + block = {type: 'marker', name, token: mm[1]}; + } else if ((mm = rhs.match(/^(.+?)\s*(<=|>=|==|!=|<|>)\s*(.+)$/))) { + block = {type: 'compare', name, left: mm[1].replace(/^\(|\)$/g, '').trim(), op: mm[2], right: mm[3].replace(/^\(|\)$/g, '').trim()}; + } else if ((mm = rhs.match(/^(.+?)\s*&&\s*(.+)$/))) { + block = {type: 'and', name, left: mm[1].trim(), right: mm[2].trim()}; + } else if ((mm = rhs.match(/^(.+?)\s*\|\|\s*(.+)$/))) { + block = {type: 'or', name, left: mm[1].trim(), right: mm[2].trim()}; + } else if ((mm = rhs.match(/^!\s*(.+)$/))) { + block = {type: 'not', name, arg: mm[1].trim()}; + } else { + block = {type: 'raw', name, text: rhs}; + } + lines.push(block); + } + return lines; +} + +function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'className') node.className = v; + else if (k === 'text') node.textContent = v; + else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v); + else if (v !== false && v != null) node.setAttribute(k, v === true ? '' : v); + } + for (const c of [].concat(children)) { + if (c == null) continue; + node.append(typeof c === 'string' ? document.createTextNode(c) : c); + } + return node; +} + +function field(label, control) { + return el('label', {}, [label, control]); +} + +function renderBuilderCard(block, index) { + const card = el('div', { + className: 'builder-card' + (index === builderSelectedIndex ? ' is-selected' : ''), + draggable: 'true', + 'data-index': String(index), + }); + card.addEventListener('click', e => { + if (e.target.closest('input, select, button, textarea')) return; + selectBuilderLine(index); + }); + card.addEventListener('dragstart', e => { + builderReorderFrom = index; + card.classList.add('dragging'); + e.dataTransfer.setData('text/plain', `reorder:${index}`); + e.dataTransfer.effectAllowed = 'move'; + }); + card.addEventListener('dragend', () => { + card.classList.remove('dragging'); + builderReorderFrom = null; + }); + + const handle = el('div', {className: 'builder-card-handle', title: 'Drag to reorder', text: '⋮⋮'}); + const body = el('div', {className: 'builder-card-body'}); + + const nameInput = el('input', {value: block.name, 'aria-label': 'Variable name'}); + nameInput.addEventListener('input', () => { + block.name = nameInput.value.trim() || block.name; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(field('name', nameInput)); + + if (block.type === 'count_token' || block.type === 'marker') { + const tok = el('input', {value: block.token ?? '', 'aria-label': 'Token'}); + tok.addEventListener('input', () => { + block.token = tok.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(field('token', tok)); + } else if (block.type === 'count_pred') { + const pred = el('input', {value: block.pred ?? 'true', list: 'builder-name-list', 'aria-label': 'Predicate'}); + const neg = el('input', {type: 'checkbox', 'aria-label': 'Negate'}); + neg.checked = Boolean(block.negate); + pred.addEventListener('input', () => { + block.pred = pred.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + neg.addEventListener('change', () => { + block.negate = neg.checked; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(field('predicate', pred), field('not', neg)); + } else if (block.type === 'compare') { + const left = el('input', {value: block.left ?? '', list: 'builder-name-list'}); + const op = el('select', {}, ['<', '<=', '>', '>=', '==', '!='].map(o => { + const opt = el('option', {value: o, text: o}); + if (o === block.op) opt.selected = true; + return opt; + })); + const right = el('input', {value: block.right ?? '', list: 'builder-name-list'}); + const bump = () => { + block.left = left.value; + block.op = op.value; + block.right = right.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }; + left.addEventListener('input', bump); + op.addEventListener('change', bump); + right.addEventListener('input', bump); + body.append(field('left', left), field('op', op), field('right', right)); + } else if (block.type === 'and' || block.type === 'or') { + const left = el('input', {value: block.left ?? '', list: 'builder-name-list'}); + const right = el('input', {value: block.right ?? '', list: 'builder-name-list'}); + const bump = () => { + block.left = left.value; + block.right = right.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }; + left.addEventListener('input', bump); + right.addEventListener('input', bump); + body.append(field('left', left), el('span', {text: block.type === 'and' ? '&&' : '||'}), field('right', right)); + } else if (block.type === 'not') { + const arg = el('input', {value: block.arg ?? '', list: 'builder-name-list'}); + arg.addEventListener('input', () => { + block.arg = arg.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(el('span', {text: '!'}), field('predicate', arg)); + } else { + const text = el('input', {value: block.text ?? '', style: 'max-width:220px'}); + text.addEventListener('input', () => { + block.text = text.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(field('expression', text)); + } + + const preview = el('code', {className: 'builder-preview', text: serializeBlock(block)}); + body.append(preview); + + const remove = el('button', { + type: 'button', + className: 'builder-card-remove', + title: 'Remove line', + text: '×', + onclick: () => { + builderLines.splice(index, 1); + renderBuilder(); + syncBuilderToProgram({immediateHistory: true}); + }, + }); + + card.append(handle, body, remove); + return card; +} + +function updateBuilderPreview(card, block) { + const preview = card.querySelector('.builder-preview'); + if (preview) preview.textContent = serializeBlock(block); +} + +function renderBuilder() { + const host = $('builder-lines'); + const empty = $('builder-empty'); + host.replaceChildren(); + // datalist of names for inputs + let list = document.getElementById('builder-name-list'); + if (!list) { + list = el('datalist', {id: 'builder-name-list'}); + document.body.append(list); + } + list.replaceChildren(...builderLines.map(l => el('option', {value: l.name}))); + + empty.hidden = builderLines.length > 0; + if (builderSelectedIndex >= builderLines.length) builderSelectedIndex = builderLines.length ? builderLines.length - 1 : -1; + builderLines.forEach((block, i) => host.append(renderBuilderCard(block, i))); + updateBuilderSource(); +} + +function addBuilderBlock(type, atIndex = builderLines.length) { + const block = defaultBlock(type); + // Prefer sensible names from earlier lines when possible + if (type === 'compare' && builderLines.length >= 2) { + block.left = builderLines[builderLines.length - 2].name; + block.right = builderLines[builderLines.length - 1].name; + } + if ((type === 'and' || type === 'or') && builderLines.length >= 2) { + block.left = builderLines[builderLines.length - 2].name; + block.right = builderLines[builderLines.length - 1].name; + } + if (type === 'count_pred' && builderLines.length) { + block.pred = builderLines[builderLines.length - 1].name; + } + if (type === 'not' && builderLines.length) { + block.arg = builderLines[builderLines.length - 1].name; + } + builderLines.splice(atIndex, 0, block); + builderSelectedIndex = atIndex; + renderBuilder(); + syncBuilderToProgram({immediateHistory: true}); +} + +function setupBuilderDnD() { + document.querySelectorAll('.palette-block').forEach(btn => { + btn.addEventListener('dragstart', e => { + builderDragType = btn.dataset.block; + e.dataTransfer.setData('text/plain', `palette:${btn.dataset.block}`); + e.dataTransfer.effectAllowed = 'copy'; + }); + btn.addEventListener('dragend', () => { builderDragType = null; }); + btn.addEventListener('click', () => addBuilderBlock(btn.dataset.block)); + }); + + const canvas = $('builder-canvas'); + canvas.addEventListener('dragover', e => { + e.preventDefault(); + canvas.classList.add('drag-over'); + e.dataTransfer.dropEffect = builderDragType ? 'copy' : 'move'; + }); + canvas.addEventListener('dragleave', () => canvas.classList.remove('drag-over')); + canvas.addEventListener('drop', e => { + e.preventDefault(); + canvas.classList.remove('drag-over'); + const data = e.dataTransfer.getData('text/plain') || ''; + if (data.startsWith('palette:')) { + addBuilderBlock(data.slice(8)); + return; + } + if (data.startsWith('reorder:')) { + const from = Number(data.slice(8)); + if (Number.isNaN(from)) return; + // drop at end for simplicity when dropping on canvas background + const [item] = builderLines.splice(from, 1); + builderLines.push(item); + renderBuilder(); + syncBuilderToProgram({immediateHistory: true}); + } + }); + + $('builder-lines').addEventListener('dragover', e => { + const card = e.target.closest('.builder-card'); + if (!card) return; + e.preventDefault(); + }); + $('builder-lines').addEventListener('drop', e => { + const card = e.target.closest('.builder-card'); + if (!card) return; + e.preventDefault(); + e.stopPropagation(); + const to = Number(card.dataset.index); + const data = e.dataTransfer.getData('text/plain') || ''; + if (data.startsWith('palette:')) { + addBuilderBlock(data.slice(8), to); + return; + } + if (data.startsWith('reorder:')) { + const from = Number(data.slice(8)); + if (Number.isNaN(from) || from === to) return; + const [item] = builderLines.splice(from, 1); + builderLines.splice(to, 0, item); + renderBuilder(); + syncBuilderToProgram({immediateHistory: true}); + } + }); +} + +function setProgramEditing(on) { + const panel = document.querySelector('.program-panel'); + const wasEditing = panel.classList.contains('editing'); + panel.classList.toggle('editing', on); + $('program-builder').hidden = !on; + $('edit-program').hidden = on; + $('edit-program').setAttribute('aria-pressed', String(on)); + const builderActions = document.querySelector('.program-actions-builder'); + const runActions = document.querySelector('.program-actions-run'); + if (builderActions) builderActions.hidden = !on; + if (runActions) runActions.hidden = on; + if (on) { + stopPlayback(); + builderCommitted = false; + builderBaseline = currentProgramSource(); + builderLines = parseProgramToBlocks(builderBaseline); + builderHistory = [snapshotBuilder()]; + builderHistoryIndex = 0; + builderSelectedIndex = builderLines.length ? 0 : -1; + renderBuilder(); + syncBuilderNavButtons(); + setCommitStatus(builderLines.length ? 'Edit, then Commit to check' : 'Drag commands into Your program', 'pending'); + } else { + if (wasEditing && builderLines.length) { + $('program').value = serializeBuilder(); + } + renderExecProgram(playIndex >= 0 && trace ? trace.rows[playIndex]?.name : null); + } + if (!trace) updateTraceEmptyHint(); +} + +function stopCheckWorker() { + if (!checkWorker) return; + checkWorker.terminate(); + checkWorker = undefined; +} + +async function commitProgram() { + updateBuilderSource(); + const source = serializeBuilder().trim(); + $('program').value = source; + const localIssues = localWellFormedCheck(source); + if (localIssues.length) { + builderCommitted = false; + setCommitStatus(`Not well-formed — ${localIssues[0]}`, 'bad'); + status(localIssues.join(' '), true); + return; + } + let tokens; + try { + tokens = tokenize($('tokens').value); + } catch (e) { + builderCommitted = false; + setCommitStatus(`Not well-formed — ${e.message}`, 'bad'); + status(e.message, true); + return; + } + setCommitStatus('Checking with interpreter…', 'pending'); + status('Checking whether the program is well-formed…'); + stopCheckWorker(); + checkWorker = new Worker('runtime/python-worker.js'); + const timer = setTimeout(() => { + stopCheckWorker(); + builderCommitted = false; + setCommitStatus('Check timed out', 'bad'); + status('Well-formedness check timed out. Try again.', true); + }, 120000); + checkWorker.onmessage = ({data}) => { + if (data.status) status(data.status); + if (data.error) { + clearTimeout(timer); + stopCheckWorker(); + builderCommitted = false; + const msg = String(data.error).replace(/^Error:\s*/i, ''); + setCommitStatus(`Not well-formed — ${msg}`, 'bad'); + status(msg, true); + return; + } + if (data.check) { + clearTimeout(timer); + stopCheckWorker(); + builderCommitted = true; + const acceptNote = data.check.accepted === true ? ' · accepts this input' + : data.check.accepted === false ? ' · rejects this input' : ''; + setCommitStatus(`Well-formed ✓ (${data.check.statements} lines)${acceptNote}`, 'ok'); + updateBuilderSource(); + status('Program is well-formed. Leaving the builder — Run to step through the trace.'); + setProgramEditing(false); + markStale(); + } + }; + checkWorker.onerror = e => { + clearTimeout(timer); + stopCheckWorker(); + builderCommitted = false; + setCommitStatus('Check failed to start', 'bad'); + status(`Could not start checker: ${e.message}`, true); + }; + checkWorker.postMessage({mode: 'check', source, tokens}); +} + +$('edit-program').onclick = () => setProgramEditing(true); +$('builder-back').onclick = () => builderUndo(); +$('builder-forth').onclick = () => builderRedo(); +$('builder-restore').onclick = () => builderRestoreInitial(); +$('commit-program').onclick = () => { commitProgram(); }; +setupBuilderDnD(); + +// If user edits advanced text, re-parse into cards +$('program').addEventListener('change', () => { + if (document.querySelector('.program-panel')?.classList.contains('editing')) { + builderCommitted = false; + builderLines = parseProgramToBlocks(currentProgramSource()); + renderBuilder(); + pushBuilderHistory(); + setCommitStatus('Uncommitted text edits', 'pending'); + } +}); + +function setTokensEditing(on) { + const input = $('tokens'); + input.readOnly = !on; + $('edit-tokens').hidden = on; + $('edit-tokens').setAttribute('aria-pressed', String(on)); + $('done-edit-tokens').hidden = !on; + if (on) { + input.focus(); + input.select(); + } else { + input.blur(); + } +} +$('edit-tokens').onclick = () => setTokensEditing(true); +$('done-edit-tokens').onclick = () => { + markStale(); + setTokensEditing(false); +}; +$('tokens').addEventListener('keydown', e => { + if (e.key === 'Enter' && !$('tokens').readOnly) { + e.preventDefault(); + markStale(); + setTokensEditing(false); + } +}); +$('copy-program').onclick = async () => { + try { + await navigator.clipboard.writeText(currentProgramSource()); + status('Program copied to clipboard.'); + } catch { + status('Could not copy program.', true); + } +}; + +function focusAssignment(ri) { + if (!trace || ri < 0 || ri >= trace.rows.length) return; + stopPlayback(); + playIndex = ri; + selected = ri; + paintTrace(); + const active = $('exec-program')?.querySelector('.program-line.active'); + active?.scrollIntoView({block: 'nearest', behavior: 'smooth'}); +} + +function updateTraceEmptyHint() { + const empty = $('trace-empty'); + if (!empty) return; + const editing = document.querySelector('.program-panel')?.classList.contains('editing'); + if (editing) { + empty.dataset.mode = 'builder'; + empty.innerHTML = 'Finish editing in the builder first. Press Commit to check that the program is well-formed — then you can run it and fill this area with the execution trace.'; + } else { + empty.dataset.mode = 'idle'; + empty.textContent = 'Nothing has run yet. Press play to fill this area with the execution trace.'; + } +} + +function showTraceUI(hasTrace) { + $('trace-empty').hidden = hasTrace; + $('trace-scroll').hidden = !hasTrace; + $('command-link').hidden = !hasTrace; + if (!hasTrace) updateTraceEmptyHint(); +} + +function paintTrace() { + if (!trace) return; + showTraceUI(true); + const reveal = playIndex; + const source = lastRequest?.source || currentProgramSource(); + const table = $('trace'); + table.replaceChildren(); + const caption = table.createCaption(); + caption.className = 'small'; + caption.textContent = 'Click an assignment to highlight its program line. Values by token position (zero-based).'; + const head = table.createTHead().insertRow(); + ['Assignment', ...trace.tokens.map((t, i) => `${i}: ${t}`)].forEach(t => { + const th = document.createElement('th'); + th.textContent = t; + head.append(th); + }); + const body = table.createTBody(); + const done = reveal >= trace.rows.length - 1; + trace.rows.forEach((row, ri) => { + const tr = body.insertRow(); + tr.dataset.row = ri; + if (ri === reveal) tr.classList.add('reveal', 'selected'); + const th = document.createElement('th'); + th.className = 'assign-cell'; + const lineText = assignmentLine(source, row.name); + if (ri <= reveal) { + const b = document.createElement('button'); + b.className = 'assign-btn'; + b.type = 'button'; + b.textContent = lineText; + b.title = 'Show this line in the program'; + b.onclick = () => focusAssignment(ri); + th.append(b); + } else { + th.textContent = lineText; + th.classList.add('dim'); + } + tr.append(th); + row.values.forEach(value => { + const td = tr.insertCell(); + if (ri > reveal) { + td.textContent = '·'; + td.classList.add('dim'); + } else { + td.textContent = typeof value === 'boolean' ? (value ? '✓' : '·') : value; + td.onclick = () => focusAssignment(ri); + } + }); + }); + + selected = Math.max(0, reveal); + const row = trace.rows[selected]; + $('row-label').textContent = reveal < 0 + ? 'Waiting…' + : `${selected + 1} / ${trace.rows.length}`; + renderExecProgram(reveal >= 0 ? row.name : null); + updateCommandLink(reveal >= 0 ? row.name : null); + + $('prev-row').disabled = reveal <= 0; + $('next-row').disabled = reveal >= trace.rows.length - 1; + $('replay-trace').disabled = !trace; + $('skip-trace').disabled = done; + + if (done) { + $('result').textContent = trace.accepted ? 'Accepted' : 'Rejected'; + $('result').className = `result ${trace.accepted ? 'accepted' : 'rejected'}`; + $('download-trace').disabled = false; + $('acceptance-note').textContent = `Accept/reject is the last cell of the final Boolean row (${trace.rows.at(-1).name}).`; + } else { + $('result').textContent = reveal < 0 ? '' : `Executing ${row.name}…`; + $('result').className = 'result'; + $('download-trace').disabled = true; + $('acceptance-note').textContent = ''; + } +} + +function startPlayback(data, {animate = true, fromIndex = 0} = {}) { + stopPlayback(); + trace = data; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (!animate || reduceMotion || !data.rows.length) { + playIndex = Math.max(0, data.rows.length - 1); + paintTrace(); + syncRunButton(); + return; + } + playIndex = Math.max(0, Math.min(fromIndex, data.rows.length - 1)); + paintTrace(); + status(fromIndex > 0 ? 'Resumed…' : 'Playing execution…'); + if (playIndex >= data.rows.length - 1) { + status('Evaluated by the pinned Python interpreter.'); + syncRunButton(); + return; + } + playTimer = setInterval(advancePlaybackTick, PLAY_MS); + syncRunButton(); +} + +function renderTrace(data, opts) { + startPlayback(data, opts); +} + +function selectRow(i) { + if (!trace) return; + stopPlayback(); + playIndex = Math.max(0, Math.min(i, trace.rows.length - 1)); + selected = playIndex; + paintTrace(); +} + +function assignmentLine(source, name) { + const i = lineIndexForAssignment(source, name); + if (i < 0) return name; + return source.split('\n')[i].trim() || name; +} + +function assignmentRhs(source, name) { + const line = assignmentLine(source, name); + const eq = line.indexOf('='); + return eq < 0 ? line : line.slice(eq + 1).trim(); +} + +/** Map a program line to the C-RASP command kinds from §02. */ +function describeCommands(name) { + const source = lastRequest?.source || currentProgramSource(); + const rhs = assignmentRhs(source, name); + if (!rhs) { + return `${name}: could not find this assignment in the program text.`; + } + const parts = []; + if (/#/.test(rhs)) parts.push('prefix count (#)'); + if (/"[^"]*"/.test(rhs)) parts.push('input marker'); + if (/\bif\b/.test(rhs)) parts.push('conditional'); + if (/\bmin\s*\(/.test(rhs)) parts.push('min'); + if (/\bmax\s*\(/.test(rhs)) parts.push('max'); + if (/(<=|>=|==|!=|<|>)/.test(rhs)) parts.push('count comparison'); + if (/&&/.test(rhs)) parts.push('conjunction (and)'); + if (/\|\|/.test(rhs)) parts.push('disjunction (or)'); + if (/!(?!=)/.test(rhs)) parts.push('negation'); + if (/\+/.test(rhs)) parts.push('addition'); + if (/(?=!])-(?!>)/.test(rhs)) parts.push('subtraction'); + if (/^\d+$/.test(rhs) || /^(true|false)$/i.test(rhs)) parts.push('constant'); + const uniq = [...new Set(parts)]; + if (!uniq.length) { + return `${name} = ${rhs} — combines earlier vectors with the allowed Boolean / count operations.`; + } + return `${name} uses: ${uniq.join(' · ')} — same command kinds as in “What is C-RASP.”`; +} + +function updateCommandLink(name) { + const el = $('command-link'); + if (!name) { + el.textContent = 'After Run, the active line is highlighted above and this note names the C-RASP command behind it.'; + return; + } + el.textContent = describeCommands(name); +} + +$('prev-row').onclick = () => selectRow(playIndex - 1); +$('next-row').onclick = () => { + stopPlayback(); + selectRow(playIndex + 1); +}; +$('replay-trace').onclick = () => { + if (!trace) return; + startPlayback(trace, {animate: true}); +}; +$('skip-trace').onclick = () => { + if (!trace) return; + stopPlayback(); + playIndex = trace.rows.length - 1; + paintTrace(); + status('Evaluated by the pinned Python interpreter.'); +}; +$('run').onclick = () => { + // Pause animation or cancel an in-flight interpreter run + if (playTimer) { + stopPlayback(); + status('Paused — press play to resume from this step.'); + return; + } + if (running) { + stop(); + status('Interpreter run cancelled.'); + return; + } + // Resume paused playback without re-running the interpreter + if (canResumePlayback()) { + resumePlayback(); + return; + } + // Replay finished trace from the start (same program/input) + if (trace && inputUnchanged() && playIndex >= trace.rows.length - 1) { + startPlayback(trace, {animate: true, fromIndex: 0}); + return; + } + + let request; + try { + setProgramEditing(false); + setTokensEditing(false); + request = {source: currentProgramSource(), 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; + syncRunButton(); + $('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; + if (data.error) { + syncRunButton(); + status(data.error, true); + worker.terminate(); + worker = undefined; + } else { + lastRequest = request; + lastRequest.tokens = request.tokens; + renderTrace(data.result); + 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); +}; +syncRunButton(); +$('program').oninput = markStale; +$('tokens').oninput = markStale; +function reset() { + stop(); + const e = examples[$('example').value]; + $('tokens').value = e.tokens; + $('program').value = e.source; + builderLines = []; + setProgramEditing(false); + setTokensEditing(false); + markStale(); + $('command-link').textContent = 'Example restored. Run to step through each line and see which C-RASP commands it uses.'; +} +$('example').onchange = reset; +$('reset').onclick = reset; +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 = () => { + if (!trace || !lastRequest) return; + download('crasp-trace.json', { + ...trace, + program: lastRequest.source, + interpreter_commit: '1e821d7279015ce0e77830faa7932575447c5164', + }); +}; + +$('download-trace').disabled = true; +$('prev-row').disabled = true; +$('next-row').disabled = true; +$('replay-trace').disabled = true; +$('skip-trace').disabled = true; +$('row-label').textContent = '—'; +status(''); +$('command-link').textContent = ''; +$('acceptance-note').textContent = ''; +$('result').textContent = ''; +$('result').className = 'result'; +showTraceUI(false); +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; + setProgramEditing(false); + markStale(); + openSection('playground'); + } catch (e) { + status(`Could not load shared program: ${e.message}`, true); + } +} else { + // Load tokens + program from the preselected example + const e = examples[$('example').value] || examples.dyck; + $('tokens').value = e.tokens; + $('program').value = e.source; + setTokensEditing(false); + setProgramEditing(false); + renderExecProgram(null); +} + diff --git a/site/version2/assets/lib.js b/site/version2/assets/lib.js new file mode 100644 index 0000000..c341ad6 --- /dev/null +++ b/site/version2/assets/lib.js @@ -0,0 +1,32 @@ +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/site/version2/assets/style.css b/site/version2/assets/style.css new file mode 100644 index 0000000..1ef6e6f --- /dev/null +++ b/site/version2/assets/style.css @@ -0,0 +1,79 @@ +: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:.55rem .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:.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:.08em;font-size:.8rem;font-weight:750}header nav{display:flex;gap:24px;font-size:.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}.hero-solo{display:block;max-width:min(1280px,100%);width:100%;padding:96px 35px 72px;box-sizing:border-box;margin-left:auto;margin-right:auto} +.hero-solo h1,.hero-solo .dek,.hero-solo .meta{max-width:none;width:100%} +.hero-solo .dek{font-size:1.28rem;line-height:1.65}.program-block{margin:0 0 20px;padding:10px 0;background:var(--pale);border:1px solid var(--line);font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:auto}.program-line{padding:2px 18px;white-space:pre}.program-line.active{background:#d9e8df;box-shadow:inset 3px 0 0 var(--teal);font-weight:600} +.exec-program{margin:0;border:0;background:transparent;flex:1;min-width:0}.tokens-field{flex:1;min-width:180px;display:flex;flex-direction:column;gap:5px}.tokens-field-row{display:flex;align-items:center;gap:0;border:1px solid var(--line);border-radius:4px;background:var(--pale);padding:0 4px 0 0;overflow:hidden}.tokens-field-row input[type=text]{flex:1;border:0!important;background:transparent!important;padding:10px 12px;min-width:0;border-radius:0;color:var(--ink);width:auto}.tokens-field-row input[readonly]{cursor:default}.tokens-field-row input:focus,.tokens-field-row input:focus-visible{outline:none!important;box-shadow:none}.tokens-field-row:focus-within{outline:3px solid var(--accent);outline-offset:1px} +button.inline-edit{border:0;background:transparent;color:var(--muted);padding:6px 8px;margin:0;border-radius:3px;display:inline-flex;align-items:center;justify-content:center;line-height:1;cursor:pointer;flex-shrink:0;box-shadow:none}button.inline-edit:hover{background:transparent;color:var(--ink)}button.inline-edit:focus-visible{outline:2px solid var(--accent);outline-offset:1px}button.inline-edit-done{font-size:.75rem;font-weight:650;color:var(--teal);padding:6px 10px}button.inline-edit-done:hover{color:var(--ink)} +.program-view{position:relative;display:flex;align-items:flex-start;background:var(--pale)}.program-view .inline-edit{position:absolute;top:8px;right:8px;z-index:1}.program-view .exec-program{padding-right:40px;min-height:3.2em;width:100%;margin:0;border:0;background:transparent} +.program-panel.editing .program-view{display:none} +.program-panel.editing .program-actions-run{display:none!important} +.program-panel:not(.editing) .program-actions-builder{display:none!important} +.program-panel{margin:12px 0 8px;border:1px solid var(--line);background:var(--white)}.program-panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line)}.program-panel-head .small{margin:0;flex:1}.icon-btn{padding:.35rem .55rem;font-size:.8rem;display:inline-flex;align-items:center;justify-content:center;gap:.35rem;line-height:1}.icon-btn svg{display:block}.program-panel-actions{display:flex;align-items:center;gap:6px} +#commit-program{font-size:.8rem;font-weight:650;padding:.4rem .85rem} +.icon-btn-primary{background:var(--ink);color:white;border-color:var(--ink)}.icon-btn-primary:hover:not(:disabled){background:var(--teal);border-color:var(--teal);color:white}.icon-btn-primary:disabled{opacity:.55} +#run .icon-play,#run .icon-stop{display:block}#run[data-mode="play"] .icon-stop{display:none!important}#run[data-mode="stop"] .icon-play{display:none!important} +#run[data-mode="stop"]{background:var(--accent);border-color:var(--accent)}#run[data-mode="stop"]:hover:not(:disabled){background:#8f3a22;border-color:#8f3a22}.program-panel textarea{display:block;width:100%;border:1px solid var(--line);border-radius:3px;min-height:140px;padding:14px 18px;font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;margin-top:10px}.program-panel.editing .exec-program{display:none}.program-panel:not(.editing) .program-builder{display:none!important} +/* Expand the whole notebook to the right only while editing (keep left edge fixed) */ +#playground .lab:has(.program-panel.editing){width:calc(100% + min(20vw, 160px));max-width:none;margin-left:0;margin-right:calc(-1 * min(20vw, 160px))} +.program-builder{padding:16px 18px 20px;background:var(--white)}.builder-hint{margin:0 0 14px;font-size:.92rem;line-height:1.55;color:var(--ink)} +.builder-layout{display:grid;grid-template-columns:minmax(160px,190px) 48px minmax(0,1.15fr) minmax(180px,1fr);gap:10px;align-items:stretch;min-height:320px} +.builder-col-title{font-size:.68rem;font-weight:750;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:0 0 8px} +.builder-palette{display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--pale);border:1px solid var(--line);border-radius:4px;min-height:0} +.builder-palette-note{margin:0 0 4px;font-size:.78rem;font-weight:650;color:var(--teal)}.builder-palette-more{margin:4px 0 0;line-height:1.4} +.palette-block{text-align:left;padding:10px 10px;background:var(--white);border:1px solid var(--line);border-radius:4px;color:var(--ink);cursor:grab;font-size:.8rem;line-height:1.35;transition:border-color .15s,box-shadow .15s,background .15s}.palette-block:hover{border-color:var(--teal)}.palette-block:active{cursor:grabbing}.palette-block code{font-size:.72rem;color:var(--teal)}.palette-block.is-linked{border-color:var(--teal);background:#d9e8df;box-shadow:inset 3px 0 0 var(--teal)} +.builder-arrow{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;color:var(--teal);user-select:none}.builder-arrow-label{font-size:.65rem;font-weight:750;letter-spacing:.12em;text-transform:uppercase}.builder-arrow-glyph{font-size:1.85rem;line-height:1;font-weight:600} +.builder-canvas{min-height:280px;border:2px dashed #9aa9a7;border-radius:4px;padding:12px;background:#fbfaf6;display:flex;flex-direction:column;min-width:0}.builder-canvas.drag-over{border-color:var(--teal);border-style:solid;background:#eef6f2;box-shadow:inset 0 0 0 2px rgba(36,109,102,.15)} +.builder-empty{padding:36px 16px;text-align:center;color:var(--muted);font-size:.95rem;line-height:1.5}.builder-empty[hidden]{display:none!important} +.builder-lines{display:flex;flex-direction:column;gap:8px;flex:1;overflow:auto} +.builder-card{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:start;padding:10px;background:var(--white);border:1px solid var(--line);border-radius:4px;cursor:pointer;transition:border-color .15s,box-shadow .15s,background .15s}.builder-card.dragging{opacity:.5}.builder-card.is-selected{border-color:var(--teal);background:#eef6f2;box-shadow:inset 3px 0 0 var(--teal)} +.builder-card-handle{cursor:grab;color:var(--muted);padding:6px 2px;user-select:none;font-size:1rem;line-height:1}.builder-card-body{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:.85rem} +.builder-card-body label{display:flex;flex-direction:column;gap:3px;font-size:.68rem;color:var(--muted)}.builder-card-body input,.builder-card-body select{font:13px ui-monospace,SFMono-Regular,Consolas,monospace;padding:6px 8px;border:1px solid #a4b0b1;border-radius:3px;min-width:0;max-width:110px;background:white;color:var(--ink)} +.builder-card-body .builder-preview{flex-basis:100%;font:12px/1.5 ui-monospace,Consolas,monospace;color:var(--teal);margin:0} +.builder-card-remove{border:0;background:transparent;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 6px;line-height:1}.builder-card-remove:hover{color:var(--accent)} +.builder-source-panel{display:flex;flex-direction:column;min-width:0;min-height:280px;border:1px solid var(--line);border-radius:4px;background:var(--pale);overflow:hidden} +.builder-source-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);background:var(--white);flex-wrap:wrap}.builder-source-head .builder-col-title{margin:0} +.builder-source{margin:0;padding:8px 0;flex:1;overflow:auto;font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--ink);background:transparent}.builder-source:empty::before{content:"(empty — changes in Your program appear here)";color:var(--muted);display:block;padding:14px 16px}.builder-source-line{padding:2px 16px;white-space:pre-wrap;word-break:break-word;cursor:pointer}.builder-source-line:hover{background:rgba(36,109,102,.08)}.builder-source-line.is-active{background:#d9e8df;box-shadow:inset 3px 0 0 var(--teal);font-weight:600} +.builder-commit-status{font-size:.75rem;font-weight:650;text-align:right}.builder-commit-status.is-ok{color:var(--teal)}.builder-commit-status.is-bad{color:var(--accent)}.builder-commit-status.is-pending{color:var(--muted)} +.builder-commit-help{margin:14px 0 0;padding-top:12px;border-top:1px solid var(--line)} +.builder-advanced{margin-top:14px;border-top:1px solid var(--line);padding-top:10px}.builder-advanced summary{cursor:pointer;font-size:.85rem;font-weight:650;color:var(--muted)} +@media(max-width:900px){.builder-layout{grid-template-columns:1fr;min-height:0}.builder-arrow{flex-direction:row;padding:2px 0}.builder-arrow-glyph{transform:rotate(90deg)}.builder-source-panel,.builder-canvas{min-height:200px}#playground .lab:has(.program-panel.editing){width:100%;margin-right:0}}.walk-controls{align-items:center;justify-content:space-between}.walk-controls #walk-step-label{flex:1;text-align:center}.walk-trace td.dim{opacity:.28}.walk-trace tr.reveal td:not(.dim){font-weight:600}.eyebrow,.section-number{font-size:.72rem;font-weight:750;letter-spacing:.15em;text-transform:uppercase;color:var(--accent)}h1{font:normal clamp(3rem,5.1vw,5.4rem)/1.06 var(--serif);letter-spacing:-.04em;margin:22px 0 25px}h1 em{font-weight:normal;color:var(--teal)}.dek{font:1.18rem/1.7 var(--serif);max-width:none}.meta{font-size:.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:.7rem;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}.layout{display:grid;grid-template-columns:185px minmax(0,1fr);gap:55px;max-width:min(1280px,100%);padding:0 35px;margin:auto;width:100%;box-sizing:border-box}.toc{position:sticky;top:35px;align-self:start;border-top:1px solid var(--line);padding-top:20px;font-size:.82rem}.toc strong{font-size:.68rem;letter-spacing:.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:-.025em;margin:12px 0 24px} +.chapter{border-top:1px solid var(--line);padding:0;margin:0} +.chapter-summary{display:grid;grid-template-columns:1fr auto;grid-template-rows:auto auto;gap:6px 16px;align-items:center;cursor:pointer;list-style:none;padding:28px 0;user-select:none} +.chapter-summary::-webkit-details-marker{display:none} +.chapter-summary .section-number{grid-column:1;margin:0} +.chapter-title{grid-column:1;font:normal 1.85rem/1.2 var(--serif);letter-spacing:-.02em;color:var(--ink)} +.chapter-marker{grid-column:2;grid-row:1 / span 2;width:1.6rem;height:1.6rem;border:1px solid var(--line);border-radius:50%;position:relative;flex-shrink:0} +.chapter-marker::before,.chapter-marker::after{content:"";position:absolute;background:var(--ink);left:50%;top:50%;transform:translate(-50%,-50%)} +.chapter-marker::before{width:10px;height:1.5px} +.chapter-marker::after{width:1.5px;height:10px} +.chapter[open] .chapter-marker::after{display:none} +.chapter[open] .chapter-summary{padding-bottom:14px} +.chapter-body{padding:0 0 36px;max-width:none;width:100%} +.chapter-body > p:first-child{margin-top:0} +.chapter-nav{margin-top:28px;padding-top:8px} +.toc a.is-active{color:var(--accent);font-weight:650} +.ops-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin:18px 0 8px;padding:18px 20px;background:var(--pale);border:1px solid var(--line)} +.ops-grid h4{margin:0 0 10px;font:650 .85rem/1.3 var(--sans);letter-spacing:.04em;text-transform:uppercase;color:var(--teal)} +.ops-list{margin:0;padding-left:1.1rem;font-size:.92rem;color:var(--muted)} +.ops-list li{margin:6px 0} +.ops-list strong{color:var(--ink);font-weight:650} +.chapter-body h3{font:600 1.05rem/1.35 var(--sans);margin:28px 0 10px} +@media(max-width:650px){.ops-grid{grid-template-columns:1fr}} + +@media(max-width:650px){.chapter-title{font-size:1.45rem}.chapter-summary{padding:22px 0}} +h3{font:600 1.1rem/1.4 var(--sans);margin:25px 0 12px}p{margin:0 0 20px}article>section>p{max-width:none}.lede{font:1.3rem/1.6 var(--serif)}.badge{display:inline-block;padding:2px 9px;font-size:.66rem;letter-spacing:.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:.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:.9rem;color:var(--muted)}details{border-block:1px solid var(--line);margin:22px 0;padding:13px 0}summary{cursor:pointer;font-size:.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:.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:.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:.9rem}.small,.caption{font-size:.8rem;color:var(--muted);line-height:1.6}.status{font-size:.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 th.assign-cell{max-width:280px;white-space:normal;vertical-align:middle} +.trace .assign-btn{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:3px;padding:4px 6px;color:inherit;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;cursor:pointer;white-space:normal;word-break:break-word} +.trace .assign-btn:hover{background:var(--pale);color:var(--teal)} +.trace tr.selected th.assign-cell{background:#e4eee8} +.trace tr.selected .assign-btn{font-weight:650;color:var(--ink)}.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:.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} +.trace-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:12px 0 10px;flex-wrap:wrap} +.trace-toolbar{display:inline-flex;align-items:center;gap:4px;padding:4px;border:1px solid var(--line);border-radius:6px;background:var(--white)} +.trace-toolbar .icon-btn{border-color:transparent;background:transparent;padding:.4rem} +.trace-toolbar .icon-btn:hover:not(:disabled){background:var(--pale)} +.trace-step{min-width:7.5rem;text-align:center;padding:0 .4rem;font-variant-numeric:tabular-nums} +.trace-panel{margin-top:4px}.trace-empty{padding:36px 20px;border:1px dashed #9aa9a7;text-align:center;color:var(--muted);font-size:.92rem;line-height:1.55;background:var(--white)}.trace-empty[data-mode="builder"]{background:var(--pale);border-color:#8aa39a;color:var(--ink)}.trace-empty[data-mode="builder"] strong{color:var(--teal)}.trace-panel .interaction-guide{margin:0 0 12px}.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:.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:.9rem}.file-input{max-width:100%;font-size:.8rem}.run-meta{font-size:.8rem;background:var(--pale);padding:16px;white-space:pre-wrap}.refs{font-size:.88rem}.refs li{margin:15px 0}.glossary dt{font-weight:bold}.glossary dd{margin:0 0 18px;color:var(--muted);font-size:.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:.75rem;color:var(--muted)}.download{font-size:.8rem}.noscript{padding:20px;background:#eed7c4}.slider{accent-color:var(--teal);width:100%}code{font-size:.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:min(1280px,100%)}.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-solo{padding:56px 24px 48px}.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-solo{padding:40px 24px 36px}.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}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:none}.trace th.contributes{box-shadow:inset 0 -3px var(--accent)} + +.interaction-guide{background:var(--pale);padding:14px 16px;font-size:.9rem;line-height:1.65}.token.masked{opacity:.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} diff --git a/site/version2/data/interpreter.json b/site/version2/data/interpreter.json new file mode 100644 index 0000000..6a65c07 --- /dev/null +++ b/site/version2/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/site/version2/index.html b/site/version2/index.html new file mode 100644 index 0000000..402b3fc --- /dev/null +++ b/site/version2/index.html @@ -0,0 +1,319 @@ + + + + + + + C-RASP · Version 2 · Length generalization + + + + + +
+ C-RASP / VERSION 2 + +
+
+
+
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 +
+ + + +
+ +
+
+

+                  
+                
+ +
+

Pinned coli-saar/c-rasp interpreter · positions start at 0

+

+ +
+
+ + + + + + +
+ +
+ +
+
+ Nothing has run yet. Press play to fill this area with the execution trace. +
+ + +

+
+
+
+ +
+ +
+
+
+ + +
+
+
+ + + 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 + + + + + +
+ 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. +

+

+ Interactive tutorial draft · C-RASP, transformer constructions, and length + generalization +

+ Start with the Dyck-1 walkthrough +
+
+
Example: prefix balanceDyck-1
+ + Prefix balance for ()(()) is 1, 0, 1, 2, 1, 0 + + + + + + + + + + + + ( + ) + ( + ( + ) + ) + + + 2 + 1 + 0 + open brackets − closed brackets + + +
+ No negative prefixFinal balance = 0 ✓ +
+
+
+
+ +
+ + +
+
01 / The question
+

Three questions about length generalization

+

+ 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. +

+

+ Construction: §5 and Appendix B. +

+
+
+ +
+
05 / When positions matter
+

Positional information and its restrictions

+

+ 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. +

+

+ Huang et al. (2025), Definitions 4–6 and Theorem 7. +

+
+
+ +
+
07 / What experiments show
+

Empirical length-generalization results

+

+ 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. Figure 1: accuracy in three length bins, comparing APE and NoPE transformers across algorithmic tasks and formal languages. +
+ 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. +

+
+ +
+
Read further
+

Sources & notation

+
    +
  1. + Andy Yang and David Chiang (2024). + Counting Like Transformers: Compiling Temporal Counting Logic Into Softmax + Transformers. + COLM. +
  2. +
  3. + Xinting Huang et al. (2025). + A Formal Framework for Understanding Length Generalization in Transformers. + ICLR. +
  4. +
  5. + Andy Yang, Pascal Bergsträßer, Georg Zetzsche, David Chiang, and Anthony W. Lin + (2026). + Length Generalization Bounds for Transformers. +
  6. +
  7. + Hattie Zhou et al. (2024). + What Algorithms Can Transformers Learn? A Study in Length Generalization. + ICLR. Empirical motivation and the RASP-L conjecture. +
  8. +
+
+

Glossary

+
+
Predicate
+
A Boolean value at each position.
+
Prefix count
+
+ 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 + + + + + + +
+ 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. +

+

Yang & Chiang (2024), §3

+
+
+
+ + diff --git a/tutorial_dual/static/web-assets/interactive/version2/README.md b/tutorial_dual/static/web-assets/interactive/version2/README.md new file mode 100644 index 0000000..4170a16 --- /dev/null +++ b/tutorial_dual/static/web-assets/interactive/version2/README.md @@ -0,0 +1,18 @@ +# Version 2 (experimental) + +Self-contained alternate tutorial with drag-and-drop C-RASP program editing. + +This directory does **not** replace the main tutorial at `site/index.html`. + +## How to start + +From the repository root: + +```sh +python3 -m http.server 8000 --directory site +``` + +Then open: + +- Main tutorial: http://localhost:8000/ +- Version 2: http://localhost:8000/version2/ diff --git a/tutorial_dual/static/web-assets/interactive/version2/assets/app.js b/tutorial_dual/static/web-assets/interactive/version2/assets/app.js new file mode 100644 index 0000000..41cae1e --- /dev/null +++ b/tutorial_dual/static/web-assets/interactive/version2/assets/app.js @@ -0,0 +1,1337 @@ +import {tokenize} from './lib.js'; +const $ = id => document.getElementById(id); + +/* ---- Section accordion (one open at a time) ---- */ +const chapters = [...document.querySelectorAll('details.chapter')]; + +function setTocActive(id) { + document.querySelectorAll('[data-open-section]').forEach(a => { + a.classList.toggle('is-active', a.dataset.openSection === id); + }); +} + +function openSection(id, {scroll = true} = {}) { + const target = document.getElementById(id); + if (!target || !target.classList.contains('chapter')) return; + chapters.forEach(ch => { ch.open = ch === target; }); + setTocActive(id); + if (scroll) { + requestAnimationFrame(() => { + target.scrollIntoView({behavior: 'smooth', block: 'start'}); + }); + } + history.replaceState(null, '', `#${id}`); +} + +chapters.forEach(ch => { + ch.addEventListener('toggle', () => { + if (!ch.open) return; + chapters.forEach(other => { + if (other !== ch) other.open = false; + }); + setTocActive(ch.id); + }); +}); + +document.querySelectorAll('[data-open-section]').forEach(el => { + el.addEventListener('click', e => { + e.preventDefault(); + openSection(el.dataset.openSection); + }); +}); + +document.querySelectorAll('.next-chapter').forEach(btn => { + btn.addEventListener('click', () => openSection(btn.dataset.next)); +}); + +const initialHash = location.hash.replace(/^#/, ''); +if (initialHash && document.getElementById(initialHash)?.classList.contains('chapter')) { + openSection(initialHash, {scroll: false}); +} else { + setTocActive(chapters.find(ch => ch.open)?.id || 'question'); +} + + +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'} +}; + +/* ---- Guided walkthrough on ( ) ---- */ +const walkTokens = ['(', ')']; +const walkProgramLines = [ + 'C_open = # "("', + 'C_close = # ")"', + 'V = C_open < C_close', + 'CV = # V', + 'M = (CV == 0)', + 'B = (C_open == C_close)', + 'D = M && B', +]; +const walkRows = [ + {name: 'C_open', values: [1, 1]}, + {name: 'C_close', values: [0, 1]}, + {name: 'V', values: [false, false]}, + {name: 'CV', values: [0, 0]}, + {name: 'M', values: [true, true]}, + {name: 'B', values: [false, true]}, + {name: 'D', values: [false, true]}, +]; +const walkSteps = [ + { + reveal: -1, + line: -1, + caption: 'Each column is one input position. Each program line will fill a whole row (a vector). We start from token markers and build the two Dyck conditions out of counts.', + verdict: '', + }, + { + reveal: 0, + line: 0, + caption: 'C_open = # "(" — prefix count of opens. Under “(” the count is 1; under “)” it stays 1. This is the running open-count both Dyck conditions need.', + verdict: '', + }, + { + reveal: 1, + line: 1, + caption: 'C_close = # ")" — same for closes: 0, then 1. Now every prefix has a pair of numbers we can compare.', + verdict: '', + }, + { + reveal: 2, + line: 2, + caption: 'V marks a prefix violation: more closes than opens (C_open < C_close). Here both entries are false. On ())( this row would turn true in the middle.', + verdict: '', + }, + { + reveal: 3, + line: 3, + caption: 'CV = # V — how many violations so far. Once this leaves zero, a bad prefix is remembered for the rest of the string. Here it stays 0.', + verdict: '', + }, + { + reveal: 4, + line: 4, + caption: 'M means “no violation yet”: CV == 0. That is condition (1) as a Boolean at each position.', + verdict: '', + }, + { + reveal: 5, + line: 5, + caption: 'B means equal open and close counts—condition (2). False after “(” (1≠0), true after “)” (1=1). Alone, B would wrongly accept ())( ; that is why M is required too.', + verdict: '', + }, + { + reveal: 6, + line: 6, + caption: 'D = M && B joins both conditions. Accept iff the last entry of D is true. Here it is → accept. Same program, longer inputs: only the vectors grow.', + verdict: 'Accepted', + }, +]; +let walkIndex = 0; + +function fmt(v) { + if (typeof v === 'boolean') return v ? '✓' : '·'; + return String(v); +} + +function renderWalkProgram(activeLine) { + const pre = $('walk-program'); + pre.replaceChildren(); + walkProgramLines.forEach((text, i) => { + const line = document.createElement('div'); + line.className = 'program-line' + (i === activeLine ? ' active' : ''); + line.textContent = text; + pre.append(line); + }); +} + +function renderWalk() { + const step = walkSteps[walkIndex]; + renderWalkProgram(step.line); + const table = $('walk-table'); + table.replaceChildren(); + const head = table.createTHead().insertRow(); + ['', ...walkTokens].forEach((t, i) => { + const th = document.createElement('th'); + th.textContent = i === 0 ? 'Assignment' : `${i - 1}: ${t}`; + head.append(th); + }); + const body = table.createTBody(); + walkRows.forEach((row, ri) => { + const tr = body.insertRow(); + if (ri === step.reveal) tr.classList.add('reveal'); + const th = document.createElement('th'); + th.textContent = row.name; + tr.append(th); + row.values.forEach(v => { + const td = tr.insertCell(); + if (ri > step.reveal) { + td.textContent = '·'; + td.classList.add('dim'); + } else { + td.textContent = fmt(v); + } + }); + }); + $('walk-caption').textContent = step.caption; + $('walk-step-label').textContent = `Step ${walkIndex + 1} / ${walkSteps.length}`; + $('walk-prev').disabled = walkIndex === 0; + $('walk-next').disabled = walkIndex === walkSteps.length - 1; + const verdict = $('walk-verdict'); + verdict.textContent = step.verdict; + verdict.className = step.verdict ? 'result accepted' : 'result'; +} + +$('walk-prev').onclick = () => { walkIndex = Math.max(0, walkIndex - 1); renderWalk(); }; +$('walk-next').onclick = () => { walkIndex = Math.min(walkSteps.length - 1, walkIndex + 1); renderWalk(); }; +$('walk-reset').onclick = () => { walkIndex = 0; renderWalk(); }; +renderWalk(); + +/* ---- Interactive interpreter ---- */ +let trace, selected = 0, worker, timer, running = false, lastRequest; +let playIndex = -1; +let playTimer = null; +const PLAY_MS = 650; + +function status(message, error = false) { + $('engine-status').textContent = message; + $('engine-status').classList.toggle('error', error); +} +function setRunMode(mode) { + const btn = $('run'); + const pausing = mode === 'stop'; + btn.dataset.mode = pausing ? 'stop' : 'play'; + const label = pausing ? 'Pause' : (canResumePlayback() ? 'Resume' : 'Run program'); + btn.title = label; + btn.setAttribute('aria-label', label); +} +function isBusy() { + return running || playTimer !== null; +} +function inputUnchanged() { + if (!lastRequest) return false; + const tokens = (() => { + try { return tokenize($('tokens').value); } catch { return null; } + })(); + if (!tokens) return false; + return lastRequest.source === currentProgramSource() + && tokens.length === lastRequest.tokens.length + && tokens.every((t, i) => t === lastRequest.tokens[i]); +} +function canResumePlayback() { + return Boolean(trace && !running && playTimer === null + && playIndex >= 0 && playIndex < trace.rows.length - 1 + && inputUnchanged()); +} +function syncRunButton() { + setRunMode(isBusy() ? 'stop' : 'play'); +} +function stop() { + worker?.terminate(); + worker = undefined; + clearTimeout(timer); + running = false; + syncRunButton(); +} +function stopPlayback() { + clearInterval(playTimer); + playTimer = null; + syncRunButton(); +} +function advancePlaybackTick() { + if (!trace) return; + if (playIndex >= trace.rows.length - 1) { + stopPlayback(); + status('Evaluated by the pinned Python interpreter.'); + return; + } + playIndex += 1; + paintTrace(); + if (playIndex >= trace.rows.length - 1) { + stopPlayback(); + status('Evaluated by the pinned Python interpreter.'); + } +} +function resumePlayback() { + if (!trace || playIndex >= trace.rows.length - 1) return; + status('Resumed…'); + playTimer = setInterval(advancePlaybackTick, PLAY_MS); + syncRunButton(); +} +function markStale() { + stopPlayback(); + $('download-trace').disabled = true; + $('result').textContent = 'Edits not evaluated'; + $('result').className = 'result'; + $('acceptance-note').textContent = 'Edits not evaluated yet — choose Run program.'; + status('Program or input changed. Run to update the trace.'); + renderExecProgram(null); + showTraceUI(false); + trace = null; + playIndex = -1; + $('row-label').textContent = '—'; + $('result').textContent = ''; + $('result').className = 'result'; + $('acceptance-note').textContent = ''; + $('command-link').textContent = ''; +} + +function lineIndexForAssignment(source, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`^\\s*${escaped}\\s*=`); + return source.split('\n').findIndex(line => re.test(line)); +} + +function currentProgramSource() { + return $('program').value; +} + +function renderExecProgram(activeName) { + const pre = $('exec-program'); + const source = (playIndex >= 0 && lastRequest?.source) ? lastRequest.source : currentProgramSource(); + pre.replaceChildren(); + const active = activeName ? lineIndexForAssignment(source, activeName) : -1; + source.split('\n').forEach((text, i) => { + const line = document.createElement('div'); + line.className = 'program-line' + (i === active ? ' active' : ''); + line.textContent = text || ' '; + pre.append(line); + }); +} + +/* ---- Block builder (pencil → drag & drop, not free typing) ---- */ +let builderLines = []; +let builderDragType = null; +let builderReorderFrom = null; +let builderCommitted = false; +let builderBaseline = ''; // program text when builder was opened / last restored +let builderHistory = []; +let builderHistoryIndex = -1; +let builderHistoryLock = false; +let builderHistoryTimer = 0; +let builderSelectedIndex = -1; +let checkWorker; + +function freshName(prefix = 'P') { + const used = new Set(builderLines.map(l => l.name)); + let i = 1; + while (used.has(`${prefix}${i}`)) i += 1; + return `${prefix}${i}`; +} + +function defaultBlock(type) { + switch (type) { + case 'count_token': + return {type, name: freshName('C'), token: '('}; + case 'count_pred': + return {type, name: freshName('C'), pred: 'true', negate: false}; + case 'marker': + return {type, name: freshName('T'), token: 'a'}; + case 'compare': + return {type, name: freshName('P'), left: 'C1', op: '<', right: 'C2'}; + case 'and': + return {type, name: freshName('P'), left: 'P1', right: 'P2'}; + case 'or': + return {type, name: freshName('P'), left: 'P1', right: 'P2'}; + case 'not': + return {type, name: freshName('P'), arg: 'P1'}; + default: + return {type: 'raw', name: freshName('X'), text: 'true'}; + } +} + +function serializeBlock(b) { + const n = (b.name || 'P').trim() || 'P'; + switch (b.type) { + case 'count_token': + return `${n} = # "${(b.token || '').replace(/"/g, '')}"`; + case 'count_pred': { + const pred = (b.pred || 'true').trim() || 'true'; + return `${n} = # ${b.negate ? `!${pred}` : pred}`; + } + case 'marker': + return `${n} = "${(b.token || '').replace(/"/g, '')}"`; + case 'compare': { + const left = (b.left || '0').trim(); + const right = (b.right || '0').trim(); + const op = b.op || '=='; + return `${n} = (${left} ${op} ${right})`; + } + case 'and': + return `${n} = ${(b.left || 'P').trim()} && ${(b.right || 'Q').trim()}`; + case 'or': + return `${n} = ${(b.left || 'P').trim()} || ${(b.right || 'Q').trim()}`; + case 'not': + return `${n} = !${(b.arg || 'P').trim()}`; + case 'raw': + return `${n} = ${(b.text || 'true').trim()}`; + default: + return `${n} = true`; + } +} + +function serializeBuilder() { + return builderLines.map(serializeBlock).join('\n'); +} + +function setCommitStatus(text, kind = '') { + const el = $('builder-commit-status'); + if (!el) return; + el.textContent = text || ''; + el.className = 'builder-commit-status' + (kind ? ` is-${kind}` : ''); +} + +function updateBuilderSource() { + const src = serializeBuilder(); + const pre = $('builder-source'); + if (pre) { + pre.replaceChildren(); + if (!builderLines.length) { + // empty ::before handles placeholder + } else { + builderLines.forEach((block, i) => { + const line = document.createElement('div'); + line.className = 'builder-source-line' + (i === builderSelectedIndex ? ' is-active' : ''); + line.dataset.index = String(i); + line.textContent = serializeBlock(block); + line.addEventListener('click', () => selectBuilderLine(i)); + pre.append(line); + }); + } + } + if ($('program')) $('program').value = src; + highlightBuilderLinks(); +} + +function selectBuilderLine(index) { + if (index < 0 || index >= builderLines.length) { + builderSelectedIndex = -1; + } else { + builderSelectedIndex = index; + } + document.querySelectorAll('.builder-card').forEach((card, i) => { + card.classList.toggle('is-selected', i === builderSelectedIndex); + }); + document.querySelectorAll('.builder-source-line').forEach(line => { + line.classList.toggle('is-active', Number(line.dataset.index) === builderSelectedIndex); + }); + highlightBuilderLinks(); + const activeLine = document.querySelector('.builder-source-line.is-active'); + activeLine?.scrollIntoView({block: 'nearest', behavior: 'smooth'}); + const activeCard = document.querySelector('.builder-card.is-selected'); + activeCard?.scrollIntoView({block: 'nearest', behavior: 'smooth'}); +} + +function highlightBuilderLinks() { + const type = builderSelectedIndex >= 0 ? builderLines[builderSelectedIndex]?.type : null; + document.querySelectorAll('.palette-block').forEach(btn => { + const match = type && btn.dataset.block === type; + btn.classList.toggle('is-linked', Boolean(match)); + }); +} + +function snapshotBuilder() { + return JSON.parse(JSON.stringify(builderLines)); +} + +function pushBuilderHistory({immediate = false} = {}) { + if (builderHistoryLock) return; + const doPush = () => { + const snap = snapshotBuilder(); + const serialized = JSON.stringify(snap); + if (builderHistoryIndex >= 0 && JSON.stringify(builderHistory[builderHistoryIndex]) === serialized) { + syncBuilderNavButtons(); + return; + } + builderHistory = builderHistory.slice(0, builderHistoryIndex + 1); + builderHistory.push(snap); + if (builderHistory.length > 80) { + builderHistory.shift(); + builderHistoryIndex = builderHistory.length - 1; + } else { + builderHistoryIndex = builderHistory.length - 1; + } + syncBuilderNavButtons(); + }; + if (immediate) { + clearTimeout(builderHistoryTimer); + doPush(); + return; + } + clearTimeout(builderHistoryTimer); + builderHistoryTimer = setTimeout(doPush, 280); +} + +function applyBuilderSnapshot(snap) { + builderHistoryLock = true; + builderLines = JSON.parse(JSON.stringify(snap)); + renderBuilder(); + builderCommitted = false; + setCommitStatus('Uncommitted changes', 'pending'); + updateBuilderSource(); + markStale(); + document.querySelector('.program-panel')?.classList.add('editing'); + $('program-builder').hidden = false; + builderHistoryLock = false; + syncBuilderNavButtons(); +} + +function syncBuilderNavButtons() { + const back = $('builder-back'); + const forth = $('builder-forth'); + if (back) back.disabled = builderHistoryIndex <= 0; + if (forth) forth.disabled = builderHistoryIndex < 0 || builderHistoryIndex >= builderHistory.length - 1; +} + +function builderUndo() { + if (builderHistoryIndex <= 0) return; + builderHistoryIndex -= 1; + applyBuilderSnapshot(builderHistory[builderHistoryIndex]); +} + +function builderRedo() { + if (builderHistoryIndex >= builderHistory.length - 1) return; + builderHistoryIndex += 1; + applyBuilderSnapshot(builderHistory[builderHistoryIndex]); +} + +function builderRestoreInitial() { + builderLines = parseProgramToBlocks(builderBaseline); + renderBuilder(); + pushBuilderHistory({immediate: true}); + builderCommitted = false; + setCommitStatus('Restored initial program', 'pending'); + updateBuilderSource(); + markStale(); + document.querySelector('.program-panel')?.classList.add('editing'); + $('program-builder').hidden = false; + status('Restored the program from when you opened the builder.'); +} + +function syncBuilderToProgram({immediateHistory = false} = {}) { + builderCommitted = false; + setCommitStatus('Uncommitted changes', 'pending'); + updateBuilderSource(); + pushBuilderHistory({immediate: immediateHistory}); + markStale(); + // Keep builder visible after markStale clears trace + document.querySelector('.program-panel')?.classList.add('editing'); + $('program-builder').hidden = false; +} + +function localWellFormedCheck(source) { + const issues = []; + const lines = source.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('//')); + if (!lines.length) issues.push('Add at least one assignment.'); + const seen = new Set(); + const defined = new Set(); + lines.forEach((line, i) => { + if (line.startsWith('#import')) { + const imp = line.match(/^#import\s+([A-Za-z_][A-Za-z0-9_]*)/); + if (imp) defined.add(imp[1]); + return; + } + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/); + if (!m) { + issues.push(`Line ${i + 1}: expected Name = expression.`); + return; + } + const [, name, rhs] = m; + if (seen.has(name)) issues.push(`Duplicate name “${name}”.`); + seen.add(name); + const stripped = rhs.replace(/"(?:\\.|[^"\\])*"/g, '""'); + for (const ref of stripped.match(/[A-Za-z_][A-Za-z0-9_]*/g) || []) { + if (['true', 'false', 'min', 'max', 'if', 'else'].includes(ref)) continue; + if (!defined.has(ref)) { + issues.push(`“${ref}” is used before it is defined (or is unknown).`); + } + } + defined.add(name); + }); + return [...new Set(issues)]; +} + +function parseProgramToBlocks(source) { + const lines = []; + for (const raw of source.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('//') || line.startsWith('#import')) continue; + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (!m) continue; + const name = m[1]; + let rhs = m[2].trim(); + // strip outer parens once + if (rhs.startsWith('(') && rhs.endsWith(')')) { + const inner = rhs.slice(1, -1).trim(); + if (!inner.includes('(')) rhs = inner; + } + let block = null; + let mm; + if ((mm = rhs.match(/^#\s*"([^"]*)"\s*$/))) { + block = {type: 'count_token', name, token: mm[1]}; + } else if ((mm = rhs.match(/^#\s*!?\s*([A-Za-z_][A-Za-z0-9_]*|true|false)\s*$/))) { + block = {type: 'count_pred', name, pred: mm[1], negate: /^#\s*!/.test(rhs)}; + } else if ((mm = rhs.match(/^"([^"]*)"\s*$/))) { + block = {type: 'marker', name, token: mm[1]}; + } else if ((mm = rhs.match(/^(.+?)\s*(<=|>=|==|!=|<|>)\s*(.+)$/))) { + block = {type: 'compare', name, left: mm[1].replace(/^\(|\)$/g, '').trim(), op: mm[2], right: mm[3].replace(/^\(|\)$/g, '').trim()}; + } else if ((mm = rhs.match(/^(.+?)\s*&&\s*(.+)$/))) { + block = {type: 'and', name, left: mm[1].trim(), right: mm[2].trim()}; + } else if ((mm = rhs.match(/^(.+?)\s*\|\|\s*(.+)$/))) { + block = {type: 'or', name, left: mm[1].trim(), right: mm[2].trim()}; + } else if ((mm = rhs.match(/^!\s*(.+)$/))) { + block = {type: 'not', name, arg: mm[1].trim()}; + } else { + block = {type: 'raw', name, text: rhs}; + } + lines.push(block); + } + return lines; +} + +function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'className') node.className = v; + else if (k === 'text') node.textContent = v; + else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v); + else if (v !== false && v != null) node.setAttribute(k, v === true ? '' : v); + } + for (const c of [].concat(children)) { + if (c == null) continue; + node.append(typeof c === 'string' ? document.createTextNode(c) : c); + } + return node; +} + +function field(label, control) { + return el('label', {}, [label, control]); +} + +function renderBuilderCard(block, index) { + const card = el('div', { + className: 'builder-card' + (index === builderSelectedIndex ? ' is-selected' : ''), + draggable: 'true', + 'data-index': String(index), + }); + card.addEventListener('click', e => { + if (e.target.closest('input, select, button, textarea')) return; + selectBuilderLine(index); + }); + card.addEventListener('dragstart', e => { + builderReorderFrom = index; + card.classList.add('dragging'); + e.dataTransfer.setData('text/plain', `reorder:${index}`); + e.dataTransfer.effectAllowed = 'move'; + }); + card.addEventListener('dragend', () => { + card.classList.remove('dragging'); + builderReorderFrom = null; + }); + + const handle = el('div', {className: 'builder-card-handle', title: 'Drag to reorder', text: '⋮⋮'}); + const body = el('div', {className: 'builder-card-body'}); + + const nameInput = el('input', {value: block.name, 'aria-label': 'Variable name'}); + nameInput.addEventListener('input', () => { + block.name = nameInput.value.trim() || block.name; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(field('name', nameInput)); + + if (block.type === 'count_token' || block.type === 'marker') { + const tok = el('input', {value: block.token ?? '', 'aria-label': 'Token'}); + tok.addEventListener('input', () => { + block.token = tok.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(field('token', tok)); + } else if (block.type === 'count_pred') { + const pred = el('input', {value: block.pred ?? 'true', list: 'builder-name-list', 'aria-label': 'Predicate'}); + const neg = el('input', {type: 'checkbox', 'aria-label': 'Negate'}); + neg.checked = Boolean(block.negate); + pred.addEventListener('input', () => { + block.pred = pred.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + neg.addEventListener('change', () => { + block.negate = neg.checked; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(field('predicate', pred), field('not', neg)); + } else if (block.type === 'compare') { + const left = el('input', {value: block.left ?? '', list: 'builder-name-list'}); + const op = el('select', {}, ['<', '<=', '>', '>=', '==', '!='].map(o => { + const opt = el('option', {value: o, text: o}); + if (o === block.op) opt.selected = true; + return opt; + })); + const right = el('input', {value: block.right ?? '', list: 'builder-name-list'}); + const bump = () => { + block.left = left.value; + block.op = op.value; + block.right = right.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }; + left.addEventListener('input', bump); + op.addEventListener('change', bump); + right.addEventListener('input', bump); + body.append(field('left', left), field('op', op), field('right', right)); + } else if (block.type === 'and' || block.type === 'or') { + const left = el('input', {value: block.left ?? '', list: 'builder-name-list'}); + const right = el('input', {value: block.right ?? '', list: 'builder-name-list'}); + const bump = () => { + block.left = left.value; + block.right = right.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }; + left.addEventListener('input', bump); + right.addEventListener('input', bump); + body.append(field('left', left), el('span', {text: block.type === 'and' ? '&&' : '||'}), field('right', right)); + } else if (block.type === 'not') { + const arg = el('input', {value: block.arg ?? '', list: 'builder-name-list'}); + arg.addEventListener('input', () => { + block.arg = arg.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(el('span', {text: '!'}), field('predicate', arg)); + } else { + const text = el('input', {value: block.text ?? '', style: 'max-width:220px'}); + text.addEventListener('input', () => { + block.text = text.value; + syncBuilderToProgram(); + updateBuilderPreview(card, block); + }); + body.append(field('expression', text)); + } + + const preview = el('code', {className: 'builder-preview', text: serializeBlock(block)}); + body.append(preview); + + const remove = el('button', { + type: 'button', + className: 'builder-card-remove', + title: 'Remove line', + text: '×', + onclick: () => { + builderLines.splice(index, 1); + renderBuilder(); + syncBuilderToProgram({immediateHistory: true}); + }, + }); + + card.append(handle, body, remove); + return card; +} + +function updateBuilderPreview(card, block) { + const preview = card.querySelector('.builder-preview'); + if (preview) preview.textContent = serializeBlock(block); +} + +function renderBuilder() { + const host = $('builder-lines'); + const empty = $('builder-empty'); + host.replaceChildren(); + // datalist of names for inputs + let list = document.getElementById('builder-name-list'); + if (!list) { + list = el('datalist', {id: 'builder-name-list'}); + document.body.append(list); + } + list.replaceChildren(...builderLines.map(l => el('option', {value: l.name}))); + + empty.hidden = builderLines.length > 0; + if (builderSelectedIndex >= builderLines.length) builderSelectedIndex = builderLines.length ? builderLines.length - 1 : -1; + builderLines.forEach((block, i) => host.append(renderBuilderCard(block, i))); + updateBuilderSource(); +} + +function addBuilderBlock(type, atIndex = builderLines.length) { + const block = defaultBlock(type); + // Prefer sensible names from earlier lines when possible + if (type === 'compare' && builderLines.length >= 2) { + block.left = builderLines[builderLines.length - 2].name; + block.right = builderLines[builderLines.length - 1].name; + } + if ((type === 'and' || type === 'or') && builderLines.length >= 2) { + block.left = builderLines[builderLines.length - 2].name; + block.right = builderLines[builderLines.length - 1].name; + } + if (type === 'count_pred' && builderLines.length) { + block.pred = builderLines[builderLines.length - 1].name; + } + if (type === 'not' && builderLines.length) { + block.arg = builderLines[builderLines.length - 1].name; + } + builderLines.splice(atIndex, 0, block); + builderSelectedIndex = atIndex; + renderBuilder(); + syncBuilderToProgram({immediateHistory: true}); +} + +function setupBuilderDnD() { + document.querySelectorAll('.palette-block').forEach(btn => { + btn.addEventListener('dragstart', e => { + builderDragType = btn.dataset.block; + e.dataTransfer.setData('text/plain', `palette:${btn.dataset.block}`); + e.dataTransfer.effectAllowed = 'copy'; + }); + btn.addEventListener('dragend', () => { builderDragType = null; }); + btn.addEventListener('click', () => addBuilderBlock(btn.dataset.block)); + }); + + const canvas = $('builder-canvas'); + canvas.addEventListener('dragover', e => { + e.preventDefault(); + canvas.classList.add('drag-over'); + e.dataTransfer.dropEffect = builderDragType ? 'copy' : 'move'; + }); + canvas.addEventListener('dragleave', () => canvas.classList.remove('drag-over')); + canvas.addEventListener('drop', e => { + e.preventDefault(); + canvas.classList.remove('drag-over'); + const data = e.dataTransfer.getData('text/plain') || ''; + if (data.startsWith('palette:')) { + addBuilderBlock(data.slice(8)); + return; + } + if (data.startsWith('reorder:')) { + const from = Number(data.slice(8)); + if (Number.isNaN(from)) return; + // drop at end for simplicity when dropping on canvas background + const [item] = builderLines.splice(from, 1); + builderLines.push(item); + renderBuilder(); + syncBuilderToProgram({immediateHistory: true}); + } + }); + + $('builder-lines').addEventListener('dragover', e => { + const card = e.target.closest('.builder-card'); + if (!card) return; + e.preventDefault(); + }); + $('builder-lines').addEventListener('drop', e => { + const card = e.target.closest('.builder-card'); + if (!card) return; + e.preventDefault(); + e.stopPropagation(); + const to = Number(card.dataset.index); + const data = e.dataTransfer.getData('text/plain') || ''; + if (data.startsWith('palette:')) { + addBuilderBlock(data.slice(8), to); + return; + } + if (data.startsWith('reorder:')) { + const from = Number(data.slice(8)); + if (Number.isNaN(from) || from === to) return; + const [item] = builderLines.splice(from, 1); + builderLines.splice(to, 0, item); + renderBuilder(); + syncBuilderToProgram({immediateHistory: true}); + } + }); +} + +function setProgramEditing(on) { + const panel = document.querySelector('.program-panel'); + const wasEditing = panel.classList.contains('editing'); + panel.classList.toggle('editing', on); + $('program-builder').hidden = !on; + $('edit-program').hidden = on; + $('edit-program').setAttribute('aria-pressed', String(on)); + const builderActions = document.querySelector('.program-actions-builder'); + const runActions = document.querySelector('.program-actions-run'); + if (builderActions) builderActions.hidden = !on; + if (runActions) runActions.hidden = on; + if (on) { + stopPlayback(); + builderCommitted = false; + builderBaseline = currentProgramSource(); + builderLines = parseProgramToBlocks(builderBaseline); + builderHistory = [snapshotBuilder()]; + builderHistoryIndex = 0; + builderSelectedIndex = builderLines.length ? 0 : -1; + renderBuilder(); + syncBuilderNavButtons(); + setCommitStatus(builderLines.length ? 'Edit, then Commit to check' : 'Drag commands into Your program', 'pending'); + } else { + if (wasEditing && builderLines.length) { + $('program').value = serializeBuilder(); + } + renderExecProgram(playIndex >= 0 && trace ? trace.rows[playIndex]?.name : null); + } + if (!trace) updateTraceEmptyHint(); +} + +function stopCheckWorker() { + if (!checkWorker) return; + checkWorker.terminate(); + checkWorker = undefined; +} + +async function commitProgram() { + updateBuilderSource(); + const source = serializeBuilder().trim(); + $('program').value = source; + const localIssues = localWellFormedCheck(source); + if (localIssues.length) { + builderCommitted = false; + setCommitStatus(`Not well-formed — ${localIssues[0]}`, 'bad'); + status(localIssues.join(' '), true); + return; + } + let tokens; + try { + tokens = tokenize($('tokens').value); + } catch (e) { + builderCommitted = false; + setCommitStatus(`Not well-formed — ${e.message}`, 'bad'); + status(e.message, true); + return; + } + setCommitStatus('Checking with interpreter…', 'pending'); + status('Checking whether the program is well-formed…'); + stopCheckWorker(); + checkWorker = new Worker('runtime/python-worker.js'); + const timer = setTimeout(() => { + stopCheckWorker(); + builderCommitted = false; + setCommitStatus('Check timed out', 'bad'); + status('Well-formedness check timed out. Try again.', true); + }, 120000); + checkWorker.onmessage = ({data}) => { + if (data.status) status(data.status); + if (data.error) { + clearTimeout(timer); + stopCheckWorker(); + builderCommitted = false; + const msg = String(data.error).replace(/^Error:\s*/i, ''); + setCommitStatus(`Not well-formed — ${msg}`, 'bad'); + status(msg, true); + return; + } + if (data.check) { + clearTimeout(timer); + stopCheckWorker(); + builderCommitted = true; + const acceptNote = data.check.accepted === true ? ' · accepts this input' + : data.check.accepted === false ? ' · rejects this input' : ''; + setCommitStatus(`Well-formed ✓ (${data.check.statements} lines)${acceptNote}`, 'ok'); + updateBuilderSource(); + status('Program is well-formed. Leaving the builder — Run to step through the trace.'); + setProgramEditing(false); + markStale(); + } + }; + checkWorker.onerror = e => { + clearTimeout(timer); + stopCheckWorker(); + builderCommitted = false; + setCommitStatus('Check failed to start', 'bad'); + status(`Could not start checker: ${e.message}`, true); + }; + checkWorker.postMessage({mode: 'check', source, tokens}); +} + +$('edit-program').onclick = () => setProgramEditing(true); +$('builder-back').onclick = () => builderUndo(); +$('builder-forth').onclick = () => builderRedo(); +$('builder-restore').onclick = () => builderRestoreInitial(); +$('commit-program').onclick = () => { commitProgram(); }; +setupBuilderDnD(); + +// If user edits advanced text, re-parse into cards +$('program').addEventListener('change', () => { + if (document.querySelector('.program-panel')?.classList.contains('editing')) { + builderCommitted = false; + builderLines = parseProgramToBlocks(currentProgramSource()); + renderBuilder(); + pushBuilderHistory(); + setCommitStatus('Uncommitted text edits', 'pending'); + } +}); + +function setTokensEditing(on) { + const input = $('tokens'); + input.readOnly = !on; + $('edit-tokens').hidden = on; + $('edit-tokens').setAttribute('aria-pressed', String(on)); + $('done-edit-tokens').hidden = !on; + if (on) { + input.focus(); + input.select(); + } else { + input.blur(); + } +} +$('edit-tokens').onclick = () => setTokensEditing(true); +$('done-edit-tokens').onclick = () => { + markStale(); + setTokensEditing(false); +}; +$('tokens').addEventListener('keydown', e => { + if (e.key === 'Enter' && !$('tokens').readOnly) { + e.preventDefault(); + markStale(); + setTokensEditing(false); + } +}); +$('copy-program').onclick = async () => { + try { + await navigator.clipboard.writeText(currentProgramSource()); + status('Program copied to clipboard.'); + } catch { + status('Could not copy program.', true); + } +}; + +function focusAssignment(ri) { + if (!trace || ri < 0 || ri >= trace.rows.length) return; + stopPlayback(); + playIndex = ri; + selected = ri; + paintTrace(); + const active = $('exec-program')?.querySelector('.program-line.active'); + active?.scrollIntoView({block: 'nearest', behavior: 'smooth'}); +} + +function updateTraceEmptyHint() { + const empty = $('trace-empty'); + if (!empty) return; + const editing = document.querySelector('.program-panel')?.classList.contains('editing'); + if (editing) { + empty.dataset.mode = 'builder'; + empty.innerHTML = 'Finish editing in the builder first. Press Commit to check that the program is well-formed — then you can run it and fill this area with the execution trace.'; + } else { + empty.dataset.mode = 'idle'; + empty.textContent = 'Nothing has run yet. Press play to fill this area with the execution trace.'; + } +} + +function showTraceUI(hasTrace) { + $('trace-empty').hidden = hasTrace; + $('trace-scroll').hidden = !hasTrace; + $('command-link').hidden = !hasTrace; + if (!hasTrace) updateTraceEmptyHint(); +} + +function paintTrace() { + if (!trace) return; + showTraceUI(true); + const reveal = playIndex; + const source = lastRequest?.source || currentProgramSource(); + const table = $('trace'); + table.replaceChildren(); + const caption = table.createCaption(); + caption.className = 'small'; + caption.textContent = 'Click an assignment to highlight its program line. Values by token position (zero-based).'; + const head = table.createTHead().insertRow(); + ['Assignment', ...trace.tokens.map((t, i) => `${i}: ${t}`)].forEach(t => { + const th = document.createElement('th'); + th.textContent = t; + head.append(th); + }); + const body = table.createTBody(); + const done = reveal >= trace.rows.length - 1; + trace.rows.forEach((row, ri) => { + const tr = body.insertRow(); + tr.dataset.row = ri; + if (ri === reveal) tr.classList.add('reveal', 'selected'); + const th = document.createElement('th'); + th.className = 'assign-cell'; + const lineText = assignmentLine(source, row.name); + if (ri <= reveal) { + const b = document.createElement('button'); + b.className = 'assign-btn'; + b.type = 'button'; + b.textContent = lineText; + b.title = 'Show this line in the program'; + b.onclick = () => focusAssignment(ri); + th.append(b); + } else { + th.textContent = lineText; + th.classList.add('dim'); + } + tr.append(th); + row.values.forEach(value => { + const td = tr.insertCell(); + if (ri > reveal) { + td.textContent = '·'; + td.classList.add('dim'); + } else { + td.textContent = typeof value === 'boolean' ? (value ? '✓' : '·') : value; + td.onclick = () => focusAssignment(ri); + } + }); + }); + + selected = Math.max(0, reveal); + const row = trace.rows[selected]; + $('row-label').textContent = reveal < 0 + ? 'Waiting…' + : `${selected + 1} / ${trace.rows.length}`; + renderExecProgram(reveal >= 0 ? row.name : null); + updateCommandLink(reveal >= 0 ? row.name : null); + + $('prev-row').disabled = reveal <= 0; + $('next-row').disabled = reveal >= trace.rows.length - 1; + $('replay-trace').disabled = !trace; + $('skip-trace').disabled = done; + + if (done) { + $('result').textContent = trace.accepted ? 'Accepted' : 'Rejected'; + $('result').className = `result ${trace.accepted ? 'accepted' : 'rejected'}`; + $('download-trace').disabled = false; + $('acceptance-note').textContent = `Accept/reject is the last cell of the final Boolean row (${trace.rows.at(-1).name}).`; + } else { + $('result').textContent = reveal < 0 ? '' : `Executing ${row.name}…`; + $('result').className = 'result'; + $('download-trace').disabled = true; + $('acceptance-note').textContent = ''; + } +} + +function startPlayback(data, {animate = true, fromIndex = 0} = {}) { + stopPlayback(); + trace = data; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (!animate || reduceMotion || !data.rows.length) { + playIndex = Math.max(0, data.rows.length - 1); + paintTrace(); + syncRunButton(); + return; + } + playIndex = Math.max(0, Math.min(fromIndex, data.rows.length - 1)); + paintTrace(); + status(fromIndex > 0 ? 'Resumed…' : 'Playing execution…'); + if (playIndex >= data.rows.length - 1) { + status('Evaluated by the pinned Python interpreter.'); + syncRunButton(); + return; + } + playTimer = setInterval(advancePlaybackTick, PLAY_MS); + syncRunButton(); +} + +function renderTrace(data, opts) { + startPlayback(data, opts); +} + +function selectRow(i) { + if (!trace) return; + stopPlayback(); + playIndex = Math.max(0, Math.min(i, trace.rows.length - 1)); + selected = playIndex; + paintTrace(); +} + +function assignmentLine(source, name) { + const i = lineIndexForAssignment(source, name); + if (i < 0) return name; + return source.split('\n')[i].trim() || name; +} + +function assignmentRhs(source, name) { + const line = assignmentLine(source, name); + const eq = line.indexOf('='); + return eq < 0 ? line : line.slice(eq + 1).trim(); +} + +/** Map a program line to the C-RASP command kinds from §02. */ +function describeCommands(name) { + const source = lastRequest?.source || currentProgramSource(); + const rhs = assignmentRhs(source, name); + if (!rhs) { + return `${name}: could not find this assignment in the program text.`; + } + const parts = []; + if (/#/.test(rhs)) parts.push('prefix count (#)'); + if (/"[^"]*"/.test(rhs)) parts.push('input marker'); + if (/\bif\b/.test(rhs)) parts.push('conditional'); + if (/\bmin\s*\(/.test(rhs)) parts.push('min'); + if (/\bmax\s*\(/.test(rhs)) parts.push('max'); + if (/(<=|>=|==|!=|<|>)/.test(rhs)) parts.push('count comparison'); + if (/&&/.test(rhs)) parts.push('conjunction (and)'); + if (/\|\|/.test(rhs)) parts.push('disjunction (or)'); + if (/!(?!=)/.test(rhs)) parts.push('negation'); + if (/\+/.test(rhs)) parts.push('addition'); + if (/(?=!])-(?!>)/.test(rhs)) parts.push('subtraction'); + if (/^\d+$/.test(rhs) || /^(true|false)$/i.test(rhs)) parts.push('constant'); + const uniq = [...new Set(parts)]; + if (!uniq.length) { + return `${name} = ${rhs} — combines earlier vectors with the allowed Boolean / count operations.`; + } + return `${name} uses: ${uniq.join(' · ')} — same command kinds as in “What is C-RASP.”`; +} + +function updateCommandLink(name) { + const el = $('command-link'); + if (!name) { + el.textContent = 'After Run, the active line is highlighted above and this note names the C-RASP command behind it.'; + return; + } + el.textContent = describeCommands(name); +} + +$('prev-row').onclick = () => selectRow(playIndex - 1); +$('next-row').onclick = () => { + stopPlayback(); + selectRow(playIndex + 1); +}; +$('replay-trace').onclick = () => { + if (!trace) return; + startPlayback(trace, {animate: true}); +}; +$('skip-trace').onclick = () => { + if (!trace) return; + stopPlayback(); + playIndex = trace.rows.length - 1; + paintTrace(); + status('Evaluated by the pinned Python interpreter.'); +}; +$('run').onclick = () => { + // Pause animation or cancel an in-flight interpreter run + if (playTimer) { + stopPlayback(); + status('Paused — press play to resume from this step.'); + return; + } + if (running) { + stop(); + status('Interpreter run cancelled.'); + return; + } + // Resume paused playback without re-running the interpreter + if (canResumePlayback()) { + resumePlayback(); + return; + } + // Replay finished trace from the start (same program/input) + if (trace && inputUnchanged() && playIndex >= trace.rows.length - 1) { + startPlayback(trace, {animate: true, fromIndex: 0}); + return; + } + + let request; + try { + setProgramEditing(false); + setTokensEditing(false); + request = {source: currentProgramSource(), 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; + syncRunButton(); + $('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; + if (data.error) { + syncRunButton(); + status(data.error, true); + worker.terminate(); + worker = undefined; + } else { + lastRequest = request; + lastRequest.tokens = request.tokens; + renderTrace(data.result); + 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); +}; +syncRunButton(); +$('program').oninput = markStale; +$('tokens').oninput = markStale; +function reset() { + stop(); + const e = examples[$('example').value]; + $('tokens').value = e.tokens; + $('program').value = e.source; + builderLines = []; + setProgramEditing(false); + setTokensEditing(false); + markStale(); + $('command-link').textContent = 'Example restored. Run to step through each line and see which C-RASP commands it uses.'; +} +$('example').onchange = reset; +$('reset').onclick = reset; +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 = () => { + if (!trace || !lastRequest) return; + download('crasp-trace.json', { + ...trace, + program: lastRequest.source, + interpreter_commit: '1e821d7279015ce0e77830faa7932575447c5164', + }); +}; + +$('download-trace').disabled = true; +$('prev-row').disabled = true; +$('next-row').disabled = true; +$('replay-trace').disabled = true; +$('skip-trace').disabled = true; +$('row-label').textContent = '—'; +status(''); +$('command-link').textContent = ''; +$('acceptance-note').textContent = ''; +$('result').textContent = ''; +$('result').className = 'result'; +showTraceUI(false); +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; + setProgramEditing(false); + markStale(); + openSection('playground'); + } catch (e) { + status(`Could not load shared program: ${e.message}`, true); + } +} else { + // Load tokens + program from the preselected example + const e = examples[$('example').value] || examples.dyck; + $('tokens').value = e.tokens; + $('program').value = e.source; + setTokensEditing(false); + setProgramEditing(false); + renderExecProgram(null); +} + diff --git a/tutorial_dual/static/web-assets/interactive/version2/assets/lib.js b/tutorial_dual/static/web-assets/interactive/version2/assets/lib.js new file mode 100644 index 0000000..c341ad6 --- /dev/null +++ b/tutorial_dual/static/web-assets/interactive/version2/assets/lib.js @@ -0,0 +1,32 @@ +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/version2/assets/style.css b/tutorial_dual/static/web-assets/interactive/version2/assets/style.css new file mode 100644 index 0000000..1ef6e6f --- /dev/null +++ b/tutorial_dual/static/web-assets/interactive/version2/assets/style.css @@ -0,0 +1,79 @@ +: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:.55rem .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:.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:.08em;font-size:.8rem;font-weight:750}header nav{display:flex;gap:24px;font-size:.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}.hero-solo{display:block;max-width:min(1280px,100%);width:100%;padding:96px 35px 72px;box-sizing:border-box;margin-left:auto;margin-right:auto} +.hero-solo h1,.hero-solo .dek,.hero-solo .meta{max-width:none;width:100%} +.hero-solo .dek{font-size:1.28rem;line-height:1.65}.program-block{margin:0 0 20px;padding:10px 0;background:var(--pale);border:1px solid var(--line);font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:auto}.program-line{padding:2px 18px;white-space:pre}.program-line.active{background:#d9e8df;box-shadow:inset 3px 0 0 var(--teal);font-weight:600} +.exec-program{margin:0;border:0;background:transparent;flex:1;min-width:0}.tokens-field{flex:1;min-width:180px;display:flex;flex-direction:column;gap:5px}.tokens-field-row{display:flex;align-items:center;gap:0;border:1px solid var(--line);border-radius:4px;background:var(--pale);padding:0 4px 0 0;overflow:hidden}.tokens-field-row input[type=text]{flex:1;border:0!important;background:transparent!important;padding:10px 12px;min-width:0;border-radius:0;color:var(--ink);width:auto}.tokens-field-row input[readonly]{cursor:default}.tokens-field-row input:focus,.tokens-field-row input:focus-visible{outline:none!important;box-shadow:none}.tokens-field-row:focus-within{outline:3px solid var(--accent);outline-offset:1px} +button.inline-edit{border:0;background:transparent;color:var(--muted);padding:6px 8px;margin:0;border-radius:3px;display:inline-flex;align-items:center;justify-content:center;line-height:1;cursor:pointer;flex-shrink:0;box-shadow:none}button.inline-edit:hover{background:transparent;color:var(--ink)}button.inline-edit:focus-visible{outline:2px solid var(--accent);outline-offset:1px}button.inline-edit-done{font-size:.75rem;font-weight:650;color:var(--teal);padding:6px 10px}button.inline-edit-done:hover{color:var(--ink)} +.program-view{position:relative;display:flex;align-items:flex-start;background:var(--pale)}.program-view .inline-edit{position:absolute;top:8px;right:8px;z-index:1}.program-view .exec-program{padding-right:40px;min-height:3.2em;width:100%;margin:0;border:0;background:transparent} +.program-panel.editing .program-view{display:none} +.program-panel.editing .program-actions-run{display:none!important} +.program-panel:not(.editing) .program-actions-builder{display:none!important} +.program-panel{margin:12px 0 8px;border:1px solid var(--line);background:var(--white)}.program-panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line)}.program-panel-head .small{margin:0;flex:1}.icon-btn{padding:.35rem .55rem;font-size:.8rem;display:inline-flex;align-items:center;justify-content:center;gap:.35rem;line-height:1}.icon-btn svg{display:block}.program-panel-actions{display:flex;align-items:center;gap:6px} +#commit-program{font-size:.8rem;font-weight:650;padding:.4rem .85rem} +.icon-btn-primary{background:var(--ink);color:white;border-color:var(--ink)}.icon-btn-primary:hover:not(:disabled){background:var(--teal);border-color:var(--teal);color:white}.icon-btn-primary:disabled{opacity:.55} +#run .icon-play,#run .icon-stop{display:block}#run[data-mode="play"] .icon-stop{display:none!important}#run[data-mode="stop"] .icon-play{display:none!important} +#run[data-mode="stop"]{background:var(--accent);border-color:var(--accent)}#run[data-mode="stop"]:hover:not(:disabled){background:#8f3a22;border-color:#8f3a22}.program-panel textarea{display:block;width:100%;border:1px solid var(--line);border-radius:3px;min-height:140px;padding:14px 18px;font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;margin-top:10px}.program-panel.editing .exec-program{display:none}.program-panel:not(.editing) .program-builder{display:none!important} +/* Expand the whole notebook to the right only while editing (keep left edge fixed) */ +#playground .lab:has(.program-panel.editing){width:calc(100% + min(20vw, 160px));max-width:none;margin-left:0;margin-right:calc(-1 * min(20vw, 160px))} +.program-builder{padding:16px 18px 20px;background:var(--white)}.builder-hint{margin:0 0 14px;font-size:.92rem;line-height:1.55;color:var(--ink)} +.builder-layout{display:grid;grid-template-columns:minmax(160px,190px) 48px minmax(0,1.15fr) minmax(180px,1fr);gap:10px;align-items:stretch;min-height:320px} +.builder-col-title{font-size:.68rem;font-weight:750;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:0 0 8px} +.builder-palette{display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--pale);border:1px solid var(--line);border-radius:4px;min-height:0} +.builder-palette-note{margin:0 0 4px;font-size:.78rem;font-weight:650;color:var(--teal)}.builder-palette-more{margin:4px 0 0;line-height:1.4} +.palette-block{text-align:left;padding:10px 10px;background:var(--white);border:1px solid var(--line);border-radius:4px;color:var(--ink);cursor:grab;font-size:.8rem;line-height:1.35;transition:border-color .15s,box-shadow .15s,background .15s}.palette-block:hover{border-color:var(--teal)}.palette-block:active{cursor:grabbing}.palette-block code{font-size:.72rem;color:var(--teal)}.palette-block.is-linked{border-color:var(--teal);background:#d9e8df;box-shadow:inset 3px 0 0 var(--teal)} +.builder-arrow{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;color:var(--teal);user-select:none}.builder-arrow-label{font-size:.65rem;font-weight:750;letter-spacing:.12em;text-transform:uppercase}.builder-arrow-glyph{font-size:1.85rem;line-height:1;font-weight:600} +.builder-canvas{min-height:280px;border:2px dashed #9aa9a7;border-radius:4px;padding:12px;background:#fbfaf6;display:flex;flex-direction:column;min-width:0}.builder-canvas.drag-over{border-color:var(--teal);border-style:solid;background:#eef6f2;box-shadow:inset 0 0 0 2px rgba(36,109,102,.15)} +.builder-empty{padding:36px 16px;text-align:center;color:var(--muted);font-size:.95rem;line-height:1.5}.builder-empty[hidden]{display:none!important} +.builder-lines{display:flex;flex-direction:column;gap:8px;flex:1;overflow:auto} +.builder-card{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:start;padding:10px;background:var(--white);border:1px solid var(--line);border-radius:4px;cursor:pointer;transition:border-color .15s,box-shadow .15s,background .15s}.builder-card.dragging{opacity:.5}.builder-card.is-selected{border-color:var(--teal);background:#eef6f2;box-shadow:inset 3px 0 0 var(--teal)} +.builder-card-handle{cursor:grab;color:var(--muted);padding:6px 2px;user-select:none;font-size:1rem;line-height:1}.builder-card-body{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:.85rem} +.builder-card-body label{display:flex;flex-direction:column;gap:3px;font-size:.68rem;color:var(--muted)}.builder-card-body input,.builder-card-body select{font:13px ui-monospace,SFMono-Regular,Consolas,monospace;padding:6px 8px;border:1px solid #a4b0b1;border-radius:3px;min-width:0;max-width:110px;background:white;color:var(--ink)} +.builder-card-body .builder-preview{flex-basis:100%;font:12px/1.5 ui-monospace,Consolas,monospace;color:var(--teal);margin:0} +.builder-card-remove{border:0;background:transparent;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 6px;line-height:1}.builder-card-remove:hover{color:var(--accent)} +.builder-source-panel{display:flex;flex-direction:column;min-width:0;min-height:280px;border:1px solid var(--line);border-radius:4px;background:var(--pale);overflow:hidden} +.builder-source-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);background:var(--white);flex-wrap:wrap}.builder-source-head .builder-col-title{margin:0} +.builder-source{margin:0;padding:8px 0;flex:1;overflow:auto;font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--ink);background:transparent}.builder-source:empty::before{content:"(empty — changes in Your program appear here)";color:var(--muted);display:block;padding:14px 16px}.builder-source-line{padding:2px 16px;white-space:pre-wrap;word-break:break-word;cursor:pointer}.builder-source-line:hover{background:rgba(36,109,102,.08)}.builder-source-line.is-active{background:#d9e8df;box-shadow:inset 3px 0 0 var(--teal);font-weight:600} +.builder-commit-status{font-size:.75rem;font-weight:650;text-align:right}.builder-commit-status.is-ok{color:var(--teal)}.builder-commit-status.is-bad{color:var(--accent)}.builder-commit-status.is-pending{color:var(--muted)} +.builder-commit-help{margin:14px 0 0;padding-top:12px;border-top:1px solid var(--line)} +.builder-advanced{margin-top:14px;border-top:1px solid var(--line);padding-top:10px}.builder-advanced summary{cursor:pointer;font-size:.85rem;font-weight:650;color:var(--muted)} +@media(max-width:900px){.builder-layout{grid-template-columns:1fr;min-height:0}.builder-arrow{flex-direction:row;padding:2px 0}.builder-arrow-glyph{transform:rotate(90deg)}.builder-source-panel,.builder-canvas{min-height:200px}#playground .lab:has(.program-panel.editing){width:100%;margin-right:0}}.walk-controls{align-items:center;justify-content:space-between}.walk-controls #walk-step-label{flex:1;text-align:center}.walk-trace td.dim{opacity:.28}.walk-trace tr.reveal td:not(.dim){font-weight:600}.eyebrow,.section-number{font-size:.72rem;font-weight:750;letter-spacing:.15em;text-transform:uppercase;color:var(--accent)}h1{font:normal clamp(3rem,5.1vw,5.4rem)/1.06 var(--serif);letter-spacing:-.04em;margin:22px 0 25px}h1 em{font-weight:normal;color:var(--teal)}.dek{font:1.18rem/1.7 var(--serif);max-width:none}.meta{font-size:.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:.7rem;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}.layout{display:grid;grid-template-columns:185px minmax(0,1fr);gap:55px;max-width:min(1280px,100%);padding:0 35px;margin:auto;width:100%;box-sizing:border-box}.toc{position:sticky;top:35px;align-self:start;border-top:1px solid var(--line);padding-top:20px;font-size:.82rem}.toc strong{font-size:.68rem;letter-spacing:.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:-.025em;margin:12px 0 24px} +.chapter{border-top:1px solid var(--line);padding:0;margin:0} +.chapter-summary{display:grid;grid-template-columns:1fr auto;grid-template-rows:auto auto;gap:6px 16px;align-items:center;cursor:pointer;list-style:none;padding:28px 0;user-select:none} +.chapter-summary::-webkit-details-marker{display:none} +.chapter-summary .section-number{grid-column:1;margin:0} +.chapter-title{grid-column:1;font:normal 1.85rem/1.2 var(--serif);letter-spacing:-.02em;color:var(--ink)} +.chapter-marker{grid-column:2;grid-row:1 / span 2;width:1.6rem;height:1.6rem;border:1px solid var(--line);border-radius:50%;position:relative;flex-shrink:0} +.chapter-marker::before,.chapter-marker::after{content:"";position:absolute;background:var(--ink);left:50%;top:50%;transform:translate(-50%,-50%)} +.chapter-marker::before{width:10px;height:1.5px} +.chapter-marker::after{width:1.5px;height:10px} +.chapter[open] .chapter-marker::after{display:none} +.chapter[open] .chapter-summary{padding-bottom:14px} +.chapter-body{padding:0 0 36px;max-width:none;width:100%} +.chapter-body > p:first-child{margin-top:0} +.chapter-nav{margin-top:28px;padding-top:8px} +.toc a.is-active{color:var(--accent);font-weight:650} +.ops-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin:18px 0 8px;padding:18px 20px;background:var(--pale);border:1px solid var(--line)} +.ops-grid h4{margin:0 0 10px;font:650 .85rem/1.3 var(--sans);letter-spacing:.04em;text-transform:uppercase;color:var(--teal)} +.ops-list{margin:0;padding-left:1.1rem;font-size:.92rem;color:var(--muted)} +.ops-list li{margin:6px 0} +.ops-list strong{color:var(--ink);font-weight:650} +.chapter-body h3{font:600 1.05rem/1.35 var(--sans);margin:28px 0 10px} +@media(max-width:650px){.ops-grid{grid-template-columns:1fr}} + +@media(max-width:650px){.chapter-title{font-size:1.45rem}.chapter-summary{padding:22px 0}} +h3{font:600 1.1rem/1.4 var(--sans);margin:25px 0 12px}p{margin:0 0 20px}article>section>p{max-width:none}.lede{font:1.3rem/1.6 var(--serif)}.badge{display:inline-block;padding:2px 9px;font-size:.66rem;letter-spacing:.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:.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:.9rem;color:var(--muted)}details{border-block:1px solid var(--line);margin:22px 0;padding:13px 0}summary{cursor:pointer;font-size:.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:.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:.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:.9rem}.small,.caption{font-size:.8rem;color:var(--muted);line-height:1.6}.status{font-size:.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 th.assign-cell{max-width:280px;white-space:normal;vertical-align:middle} +.trace .assign-btn{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:3px;padding:4px 6px;color:inherit;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;cursor:pointer;white-space:normal;word-break:break-word} +.trace .assign-btn:hover{background:var(--pale);color:var(--teal)} +.trace tr.selected th.assign-cell{background:#e4eee8} +.trace tr.selected .assign-btn{font-weight:650;color:var(--ink)}.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:.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} +.trace-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:12px 0 10px;flex-wrap:wrap} +.trace-toolbar{display:inline-flex;align-items:center;gap:4px;padding:4px;border:1px solid var(--line);border-radius:6px;background:var(--white)} +.trace-toolbar .icon-btn{border-color:transparent;background:transparent;padding:.4rem} +.trace-toolbar .icon-btn:hover:not(:disabled){background:var(--pale)} +.trace-step{min-width:7.5rem;text-align:center;padding:0 .4rem;font-variant-numeric:tabular-nums} +.trace-panel{margin-top:4px}.trace-empty{padding:36px 20px;border:1px dashed #9aa9a7;text-align:center;color:var(--muted);font-size:.92rem;line-height:1.55;background:var(--white)}.trace-empty[data-mode="builder"]{background:var(--pale);border-color:#8aa39a;color:var(--ink)}.trace-empty[data-mode="builder"] strong{color:var(--teal)}.trace-panel .interaction-guide{margin:0 0 12px}.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:.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:.9rem}.file-input{max-width:100%;font-size:.8rem}.run-meta{font-size:.8rem;background:var(--pale);padding:16px;white-space:pre-wrap}.refs{font-size:.88rem}.refs li{margin:15px 0}.glossary dt{font-weight:bold}.glossary dd{margin:0 0 18px;color:var(--muted);font-size:.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:.75rem;color:var(--muted)}.download{font-size:.8rem}.noscript{padding:20px;background:#eed7c4}.slider{accent-color:var(--teal);width:100%}code{font-size:.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:min(1280px,100%)}.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-solo{padding:56px 24px 48px}.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-solo{padding:40px 24px 36px}.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}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:none}.trace th.contributes{box-shadow:inset 0 -3px var(--accent)} + +.interaction-guide{background:var(--pale);padding:14px 16px;font-size:.9rem;line-height:1.65}.token.masked{opacity:.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} diff --git a/tutorial_dual/static/web-assets/interactive/version2/data/interpreter.json b/tutorial_dual/static/web-assets/interactive/version2/data/interpreter.json new file mode 100644 index 0000000..6a65c07 --- /dev/null +++ b/tutorial_dual/static/web-assets/interactive/version2/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/version2/index.html b/tutorial_dual/static/web-assets/interactive/version2/index.html new file mode 100644 index 0000000..402b3fc --- /dev/null +++ b/tutorial_dual/static/web-assets/interactive/version2/index.html @@ -0,0 +1,319 @@ + + + + + + + C-RASP · Version 2 · Length generalization + + + + + +
+ C-RASP / VERSION 2 + +
+
+
+
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 +
+ + + +
+ +
+
+

+                  
+                
+ +
+

Pinned coli-saar/c-rasp interpreter · positions start at 0

+

+ +
+
+ + + + + + +
+ +
+ +
+
+ Nothing has run yet. Press play to fill this area with the execution trace. +
+ + +

+
+
+
+ +
+ +
+
+
+ + +
+
+
+ + + diff --git a/tutorial_dual/static/web-assets/interactive/version2/runtime/bridge.py b/tutorial_dual/static/web-assets/interactive/version2/runtime/bridge.py new file mode 100644 index 0000000..2bb47ac --- /dev/null +++ b/tutorial_dual/static/web-assets/interactive/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/tutorial_dual/static/web-assets/interactive/version2/runtime/python-worker.js b/tutorial_dual/static/web-assets/interactive/version2/runtime/python-worker.js new file mode 100644 index 0000000..17db80d --- /dev/null +++ b/tutorial_dual/static/web-assets/interactive/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/tutorial_dual/static/web-assets/scripts/article.js b/tutorial_dual/static/web-assets/scripts/article.js new file mode 100644 index 0000000..377a16d --- /dev/null +++ b/tutorial_dual/static/web-assets/scripts/article.js @@ -0,0 +1,32 @@ +const explanations = [...document.querySelectorAll('details.explanation')]; +document.getElementById('reading-tools')?.removeAttribute('hidden'); +for (const [id, open] of [['expand-details', true], ['collapse-details', false]]) { + document.getElementById(id)?.addEventListener('click', () => explanations.forEach(d => { d.open = open; })); +} +// Reveal targets inside a folded explanation, including references from the contents. +function revealFragment() { + let id; + try { id = decodeURIComponent(location.hash.slice(1)); } catch { return; } + const target = document.getElementById(id); + for (let p = target; p; p = p.parentElement) if (p.tagName === 'DETAILS') p.open = true; + if (target) requestAnimationFrame(() => target.scrollIntoView()); +} +addEventListener('hashchange', revealFragment); +revealFragment(); +const frame = document.querySelector('.notebook-frame'); +frame?.addEventListener('load', () => { + // Assets are served alongside the article: no external embedding service is needed. + const doc = frame.contentDocument; + if (!doc) return; + const style = doc.createElement('style'); + style.textContent = `body.notebook-page { min-width:0; margin:0; } + body.notebook-page > header, .skip, .notebook-intro, .design-notes { display:none !important; } + .notebook-shell { width:100%; max-width:none; padding:12px; margin:0; } + .experiment { margin-bottom:0; }`; + doc.head.append(style); + const resize = () => { frame.style.height = `${Math.ceil(doc.body.getBoundingClientRect().height) + 24}px`; }; + new ResizeObserver(resize).observe(doc.body); + resize(); +}); +addEventListener('beforeprint', () => explanations.forEach(d => { d.dataset.wasOpen = String(d.open); d.open = true; })); +addEventListener('afterprint', () => explanations.forEach(d => { d.open = d.dataset.wasOpen === 'true'; })); diff --git a/tutorial_dual/tests/notebook.test.mjs b/tutorial_dual/tests/notebook.test.mjs new file mode 100644 index 0000000..cf2118e --- /dev/null +++ b/tutorial_dual/tests/notebook.test.mjs @@ -0,0 +1,70 @@ +// Adapted from the upstream notebook tests; see ../UPSTREAM.md. +import test from "node:test"; +import assert from "node:assert/strict"; +import { + DYCK, + CONTAINS, + BOTH_SYMBOLS, + A_BEFORE_B, + compile, + validateProgram, + moveBlock, +} from "../static/web-assets/interactive/assets/notebook-model.js"; +import { evaluateBlocks } from "../static/web-assets/interactive/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); + assert.equal(evaluateBlocks(DYCK, [..."())("], "Accept").accepted, false); + 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/tola.toml b/tutorial_dual/tola.toml new file mode 100644 index 0000000..965e393 --- /dev/null +++ b/tutorial_dual/tola.toml @@ -0,0 +1,35 @@ +# Tola configuration (v0.8.1) +# https://github.com/tola-rs/tola-ssg + +# tola help "[site]" +[site] +# origin = "" +base-path = "/" +title = "Length Generalization in Transformers" +authors = [] +description = "" +languages = [] +language = "en" + +# tola help "[build]" +[build] +entry = "site.typ" +content-dir = "content" +publish-dir = "public" + +# tola help "[build.hooks]" + +# tola help "[assets]" +[assets] +cache-busting = false +trees = [{ source = "static/web-assets", url-prefix = "/assets" }] +files = [] + +# tola help "[vendor]" +[vendor] +path = "vendor" + + +# Preserve upstream ES modules verbatim (some use top-level await). +[build.minify] +javascript = false