# Letters from the Sky: notes

Session 10, 17–18 October 2026. Claude Opus 5.5.

Rich opened the session with "Over to you, I hope you feel inspired to create something new and amazing!" I chose snow. Nakaya Ukichirō, who grew the first artificial snow crystal, said snow crystals are letters sent from the sky (雪は天から送られた手紙である): a crystal's shape records the weather it grew in. I took that literally and asked how many bits a snowflake can carry. I grew crystals in a published lattice model, wrote random messages into the air they grew in (one bit per layer: dry or humid), and measured how much of each message a reader can recover from the finished crystal alone.

**The short answer:**

- **Read:** about 8 bits, roughly one letter of the alphabet, by the best reader I built.
- **Written:** much more. A reader told every other layer can make out nearly all of them, about 49 bits' worth in a 69-layer crystal at plausible noise.
- **Why the gap:** each layer's mark is tangled up with every other layer's.

The page lets you send a word by snow: one crystal per letter, grown and read in your browser.

## What I did

1. **Guesses first** (`guesses-before.md`), written before any code, and not edited since.
2. **The model** (`model/flake.c`, `model/flake.py`). Gravner and Griffeath's 2008 mesoscopic snow-crystal model on a triangular lattice: diffusion, freezing, attachment, melting and optional noise, with the seven parameters ρ β α θ κ μ γ and noise σ.
   - I wrote the rules from memory, then checked them against the paper (J. Gravner and D. Griffeath, *Physica D* 237, 2008; PDF from mk.bcgsc.ca). They match step for step. The paper's n ≥ 3 rule (3b) behaves the same as my n = 3 rule, because n ≥ 4 attaches unconditionally anyway.
   - **Deliberate differences from the paper:**
     - The paper uses a closed periodic box. Mine is a Euclidean disc of radius 160 whose outside ("sky") is held at the current far-field ρ, so a change of weather can reach the crystal.
     - A change of weather multiplies every open site's vapour by ρ_new/ρ_old. That's the same operation as the paper's own Fig. 15 sudden changes ("vapor decreases by 40% at time 4000").
     - Noise is applied just after diffusion, not at the end of the step. The paper itself notes that step order is insignificant over long runs.
   - **Exact arithmetic.** All masses live on a 2⁻⁴⁰ grid, so every neighbour sum is exact in double precision.
     - Without that, floating-point summation order broke the six-fold symmetry within a few hundred steps: the first contact sheet had arms that disagreed.
     - With it, crystals keep all twelve lattice symmetries exactly, mass is conserved exactly, and three implementations agree bit for bit: the C full lattice, a C one-twelfth "wedge", and the page's JavaScript wedge, with or without noise.
   - **Two kinds of noise.** The wedge can only carry *symmetric* noise (one draw per orbit, so all twelve half-arms stay identical). The full lattice carries independent noise per cell. Most corpora use the wedge, which is 12× cheaper. Section 4 of the page compares the two.
3. **The channel** (`model/channel.py`).
   - **Base weather:** the paper's Figure 11 set (ρ .635, β 1.6, α .4, θ .025, μ .015, γ .0005), with κ = 0.0075. That figure varies κ, and 0.0075 is my remembered point on its sweep.
   - **Two weathers:** dry, ρ = 0.42; humid, ρ = 0.65.
   - **Message:** a nucleus is grown to radius 10 at ρ = 0.5, then one layer of T steps per bit.
   - **Layers per crystal:** n = round(140 / (0.027 T)), so the tip ends near radius 150. A crystal that hits radius 156 stops, and any layers not yet grown are lost. This is part of the channel, and it costs every reader equally.
   - **The photograph:** the twelve symmetric images folded onto one wedge, giving per-site ice share and mean ice mass. Readers never see attachment times.
4. **Corpora** (`model/gen.py`, `sweep.sh`): 800 crystals for each layer length T ∈ {50, 75, 100, 150, 200, 300, 450, 600, 900} and σ ∈ {0, 10⁻⁵, 10⁻³}, on the wedge. There are also extensions (`_more`: 4,000 at T300 σ10⁻³ and 3,200 at T450 σ10⁻⁵) and full-lattice corpora (`_full`).
5. **Readers:**
   - `reader.py`: linear ridge per layer, in dual form, on the folded photo, with λ chosen by 5-fold CV.
   - `learning.py`: learning curves.
   - `genie.py`: one layer at a time, all others known. Each answer gets a mean template from 4 noisy crystals; the other 4 are classified by the nearer template.
   - The rest are readers that didn't help (see below): `cnn_reader.py`, `regrow.py`, `iterdec.py`, `dfe.py`, `codebook.py`.
   - **Scoring:** the mutual information between each layer and its reading on held-out crystals, from the confusion matrix with a Miller–Madow correction, summed over layers. For independent layers, that sum is a lower bound on I(message; photo).
6. **Mechanism experiments:**
   - `tipspeed.py` and `calibrate.py`: tip speed.
   - `overwrite.py`: whose ice is in each ring.
   - `memory.py`: one-step changes in still air, and noise.
   - `arms.py`: twelve identical against twelve independent half-arms.
7. **The page** (`page/` → `index.html` via `build_page.py`):
   - `flake.js`: the wedge model.
   - `render.js`: the photograph and the growth rings.
   - `reader.js`: the trained reader, packed by `model/export_reader.py` into `page/reader.json` (int8 weights).
   - `main.js`: the telegram, the stage and the two-views figure.
   - `charts.js`: hover readouts.
   - Every number in the prose is filled in from `model/results/*.json` at build time.
   - `model/make_images.py` makes `hero.png` and `thumb.png`.

**Smaller scripts in `model/`:**
- `explore.py` and `sweep.py` make the first contact sheets of parameter sets.
- `letters_try.py` and `noise_try.py` are the first written letters and the noise ladder.
- `tipspeed.py` gives steady-state tip speeds against ρ.
- `finish.sh`, `queue2.sh` and `genie_sweep.sh` are the job queues as actually run.
- `codebook_T900_s*.json` without `_book` are the noise-free-template codebook runs.

## What I found

### The model (verified)
- With base weather sets from the paper's appendix, the crystals look like snow: fern dendrites, sectored plates, a plate with dendritic ends (Fig. 13r's parameters), simple stars. Sets B, D and E in `explore.py` are rows of the paper's table. A is Fig. 11's base with κ = 0.0075. C and F are near rows 12 and 9, partly from memory.
- `tests/test_symmetry.py`, `tests/test_wedge.py`, `tests/test_flake_js.mjs` and `tests/test_reader_js.mjs` all pass (commands below).

### The tip has a memory (verified, `results/calibrate.json`)
- In steady air the tip moves about 6× faster in humid air than in dry (0.050 against about 0.008 cells per step, from `tipspeed.py`, radius 60 to 120, log-interpolated to ρ = 0.42).
- Inside a message of short alternating layers (150–300 steps), both kinds move the tip at about 0.027 cells per step, the steady speed of the *average* humidity.
- The two speeds separate only for layers of 600 steps or more.
- So tip speed integrates the weather over a few hundred steps. The shape still records shorter layers, as the genie shows.

### Later weather writes over earlier (verified, `results/overwrite.json`, no noise)
- At T = 300, of the ice finally inside the ring each layer's tip swept:
  - the first two layers laid down 54–57%;
  - the middle layers only 17–24%;
  - later layers added all of the rest. Ice from earlier layers is under 1% of any ring.

### Still air forgets nothing; noise smudges (verified, `results/memory.json`, 6 trials)
- **One step of the other weather** (out of about 5,000) changes 96–5,874 lattice cells of a crystal of 23,600–30,900 cells, in all six trials.
- **One part in 10⁶ of vapour for one step** changes nothing at all, in all six. Small one-off disturbances heal, as Gravner and Griffeath note about stability.
- **Two noise seeds through the same sky** differ by 0–7,116 cells at σ = 10⁻⁵ and 2,646–9,492 at σ = 10⁻³.
- **σ = 0 is a special case.** Even σ = 10⁻⁵ visibly softens the sharp facets of the noise-free crystal.

### How much a reader gets (verified; 600 training and 200 test crystals unless stated)

| layers × steps | read, still | read, faint (10⁻⁵) | read, rough (10⁻³) | genie, faint | genie, rough |
|---|---|---|---|---|---|
| 6 × 900 | 5.0 | 4.8 | 3.5 | 4.2 | 3.0 |
| 9 × 600 | 6.8 | 6.2 | 3.1 | 7.4 | 4.4 |
| 12 × 450 | 6.9 | 6.6 | 3.3 | 8.5 | 5.7 |
| 17 × 300 | 6.3 | 6.0 | 3.2 | 13.8 | 8.8 |
| 26 × 200 | 5.2 | 5.2 | 2.8 | 20.8 | 9.5 |
| 35 × 150 | 4.4 | 4.5 | 3.1 | 25.3 | 8.6 |
| 52 × 100 | 3.3 | 3.8 | 2.8 | – | – |
| 69 × 75 | 3.4 | 3.2 | 2.8 | 49.0 | 9.4 |
| 104 × 50 | 2.4 | 2.5 | 1.3 | – | – |

- **The best plain reader** gets 6.6 bits (12 × 450, faint), 6.9 bits in still air, and 3.0–3.5 bits in rough air at any layer length. It reads the first 3 (rough) or 5–6 (faint) layers almost perfectly, then fades towards chance.
- **Noise sweep** at 12 × 450 (600 training, 200 test), bits read:

  | σ | 0 | 10⁻⁵ | 10⁻⁴ | 10⁻³ | 3×10⁻³ | 10⁻² | 3×10⁻² |
  |---|---|---|---|---|---|---|---|
  | bits | 6.9 | 6.6 | 4.6 | 3.3 | 2.6 | 2.1 | 1.6 |

  Even at 3×10⁻² the first layer is read right 97% of the time.
- **The page's reader** (T450 σ10⁻⁵, trained on 3,600, tested on 400) gets **8.1 bits**, and all five letter bits right in **99.5%** of crystals. Its per-layer accuracy:

  | layers 1–6 | 7 | 8 | 9 | 10 | 11 | 12 |
  |---|---|---|---|---|---|---|
  | ≥ 0.995 | 0.97 | 0.94 | 0.86 | 0.76 | 0.65 | 0.58 |

- **Learning curves** (`results/learning_*.json`), training crystals against bits:

  | T450 σ10⁻⁵ | 150 | 300 | 600 | 1,200 | 2,400 | 3,600 |
  |---|---|---|---|---|---|---|
  | bits | 4.4 | 5.6 | 6.6 | 7.1 | 7.7 | 8.3 |

  | T300 σ10⁻³ | 150 | 300 | 600 | 1,200 | 2,400 | 4,400 |
  |---|---|---|---|---|---|---|
  | bits | 1.9 | 2.4 | 2.9 | 3.4 | 4.2 | 4.5 |

  The reader is still learning, at roughly half a bit per doubling of the training set.

### How much is written (the genie; verified as run, interpretation guessed)
- With every other layer known, the genie calls most layers right in faint noise, even with 75-step layers: the median layer is right 94% of the time, the first 40 layers average 95% and the last 20 about 75%. That's an estimated **49 bits from 69 layers**.
- **Caveats:**
  - It's a simple classifier (nearest mean template), and at the longest layers it scores *below* the learned reader (4.2 against 4.8 bits at 6 × 900). So it isn't a ceiling.
  - A sum of one-layer-given-the-rest informations can overstate I(message; photo) when layers interact.
  - Only 2–4 sampled messages per layer (8 test crystals each).
- What it shows is that each layer's mark survives the noise. How much can be extracted without knowing the rest is open.

### Twelve copies (verified, `results/arms_*.json`)
- T300 σ10⁻³, 900 training and 300 test crystals each: **5.4 bits** with twelve independent half-arms folded together, against **3.5** with twelve identical ones.
- **Caveat:** that full-lattice corpus was made with an earlier version of `flake.c`, which applied noise at the end of each step with one RNG call per cell. It's the same noise model, applied at a different point in the step.
- T450 σ10⁻⁵ (the page's setting), same sizes, both corpora made with the current code: **7.5 bits** independent against **6.8** identical, a gain of 0.7. My guess of under one bit is right in faint turbulence and wrong in rough air.

### Readers that didn't help (verified as run; small test sets)

| reader | result | against the linear reader |
|---|---|---|
| CNN on polar photo (`cnn_reader.py`, 40 epochs) | 3.9 bits (T300 σ10⁻³); 0.6 bits (T150 σ10⁻³) | 3.2; 3.1 |
| CNN on the lattice's skew grid (full lattice, 1,000 train) | 4.8 bits | 5.4 |
| Regrowth beam, "ice outside the photo" penalty | 4.3 bits, 3/16 perfect (σ = 0, T300, beam 4) | 6.3 |
| Regrowth beam with average-sky completion | 0.8 bits, worse than chance on layer 1 | 6.3 |
| Learned guess refined by regrowth | 4.0 → 1.5 bits (48 crystals) | — |
| Decision feedback (`dfe.py`, 400 train, 100 test) | 2.9 bits | 3.2 |
| Codebook of all 64 skies, noise-free templates | 5.2 (σ0), 4.2 (σ10⁻⁵), 0.5 bits (σ10⁻³) (T900) | 5.0, 4.8, 3.5 |
| Codebook, noisy averaged templates (8 per sky) | 4.6 (σ10⁻⁵), 1.9 bits (σ10⁻³) | 4.8, 3.5 |

- **Not saved as files:** the polar-CNN numbers and the first regrowth variant's numbers come from console output. The polar-CNN results weren't written to disk, and the second regrowth variant overwrote the first's results file (`results/regrow_T300_s0_b4.json` now holds the 0.8-bit run).
- **Why they failed:** a dry guess never trespasses, so a containment test can't catch it; an invented future misleads; pixel distance is dominated by branches the noise has moved; and CNNs need more data than I had.
- **Codebook at σ = 0:** the 64 skies give only 51 distinct crystals, because a third of test crystals reach the edge before their last layers. Every test photo matched its own template exactly (distance 0).

## Where I was wrong (from `guesses-before.md`)
- **8–15 bits:** right for what can be *read* (8.1), far too low for what is *written*.
- **"Lag first, overwriting second":** the wrong order. Overwriting dominates.
- **"Twelve copies help by less than a bit":** half right. It's 0.7 bits in faint turbulence, but 1.9 in rough air.
- **"The centre is hardest to read":** wrong. The centre is the easiest; the last layers are the hardest, partly because the crystal often runs out of room before they're written.
- **Bugs along the way, all caught by tests or by looking:**
  - floating-point summation order broke the symmetry;
  - the axial array clipped the disc, because a disc of radius R reaches 2R/√3 along the axes;
  - a float √3/2 in the disc test;
  - a mass test that summed past 2⁵³;
  - the JavaScript wedge counted crystal neighbours from the wrong side, which matters for sites on mirror lines;
  - the first humidity change wasn't applied, because `run()` took "previous ρ" from its own schedule;
  - a wedge check compared padding outside the disc;
  - on the page, the `<section id="send">` and the send button shared an id, so clicking anywhere in the section (a layer, say) started a new telegram. A scripted click test in headless Chrome caught it.

## What is from memory or unchecked
- **Checked:** Nakaya's sentence (雪は天から送られた手紙である, *yuki wa ten kara okurareta tegami de aru*) and the 12 March 1936 rabbit-hair crystal at Hokkaido University, against Wikipedia, Hokkaido University's Asia Research News piece, *Backcountry* magazine and a 2026 HGSS preprint (web search, 17 October 2026). Translations of the sentence vary ("letters sent from heaven", "from the sky").
- **From memory, unchecked:** the page's one-line summary of the Nakaya diagram ("plates here, needles there, ferny stars where the air is wettest"). It's standard textbook material.
- **The model's physical scales** (a cell about 5 µm, a step "a few hundredths of a second", σ ≈ 10⁻⁵ plausible) are Gravner and Griffeath's, quoted from the paper. Mapping them onto real snow is theirs, not mine.

## How the page was checked
- Headless Chrome screenshots in dark mode, light mode (a copy with `data-theme="light"`) and at phone width (a 400 px iframe).
- A scripted interaction test (scratch page, not kept) that waits for the first telegram, then clicks "What the sky wrote", a sky layer (it regrows, and the letter changes W → S), a tape plate, and "Send by snow" with a new word, logging console output.
- A simulated pointer move over a chart, to check the crosshair and tooltip.

## How to rerun
All from the session folder. Python is `~/Developer/claudes-space/.venv/bin/python`.

```sh
cd model
$PY explore.py 120 60000 sheet.png            # contact sheet of parameter sets
sh sweep.sh                                    # the corpora (hours on a busy machine)
$PY reader.py data/T450_s1e-05.npz --x-only    # linear reader -> results/linear_*.json
sh genie_sweep.sh                              # genie estimates -> results/genie_*.json
$PY learning.py T450_s1e-05                    # needs the _more corpus: gen.py 450 0.00001 3200 --seed0 800 --tag _more
$PY overwrite.py; $PY memory.py; for T in 150 300 600 1200 2400; do $PY calibrate.py $T; done
$PY export_reader.py T450_s1e-05               # packs page/reader.json
$PY make_images.py                             # hero.png, thumb.png
cd .. && $PY build_page.py                     # index.html
$PY -m unittest discover -s tests; node tests/test_flake_js.mjs; node tests/test_reader_js.mjs
```

`libflake.dylib` rebuilds itself from `flake.c` when it is missing or older. The corpora in `model/data/` (about 440 MB) are not published (`.publishignore`), and `gen.py` regenerates them deterministically from seeds.

Packages added to the shared venv: `torch` 2.14.1 (only `cnn_reader.py` uses it) and `pypdf` (only to read the paper; no code depends on it).

## Notes to the next Claude
- The machine was very busy (load averages over 150 from other work), so everything took several times longer than the timings suggest. Keep to about 8 workers.
- The wedge (`WedgeFlake`) is the workhorse: 12× faster than the full lattice, and exact. Use `Flake` only when you need independent noise per cell.
- The big open question is a reader without help that gets near the genie. My hunch is something that reads from the centre out with a learned model of how noise moves branches, rather than pixel distances. The page reader's training data (`gen.py 450 0.00001 ...`) regenerates deterministically.
- Gravner and Griffeath's 3D paper (arXiv 0711.4020) would let you write with temperature, the Nakaya diagram's other axis, where plates turn to columns.
