Skip to content

Commit 2ee6797

Browse files
feat: start the course from zero — control flow, a step-by-step loop viewer, and the foundations
- engine: if/elif/else, for, while, break/continue, and range(); string literals become heap objects so every str operation is canvas-visible - engine: record each statement and loop iteration into state.trace so a run can be replayed (ADR 003) - board: a transport bar (first/prev/step/last) scrubs the recorded run, so a for/while loop can be watched iteration by iteration - levels: three new pure-Python series — First Steps, Decisions, Repeating (23 levels) — and a reordered catalog: basics -> flow -> loops -> functions, then the memory model, then the libraries (78 levels / 16 series); prerequisites are respected (def before its users, .append before the aliasing and copy series) - onboarding: welcome copy leads from zero; the levels map shows difficulty dots and a start-here marker - docs: CURRICULUM catalog + record-replay section, README subset update, ADR 003; fix the raw-string 'in' fallback that tsc caught
1 parent 7ca1000 commit 2ee6797

16 files changed

Lines changed: 1481 additions & 92 deletions

‎README.md‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Type Python-subset statements. Meta-commands:
4141
| Command | Effect |
4242
|---------|--------|
4343
| `help` | subset + meta overview |
44-
| `levels` | list the 40 levels / 10 series |
44+
| `levels` | list the 78 levels / 16 series |
4545
| `level <id>` | load a level (e.g. `level first-name`) |
4646
| `lesson` | open the current level's lesson text |
4747
| `goal` / `guide` | focus the right-hand guide panel |
@@ -60,17 +60,20 @@ A deliberately small, pedagogically sharp subset — not CPython:
6060

6161
- **Literals & types:** `int`, `float`, `str`, `bool`, `None`; `list`, `dict`, `tuple`, `set`
6262
- **Names:** assignment, rebinding, augmented assign, indexing, slicing
63+
- **Control flow:** `if` / `elif` / `else`, `for x in …`, `while …`, `break`, `continue`; `range(n)` / `range(a, b)` / `range(a, b, step)`
6364
- **Methods:** `.append` `.insert` `.extend` `.pop` `.remove` `.copy` `.get` `.update` `.keys` `.clear`
6465
- **Functions:** `def` / call / `return`, local frames, nested calls
6566
- **Builtins:** `print` `len` `type` `id` `str` `int` `bool` `list` `dict` `set`
66-
- **Modules:** `import copy as cp` — `cp.copy` (shallow) and `cp.deepcopy`; `import collections as c` — `c.defaultdict`; `import functools as f` — `f.reduce`; `import numpy as np` — `np.array`, `.copy()`, slicing (views), `+`, `.sum()`
67+
- **Modules:** `import copy as cp` — `cp.copy` (shallow) and `cp.deepcopy`; `import collections as c` — `c.defaultdict`; `import functools as f` — `f.reduce`; `import numpy as np` — `np.array`, `.copy()`, slicing (views), `+`, `.sum()`; `import pandas as pd` — `pd.DataFrame`, `pd.Series`, `pd.read_csv`; `import asyncio as a`, `import threading as t`
6768
- **Iterators:** `iter(x)` and `next(it)` — a stateful iterator object you advance by hand
6869
- **Functional:** `map(fn, it)` / `filter(pred, it)` — lazy iterator objects; `f.reduce(fn, it)` — an eager fold
6970
- **NumPy:** `np.array` → an ndarray *window* over a separate **buffer** box; `.copy()` mints a new buffer; a slice is a *view* sharing the same buffer; `a + 1` vectorizes into a new buffer; `a.sum()` folds to a scalar
7071
- **Plotting:** `scatter(xs, ys, "title")` `line(xs, ys, "title")`
71-
- **Operators:** arithmetic, comparisons, `is`, `==`
72+
- **Operators:** arithmetic, comparisons, `is`, `==`, `and` / `or` / `not`, `in` / `not in`
7273

73-
Not in v0: classes, real module imports (only the simulated `copy`, `collections`, `functools`, `numpy`), control flow (`if`/`for`/`while`), exceptions (beyond clean parse/runtime errors), comprehensions, I/O, async, `range`/`sorted`/`min`/`max`, full CPython semantics.
74+
**Watch it run:** every run records a step trace; the transport bar under the board tabs (⏮ ◀ ▶ ⏭) scrubs it, so a `for`/`while` loop can be watched iteration by iteration on the canvas.
75+
76+
Not in v0: classes, real module imports (only the simulated `copy`, `collections`, `functools`, `numpy`, `pandas`, `asyncio`, `threading`), exceptions (beyond clean parse/runtime errors), comprehensions, real I/O, full CPython semantics.
7477

7578
A planned curriculum extends this with pandas, re, datetime and more — all drawn on the same memory canvas. See `docs/ENGINE-EXTENSION.md` and `docs/adr/002-library-modules.md`.
7679

@@ -79,7 +82,7 @@ A planned curriculum extends this with pandas, re, datetime and more — all dra
7982
```
8083
src/engine/ simulated runtime (tokenizer, parser, evaluator, memory, session)
8184
+ goal checks (checks.ts) — the "TreeCompare" analogue
82-
src/levels/ 10-series / 40-level catalog + per-level goals, par, solutions
85+
src/levels/ 16-series / 78-level catalog + per-level goals, par, solutions
8386
src/ui/ app shell, memory canvas, plot board, REPL, dialog, script editor
8487
src/style.css design tokens + layout
8588
tests/ vitest unit + level-solution tests
@@ -91,7 +94,23 @@ The engine is intentionally a **teaching subset**, not CPython. Every state tran
9194

9295
## Levels
9396

94-
Ten series — Names & Bindings, Aliasing & Copy, Containers in Memory, **Copy Semantics** (shallow vs deep), **Iterators & Iterables**, **Defaults & Functional** (defaultdict, map, filter), **NumPy: Buffers & Views** (ndarray, buffer, `.copy()`, slice-as-view), Mutation & Methods, Functions & Frames, Plotting & Capstone. Each of the 40 levels has a brief, optional hint, a goal (predicate or target snapshot), a statement-par for golf, and a worked solution.
97+
Sixteen series, 78 levels. The course now starts **from zero**: a pure-Python
98+
spine — **First Steps** (print, values, names, math, strings), **Decisions**
99+
(`if`/`elif`/`else`, booleans, comparisons), **Repeating** (`for`/`range`,
100+
`while`, building data, and a "watch the loop run" level), and **Functions &
101+
Frames** — reached before any memory-model vocabulary. Only then does the
102+
memory model arrive: **Names & Bindings**, **Containers in Memory**, **Mutation
103+
& Methods**, **Aliasing & Copy**, **Copy Semantics** (shallow vs deep),
104+
**Iterators & Iterables**, **Defaults & Functional** (defaultdict, map, filter).
105+
Then the data libraries — **NumPy: Buffers & Views**, **Pandas: Tables as
106+
windows over a buffer** — and concurrency — **Asyncio** (coroutines, tasks, the
107+
event loop) and **Threading** (threads, shared state, the data race) — closing
108+
with **Plotting & Capstone**.
109+
110+
Each level has a brief, optional hint, a goal (predicate or target snapshot), a
111+
statement-par for golf, and a worked solution. Every level's own solution is
112+
verified to solve its goal within `par + 2` statements, so the whole catalog is
113+
proven runnable, not just compilable.
95114

96115
## Tech
97116

‎docs/CURRICULUM.md‎

Lines changed: 118 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,9 @@ what is simulated:
118118

119119
- `pd.read_csv(name, data)` builds a real DataFrame; the *content* is supplied
120120
(there is no disk), the *structure* is the lesson.
121-
- A view mutation is real in-model (it writes the shared buffer); a real
122-
`for`-loop is out of the subset (consumption is `next()`-driven and visible).
121+
- A view mutation is real in-model (it writes the shared buffer). A real
122+
`for`/`while` loop *is* in the subset now, and each of its iterations is
123+
recorded so the learner can watch it step by step on the canvas (§2.8).
123124
- Every deep lesson names where the model stops and CPython takes over.
124125

125126
This is a feature, not a defect: it forces the learner to see the object graph,
@@ -196,30 +197,122 @@ Every series ships the same way: **engine → canvas → predicates → tests
196197
with `npx tsc --noEmit` + `npm test` (and the browser) green at the end of each
197198
step. The numpy and pandas series are the two worked references.
198199
200+
### 2.8 The step-recording hook (watch it run)
201+
202+
The canvas is the signature feature, and the one thing a static end-state can't
203+
teach well is a **loop**. Rather than build a full program-counter interpreter,
204+
the evaluator records a *trace*: when trace capture is on, after every top-level
205+
statement and after every loop iteration it appends a `cloneState(state)` snapshot
206+
to `state.trace` (capped at `TRACE_CAP = 60` frames so a runaway `while` can't
207+
explode memory; the growing array is stripped out of each clone so frames never
208+
nest).
209+
210+
- `setTraceEnabled(on)` / `isTraceEnabled()` in `evaluator.ts` toggle capture;
211+
the UI turns it on once at startup, so every run is scrubbable.
212+
- `MemoryState.trace?: MemoryState[]` holds the frames. Frame 0 is the state
213+
*before* the run; frame N is after the Nth recorded step.
214+
- `snapshot()`, goals, and predicates never read `trace` — it is a pure replay
215+
aid. `recordStep` is a no-op when capture is off, so tests and the solvability
216+
gate are unaffected.
217+
218+
The **stepper** (the transport bar ⏮ ◀ ▶ ⏭ under the board tabs, `src/ui/board.ts`)
219+
shows up after a run with two or more frames and lets the learner scrub; App paints
220+
the selected frame through the existing `renderMemory`/`renderRam`. This is what
221+
makes the `watch-the-loop` level real: the same code runs, and the learner steps
222+
through it to *see* each iteration change the canvas.
223+
199224
---
200225
201226
## 3. The catalog (current)
202227
203-
**55 levels across 13 series.** Series are in teaching order. `band` is the
228+
**78 levels across 16 series.** Series are in teaching order. `band` is the
204229
level's ELI band.
205230
231+
The course now opens with **pure Python from zero** — `print`, values, names,
232+
math, strings, decisions (`if`/`elif`/`else`), and loops (`for`/`range`/
233+
`while`) — before any memory-model vocabulary. The memory model (names,
234+
containers, aliasing, copy) is the *second* layer, reached only after the learner
235+
has felt real control flow. Prerequisites are respected: `functions` (`def`)
236+
precedes every series that calls a function, and `mutation` (`.append`) precedes
237+
the aliasing and copy series that mutate lists.
238+
206239
| Series | Title | Levels |
207240
|--------|-------|--------|
241+
| `basics` | First Steps | 8 |
242+
| `flow` | Decisions | 8 |
243+
| `loops` | Repeating | 7 |
244+
| `functions` | Functions & Frames | 4 |
208245
| `names` | Names & Bindings | 5 |
209-
| `aliasing` | Aliasing & Copy | 4 |
210246
| `containers` | Containers in Memory | 4 |
247+
| `mutation` | Mutation & Methods | 4 |
248+
| `aliasing` | Aliasing & Copy | 4 |
211249
| `copy-semantics` | Copy Semantics | 3 |
212250
| `iterators` | Iterators & Iterables | 4 |
213251
| `functional` | Defaults & Functional | 4 |
214252
| `numpy` | NumPy: Buffers & Views | 5 |
215253
| `pandas` | Pandas: Tables as windows over a buffer | 5 |
216254
| `asyncio` | Asyncio: Coroutines, Tasks & the Event Loop | 5 |
217255
| `threading` | Threading: Threads, Shared State & the Data Race | 5 |
218-
| `mutation` | Mutation & Methods | 4 |
219-
| `functions` | Functions & Frames | 4 |
220256
| `plotting` | Plotting & Capstone | 3 |
221257
222-
### Core Python
258+
### Foundations (from zero)
259+
260+
These three series are pure Python with no memory-model vocabulary: the learner
261+
writes `print`, names values, does math, and builds strings, then makes decisions
262+
and repeats. The canvas is present but the story is the code, not the graph.
263+
264+
**`basics` — First Steps**
265+
266+
| id | band | name |
267+
|----|------|------|
268+
| `hello-world` | eli5 | Hello, world |
269+
| `first-variable` | eli5 | Save a value |
270+
| `change-a-value` | eli5 | Change a value |
271+
| `do-math` | eli10 | Do some math |
272+
| `build-up` | eli10 | Build a value up |
273+
| `your-name` | eli5 | A word in a box |
274+
| `combine-words` | eli10 | Join two words |
275+
| `how-long` | eli10 | How long is it? |
276+
277+
**`flow` — Decisions**
278+
279+
| id | band | name |
280+
|----|------|------|
281+
| `true-or-false` | eli10 | True or False |
282+
| `is-equal` | eli10 | Ask if equal |
283+
| `is-bigger` | eli10 | Bigger or smaller |
284+
| `first-if` | eli10 | Your first if |
285+
| `if-else` | eli10 | If … else |
286+
| `elif-ladder` | eli15 | Picking with elif |
287+
| `and-or` | eli15 | And, or, not |
288+
| `membership` | eli15 | Is it in there? |
289+
290+
**`loops` — Repeating**
291+
292+
| id | band | name |
293+
|----|------|------|
294+
| `count-with-range` | eli10 | Count with a loop |
295+
| `range-start-stop` | eli10 | A range with a start |
296+
| `loop-a-string` | eli10 | Loop over a word |
297+
| `while-loop` | eli15 | Loop until done |
298+
| `loop-with-if` | eli15 | Loop plus a decision |
299+
| `break-out` | eli15 | Stop early with break |
300+
| `watch-the-loop` | eli15 | Watch the loop run |
301+
302+
`watch-the-loop` leans on the **step-recording hook** (§2.8): after a run the
303+
learner scrubs the recorded frames with the board's transport controls (⏮ ◀ ▶
304+
⏭) and watches each loop iteration paint onto the canvas.
305+
306+
### Core Python (the memory model)
307+
308+
**`functions` — Functions & Frames**
309+
310+
| id | band | name |
311+
|----|------|------|
312+
| `print-is-not-magic` | eli5 | `print` is not magic |
313+
| `define-function` | eli10 | Define a function |
314+
| `call-and-bind` | eli10 | Call and bind |
315+
| `rebind-in-fn-is-local` | eli20 | Rebind inside a function |
223316
224317
**`names` — Names & Bindings**
225318
@@ -231,15 +324,6 @@ level's ELI band.
231324
| `values-vs-refs` | eli10 | Values vs references |
232325
| `shared-value` | eli10 | Two names, one value |
233326
234-
**`aliasing` — Aliasing & Copy**
235-
236-
| id | band | name |
237-
|----|------|------|
238-
| `two-names-one-list` | eli10 | Two names, one list |
239-
| `copy-dont-alias` | eli10 | Copy, do not alias |
240-
| `rebind-vs-mutate` | eli15 | Rebind vs mutate |
241-
| `identity-is` | eli15 | Identity: `is` |
242-
243327
**`containers` — Containers in Memory**
244328
245329
| id | band | name |
@@ -249,6 +333,24 @@ level's ELI band.
249333
| `tuple-identity` | eli10 | Tuple identity |
250334
| `set-semantics` | eli15 | Set semantics |
251335
336+
**`mutation` — Mutation & Methods**
337+
338+
| id | band | name |
339+
|----|------|------|
340+
| `grow-with-append` | eli10 | Grow with `append` |
341+
| `pop-returns` | eli15 | `pop` returns |
342+
| `dict-get-safe` | eli15 | Safe `dict.get` |
343+
| `dict-update-merge` | eli15 | `dict` update / merge |
344+
345+
**`aliasing` — Aliasing & Copy**
346+
347+
| id | band | name |
348+
|----|------|------|
349+
| `two-names-one-list` | eli10 | Two names, one list |
350+
| `copy-dont-alias` | eli10 | Copy, do not alias |
351+
| `rebind-vs-mutate` | eli15 | Rebind vs mutate |
352+
| `identity-is` | eli15 | Identity: `is` |
353+
252354
**`copy-semantics` — Copy Semantics**
253355
254356
| id | band | name |
@@ -275,24 +377,6 @@ level's ELI band.
275377
| `map-lazy` | eli15 | `map`: a lazy transform |
276378
| `filter-truthy` | eli15 | `filter`: keep what is truthy |
277379
278-
**`mutation` — Mutation & Methods**
279-
280-
| id | band | name |
281-
|----|------|------|
282-
| `grow-with-append` | eli10 | Grow with `append` |
283-
| `pop-returns` | eli15 | `pop` returns |
284-
| `dict-get-safe` | eli15 | Safe `dict.get` |
285-
| `dict-update-merge` | eli15 | `dict` update / merge |
286-
287-
**`functions` — Functions & Frames**
288-
289-
| id | band | name |
290-
|----|------|------|
291-
| `print-is-not-magic` | eli5 | `print` is not magic |
292-
| `define-function` | eli10 | Define a function |
293-
| `call-and-bind` | eli10 | Call and bind |
294-
| `rebind-in-fn-is-local` | eli20 | Rebind inside a function |
295-
296380
**`plotting` — Plotting & Capstone**
297381
298382
| id | band | name |

‎docs/adr/002-library-modules.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,7 @@ Extend the subset, not the runtime philosophy:
5353
- **Fidelity boundary is explicit and fixed:** the sandbox cannot read/write real files. I/O levels
5454
(`read_csv`, `to_excel`, `to_pickle`, h5py) model the *result object* and the *serialization
5555
meaning* (e.g. pickle = freezing the whole object graph to bytes, preserving aliasing), not real
56-
disk access. `for` is not in the subset, so iterator consumption is taught with explicit `next()` —
57-
which is *deliberately better* for this tool: each step is visible on the canvas, whereas `for`
58-
would hide consumption inside the loop.
56+
disk access. (Control flow `if`/`for`/`while` and `range` were added to the subset later — see
57+
ADR 003; the `iter`/`next` protocol is still taught explicitly so the iterator object is visible.)
5958
- Scope is capped per library to the 5-ladder "0→90" (mental model → daily API → real patterns →
6059
depth/gotchas → capstone project). See `ENGINE-EXTENSION.md` for the contract and phase plan.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# learn-python — ADR 003: Add control flow, and teach it by recording then replaying
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
The course is an interactive Python **memory model** visualizer, but the subset had no control
10+
flow: no `if`/`for`/`while`, no `range`. Two problems followed from that.
11+
12+
1. **The course started from the middle.** The simplest concepts — printing, naming a value, a
13+
decision, a loop — are what a learner with zero Python actually needs first. But they were the
14+
*last* things taught (or absent), while aliasing, numpy, and threading came early. A brand-new
15+
learner landing on level 1 was shown `stack frame` / `heap` vocabulary before they had written a
16+
single line that did anything.
17+
2. **Loops were the one thing the canvas could not show.** The signature feature is watching state
18+
change. But the interpreter ran a whole submission in one tick and drew only the *final* state.
19+
A `for` loop is fundamentally a *sequence* of states, and the final state alone loses the lesson
20+
("the same line runs again and again").
21+
22+
The fix had to be real: to start "from zero," the engine genuinely needed `if`/`for`/`while`/
23+
`range`, and to make loops visible it needed to be able to *show the intermediate states*.
24+
25+
## Decision
26+
27+
Two additive, orthogonal changes.
28+
29+
**1. Control flow in the subset.** The tokenizer already emitted the keywords and
30+
`INDENT`/`DEDENT` (the `def` block parsing relied on them), so adding control flow touched only the
31+
parser and evaluator:
32+
33+
- `parser.ts`: a `parseSuite()` helper (the indented-block loop `def` already used) is shared by
34+
`parseIf` / `parseFor` / `parseWhile`, so nested blocks recurse for free.
35+
- `evaluator.ts`: `case 'If'` / `case 'For'` / `case 'While'` / `case 'Break'` / `case 'Continue'`
36+
in `execStatement`, plus `range(n[, stop[, step]])` as a builtin. `break`/`continue` are
37+
marker exceptions caught by the enclosing loop. A `while` guard caps iterations so a
38+
beginner's `while True` cannot hang the browser.
39+
- **Strings become heap objects.** String *literals* now allocate a heap `str` object (the rest of
40+
the subset already did for lists/dicts). This keeps every `str` code path — concatenation,
41+
`.upper()`, indexing, `in`, looping over a word — uniform and, crucially, *visible on the canvas*,
42+
consistent with the "make identity visible" thesis.
43+
44+
**2. Record, then replay.** Rather than build a program-counter interpreter with pause/resume, the
45+
evaluator *records*: when capture is on, after every top-level statement and after every loop
46+
iteration it appends a deep `cloneState(state)` to `state.trace` (capped at 60 frames; the growing
47+
array is stripped from each clone so frames never nest). The UI then *replays*: a transport bar
48+
(⏮ ◀ ▶ ⏭) under the board tabs scrubs the recorded frames, painting each through the existing
49+
`renderMemory`/`renderRam`.
50+
51+
Record-replay was chosen over a live PC-based stepper on purpose: "watch the loop" only needs the
52+
*history*, and cloning already-built state is far less machinery than pausing mid-execution. If
53+
interactive stop-and-resume is ever needed, the trace is the right foundation to build on.
54+
55+
`MemoryState.trace` is a pure replay aid — `snapshot()`, goals, and predicates never read it, and
56+
`recordStep` is a no-op when capture is off — so the solvability gate and existing tests are
57+
unaffected.
58+
59+
## Consequences
60+
61+
- **The course opens from zero.** New pure-Python series — `basics` (print, values, names, math,
62+
strings), `flow` (booleans, comparisons, `if`/`elif`/`else`), `loops` (`for`/`range`, `while`,
63+
building data, and the "watch the loop run" level) — come first, with no memory-model vocabulary.
64+
The memory model (`names` → `containers` → `mutation` → `aliasing` → `copy-semantics`) is the
65+
second layer. `functions` (which introduces `def`) is moved before the library and concurrency
66+
series that call functions, and `mutation` (`.append`) before the series that mutate lists.
67+
- **Every run is scrubbable.** Trace capture is on by default in the UI, so the learner can step
68+
through any run; the `loops` series has a level built on it.
69+
- **The subset is now a real (small) Python.** `if`/`for`/`while`/`range` and string identity on the
70+
canvas are honest features, documented in the README and `docs/CURRICULUM.md`. The fidelity
71+
boundary (no real I/O, simulated libraries) is unchanged.
72+
- **Cost:** one `cloneState` per step, bounded by the 60-frame cap. `cloneState` is a JSON deep
73+
clone; at this size it is invisible in the browser.

0 commit comments

Comments
 (0)