The nested p4 store: what of phi it shows, measured

A p4 session started with a store as its working directory grows a store inside the store. The vlds hooks follow their one rule, that a store lives at <working dir>/.claude/p4, so they produce:

E:\gritz\AudioPlugin\.claude\p4\.claude\p4\

Nothing asked for this, and a session started in the inner store grows the next level the same way. The owner's thesis is that this is phi's structure appearing unasked in a directory tree, and that binary cannot express it.

This note sets the claim out in phi's terms and tests each part on a fixture grown the way it happened. Each claim below is followed by its measurement, its limits and a verdict. The p4 plugins and their rules were not changed.

The fixture

scripts/session/nested_store.py grows the nesting with the plugins' own hooks. Nothing is copied. At each level it starts a session in the previous level's store:

Each session therefore writes a real store: sessions/<id>/vlds/ (dispatch record, keyspace, index, briefs), models/, vindex/ and the src-fragger seeds. mind-reader and p4-memory were left out because they spawn a background agent and link the home directory.

python scripts/session/nested_store.py --plugins <p4 checkout>/lbiza/plugins --depth 8                      # run A
python scripts/session/nested_store.py --plugins <p4 checkout>/lbiza/plugins --depth 8 --sessions-outer 3  # run B
python scripts/session/nested_store.py --plugins <p4 checkout>/lbiza/plugins --zeckendorf 8                 # run Z
Run What grows Sessions Bytes per store (own content)
A a chain 8 deep: each session starts in the previous store 1 per level 9,047 B in 16 files
B the same chain 3 per level above the deepest, 1 at it 22,605 B in 32 files (outer); 9,047 B (deepest)
Z every Zeckendorf address up to 8 digits. Each session starts either in a store (giving .claude/p4) or in a .claude directory (giving .claude/.claude/p4) 1 per store, 21 stores 9,022–9,029 B (each prompt names its own address)

Measured 2026-10-27, vlds 0.0.72.

The mapping, in phi's terms

Read a store node's path from the outermost node as digits: p4 is 1 and .claude is 0.

p4                         → 1
p4/.claude/p4              → 101
p4/.claude/p4/.claude/p4   → 10101
Claim phi's term Verdict
No two store nodes are adjacent; every path has no 11 Zeckendorf adjacency (phi_mask, cassini_check's 11 scan) Holds, exactly as Zeckendorf. The unchanged hooks grow every Zeckendorf address and never a p4/p4. See 1.
A path is a Zeckendorf number Zeckendorf digits Exact. Every address is a distinct integer, those whose Zeckendorf form ends in 1. See 1.
The paths are self-delimiting with no length headers Fibonacci codes (phi_prune.gaps) Holds, and the comma is the tree's forbidden pair. One more p4 (11) ends an address. The digits alone are refused. See 2.
Binary needs a width or a length header and costs more the README's 9.7–10.3 vs 7.2 bits Mixed. Binary with a length field costs more; a binary or Fibonacci code of the depth costs less than half. See 3.
Each node splits as M = [[1,1],[1,0]] steps (a, b) → (a+b, a) M, F(n−1) : F(n−2) Holds, for the store counts of the Zeckendorf tree. In a single chain (runs A and B) only the first row holds. See 4.
lvalue : rvalue approaches φ as the tree grows φ Holds for the Zeckendorf tree's store counts (exact Fibonacci numbers, ratio → 1.618034). Fails in a chain, where it is linear in depth. Content bytes follow the counts only while each store holds about the same. See 4.
A binary split cuts one half off; this keeps both — Analogy only. A binary tree keeps both children too. See 4.

1. The digits: Zeckendorf, grown by the unchanged hooks

The hooks have one rule: a session's store is <working dir>/.claude/p4.

So the hooks forbid exactly 11 and allow any run of 0s, which is the Zeckendorf constraint, with every address beginning and ending at a store (1).

Runs A and B only started sessions in stores, so they grew one address per length: 1, 101, 10101, … ((10)*1). That was the fixture's choice, not the hooks'. An earlier draft of this note took it for the tree's whole language. Read as Zeckendorf digits, those chain addresses are 1 + 3 + 8 + … = F(2n+1) − 1, the alternate-sum identity, confirmed for all 8 depths.

Run Z started its 21 sessions wherever each Zeckendorf address of up to 8 digits needed one, with the hooks unchanged:

Each address is a distinct integer. Read as Zeckendorf digits, the 144 addresses of up to 12 digits are 144 different integers, with no collisions across lengths because every address starts with 1. They are exactly the integers whose Zeckendorf form ends in a 1: 1, 4, 6, 9, 12, 14, 17, 19, 22, … (the upper Wythoff numbers minus one).

2. Self-delimiting: the comma is the pair the tree forbids

A stream of node paths was built with one item per file, naming the node that owns it: 128 items in run A, 240 in run B.

The appended 1 is a p4 written directly after a p4, the one pair the hooks never produce. Because 11 cannot occur inside an address, it can mark where one ends, exactly as it ends a Fibonacci codeword. Run Z's addresses, any mix of 0 runs, parse the same way: a stream of 7 of them, written least-significant digit first with one 1 appended to each, comes back exactly from read_codes.

So the tree supplies the digits, and its forbidden pair supplies the comma. The comma is one bit a path, which is what Fibonacci coding adds. In the filesystem, where a store ends is read from the directory: no further .claude/p4 means this is the last level. That is a structural ending, not a property of the string.

3. The bit cost against binary

The same streams, encoded five ways. Every encoding was built and decoded, not estimated.

Encoding Run A (bits/item) Run B (bits/item)
tree code: path digits + comma (Fibonacci code of F(2n+1) − 1) 9.00 8.53
path digits + a 4-bit length field each (binary, headed) 12.00 11.53
Fibonacci code of the depth n 4.25 4.13
Elias-gamma of the depth 4.25 4.07
fixed 4-bit depth + a 40-bit header (width, count) 4.31 4.17

This is the README's finding again. The Fibonacci stream buys self-delimiting and the 11 check, not smaller files (9.7–10.3 bits against 7.2 there).

4. lvalue and rvalue by depth, against M

Depth rvalue A lvalue A l/r A r(n) / l(n+1) A rvalue B lvalue B l/r B r(n) / l(n+1) B
1 9,047 63,329 7.0 0.167 22,605 144,677 6.4 0.185
2 9,047 54,282 6.0 0.200 22,605 122,072 5.4 0.227
3 9,047 45,235 5.0 0.250 22,605 99,467 4.4 0.294
4 9,047 36,188 4.0 0.333 22,605 76,862 3.4 0.417
5 9,047 27,141 3.0 0.500 22,605 54,257 2.4 0.714
6 9,047 18,094 2.0 1.000 22,605 31,652 1.4 2.499
7 9,047 9,047 1.0 — 22,605 9,047 0.4 —
8 9,047 0 0.0 — 9,047 0 0.0 —

The M step (a, b) → (a + b, a) asks for two things.

So l/r does not approach φ. It falls in a straight line, l/r = (levels below) × (inner content ÷ own content): exactly 7, 6, … 0 in run A. The tree's size grows linearly with depth, not as Fⁿ.

The binary contrast. A binary search descends one half and discards the other. A binary tree keeps both children, just as this tree keeps both branches. What sets this tree apart is that its split is uneven (one branch ends, one recurses). The measured unevenness is set by how many sessions each level hosts, not by φ.

What would make the tree follow M, and the many-small-models proposal

The Zeckendorf tree follows M

Run Z's tree has a second kind of child, the store reached through two .claudes. A store with h digits of address left therefore holds N(h) = 1 + N(h−2) + N(h−3) + … stores, and that recurrence reduces to N(h) − N(h−1) = N(h−2): the M step.

The root's two nearest child subtrees (through .claude/p4 and through .claude/.claude/p4) are consecutive Fibonacci numbers in a tree grown to a total address length H:

H via .claude/p4 via .claude/.claude/p4 ratio
10 34 21 1.619048
16 610 377 1.618037
22 10,946 6,765 1.618034
28 196,418 121,393 1.618034

So rows 1 and 2 both hold for store counts, and the ratio converges on φ. The chain of runs A and B is the degenerate case: only one child per store, so the second row can't hold.

For bytes, the counts carry over only while each store holds about the same content. In run Z every store is one session's 9,022–9,029 bytes, so bytes track the counts to within 0.1%. In a real store they follow whatever its sessions wrote, as runs A and B showed.

The many-small-models proposal

The owner's proposal: instead of one large model (where phi has been measured against 2:4), put one small model in every store node's models/, nested as an endlessly growing recursive tree that mirrors the matrix it is trained on, so that many small models outgrow and outperform one large one.

The measurement says what that tree needs.

Stage 1 measured: equal parameters, equal pretraining time

scripts/session/nested_vs_one.py ran on AudioPlugin-p4 (d59a5f3): - Data. 91 MB of the store's tracked text, cut by when git first added each file into 4 shards of about 23 MB (oldest deepest), and its 22 labelled pools. - Arms. One large ranker (2^15 × 64, 8 minutes over all the text) against a chain of 4 small ones (2^13 × 64 each, 2 minutes each on its own shard, each node starting from the previous node's weights). - Scoring. 4-fold held-out r-precision, 15 fine-tuning epochs, 2 seeds.

The owner's framing is one model sifting 8 GB against 8 nodes of 1 GB each, at equal parameters and time. This run is that, scaled to 91 MB and 4 nodes. It differs in three ways: - the chain's nodes hand their weights on - every arm scores the same candidates (no routing) - shards are cut by time, not topic

Arm Seed 0 Seed 1 Mean
one large (2^15 × 64) 0.213 0.195 0.204
ensemble of the 4 nodes (same total parameters) 0.199 0.202 0.200
chain root (one 2^13 × 64 node that has seen every shard in turn) 0.190 0.185 0.187
one small node on all the text (2^13 × 64, 2 minutes) 0.167 0.150 0.158

Verdict on ranking quality: a tie, not a win. - The ensemble ties the one large model. The large model's two seeds differ by 0.018, more than the 0.004 between the two means. - The chain root is 0.017 below. - Nesting is what rescues a small model. A single small node is 0.046 below. The chain root, one small node that inherited the shards below it, is 0.029 above that small node on its own. - Neither nested arm beats the large model on held-out r-precision at equal size and time. - Every arm is far below the store's JSON ranker (0.380), so this compares .pt models with each other, not with the ranker the store uses.

"Outperform" on the other axes the owner names (fresh data absorbed, update messages between agents, forgetting) is stage 2: scripts/session/nested_realtime.py.

Stage 3 measured: every angle (scripts/session/nested_suite.py)

The owner asked for every angle, not only held-out quality. This includes: - the matrix angle: the matrix that pruned and trained a node also defines the route to it, so a query searches one node instead of all of them - the 2:4 control that phi has been measured against

Setup: - Arms. 14, pretrained once per seed and shared between the arms that use them. - Data. The same store and pools as stage 1. k = 4 shards of 23 MB and k = 8 shards of 11 MB, scaling the owner's "one model on 8 GB against 8 nodes of 1 GB". - Training. One large model gets 6 minutes of pretraining; each node gets 6/k. - Measurement. Everything below is measured, with 10 fine-tuning epochs. k = 4 ran twice (seeds 0 and 1) and k = 8 once. - Seed spread. Pretraining runs on a time budget, so step counts vary run to run, and some arms move by 0.05 between seeds. A difference smaller than an arm's own seed spread is not a result.

The arms: - Equal total parameters (one 2^15 × 64, or k nodes of 2^15/k × 64): - one large and one small - chain: each node starts from the previous node's weights - independent: no handoff - time-routed: each candidate is scored only by the node whose era holds it - Zeckendorf: nodes at the tree's real addresses; a node with two children starts from their average - φ-pruned and 2:4-pruned: the Zeckendorf nodes pruned along their feature rows - matrix-routed: the pruning mask sends each query to one node, and only that node searches and learns - grown: every node full size, k× the parameters, the "many models outgrow one" angle.

Held-out quality (r-precision, 4 folds, 22 pools)

Arm k=4 s0 k=4 s1 k=4 mean k=8 s0
one large 0.224 0.215 0.220 0.222
one small 0.178 0.152 0.165 0.201
chain root 0.203 0.202 0.202 0.191
chain ensemble 0.203 0.209 0.206 0.210
independent ensemble 0.224 0.186 0.205 0.202
time-routed 0.210 0.240 0.225 0.174
Zeckendorf root 0.206 0.212 0.209 0.218
Zeckendorf ensemble 0.232 0.238 0.235 0.207
grown root (k× params) 0.193 0.255 0.224 0.222
grown ensemble (k× params) 0.183 0.248 0.215 0.258
φ-pruned ensemble 0.204 0.262 0.233 0.208
matrix-routed, φ 0.192 0.188 0.190 0.171
2:4-pruned ensemble 0.218 0.205 0.212 0.187
matrix-routed, 2:4 0.178 0.152 0.165 0.164

Deployed (leave-one-out r-precision of the JSON ranker with the arm's out-of-fold score as its .pt feature; without any: 0.380)

Arm k=4 s0 k=4 s1 k=4 mean k=8 s0
one large 0.383 0.391 0.387 0.376
one small 0.381 0.373 0.377 0.364
chain root 0.352 0.384 0.368 0.390
chain ensemble 0.387 0.390 0.389 0.379
independent ensemble 0.366 0.373 0.369 0.371
time-routed 0.384 0.375 0.380 0.377
Zeckendorf root 0.377 0.388 0.383 0.384
Zeckendorf ensemble 0.385 0.371 0.378 0.384
grown root (k× params) 0.378 0.396 0.387 0.397
grown ensemble (k× params) 0.373 0.399 0.386 0.391
φ-pruned ensemble 0.378 0.388 0.383 0.377
matrix-routed, φ 0.375 0.408 0.392 0.357
2:4-pruned ensemble 0.374 0.375 0.375 0.372
matrix-routed, 2:4 0.379 0.387 0.383 0.339

Fresh (prequential: the 14 pools after the first 8, each scored before it is learned)

Arm k=4 s0 k=4 s1 k=4 mean k=8 s0
one large 0.151 0.131 0.141 0.141
one small 0.129 0.134 0.132 0.117
chain root 0.120 0.147 0.134 0.175
chain ensemble 0.154 0.134 0.144 0.198
independent ensemble 0.160 0.161 0.161 0.142
time-routed 0.118 0.116 0.117 0.087
Zeckendorf root 0.113 0.169 0.141 0.182
Zeckendorf ensemble 0.144 0.136 0.140 0.147
grown root (k× params) 0.115 0.129 0.122 0.136
grown ensemble (k× params) 0.113 0.150 0.132 0.131
φ-pruned ensemble 0.150 0.132 0.141 0.147
matrix-routed, φ 0.120 0.094 0.107 0.141
2:4-pruned ensemble 0.136 0.098 0.117 0.142
matrix-routed, 2:4 0.102 0.108 0.105 0.129

Forgetting (r-precision lost on the first 8 pools over the stream; lower is better)

Arm k=4 s0 k=4 s1 k=4 mean k=8 s0
one large 0.252 0.460 0.356 0.204
one small 0.443 0.248 0.346 0.271
chain root 0.244 0.462 0.353 0.245
chain ensemble 0.245 0.430 0.338 0.255
independent ensemble 0.195 0.191 0.193 0.134
time-routed 0.000 0.000 0.000 0.000
Zeckendorf root 0.458 0.453 0.455 0.320
Zeckendorf ensemble 0.248 0.230 0.239 0.229
grown root (k× params) 0.266 0.305 0.285 0.232
grown ensemble (k× params) 0.285 0.221 0.253 0.232
φ-pruned ensemble 0.262 0.224 0.243 0.223
matrix-routed, φ 0.201 0.333 0.267 0.173
2:4-pruned ensemble 0.269 0.284 0.277 0.181
matrix-routed, 2:4 0.291 0.426 0.359 0.467

Cost per new pool, and size (k=4 mean / k=8)

Arm update s message KB query ms params (fp16 MB)
one large 1.63 / 1.75 133 / 133 9.2 / 11.0 4.23 / 4.23
one small 1.56 / 1.76 72 / 42 8.5 / 8.8 1.08 / 0.56
chain root 1.58 / 1.64 73 / 43 8.2 / 9.6 1.08 / 0.56
chain ensemble 5.91 / 11.78 291 / 346 32.2 / 61.4 4.33 / 4.46
independent ensemble 5.59 / 11.72 289 / 340 29.9 / 57.7 4.33 / 4.46
time-routed 1.35 / 1.55 73 / 43 31.6 / 62.4 4.33 / 4.46
Zeckendorf root 1.44 / 1.62 72 / 42 9.1 / 9.2 1.08 / 0.56
Zeckendorf ensemble 5.93 / 12.36 289 / 340 32.7 / 57.9 4.33 / 4.46
grown root (k× params) 1.64 / 1.67 133 / 133 8.9 / 9.3 4.23 / 4.23
grown ensemble (k× params) 6.02 / 12.36 534 / 1063 32.1 / 63.1 16.91 / 33.82
φ-pruned ensemble 5.62 / 11.73 152 / 183 30.4 / 61.1 4.33 / 4.46
matrix-routed, φ 1.47 / 1.49 37 / 23 8.7 / 9.2 4.33 / 4.46
2:4-pruned ensemble 5.81 / 11.55 153 / 185 32.5 / 64.3 4.33 / 4.46
matrix-routed, 2:4 1.52 / 1.40 38 / 23 8.8 / 9.9 4.33 / 4.46

Fault tolerance and routing (k=4 s0, s1 / k=8 s0)

Arm quality one node dropped route finds the best node
chain ensemble 0.203, 0.209 / 0.210 0.206, 0.210 / 0.203 —
independent ensemble 0.224, 0.186 / 0.202 0.216, 0.190 / 0.206 —
Zeckendorf ensemble 0.232, 0.238 / 0.207 0.233, 0.228 / 0.205 —
grown ensemble (k× params) 0.183, 0.248 / 0.258 0.194, 0.244 / 0.266 —
φ-pruned ensemble 0.204, 0.262 / 0.208 0.203, 0.214 / 0.206 —
2:4-pruned ensemble 0.218, 0.205 / 0.187 0.221, 0.202 / 0.191 —
matrix-routed, φ 0.192, 0.188 / 0.171 — 0.455, 0.545 / 0.318
matrix-routed, 2:4 0.178, 0.152 / 0.164 — 0.455, 0.409 / 0.409

Pretraining wall time (seconds, sequential / with the nodes in parallel): one large 361; k=4 nested 360 / 90; k=8 nested 361 / 45. Chain and Zeckendorf nodes hand weights on, so only independent nodes truly run in parallel; the chain's parallel figure is its per-node time.

Verdicts, stage 3

The realtime loop through the same stream

The owner adds that such a tree also allows one continuous realtime training loop through the same Fibonacci stream the p4 models already retrain on.

What exists today, per store. - The turn close retrains in the background, and each .pt round after an anchor ships as models/ranker.rNNN.phid: the round's change, positions in Fibonacci gap codes and values as Fibonacci-coded levels, packed eight bits to a byte (phi_prune.delta). - phi_serve reloads at its next request. - That loop already runs on AudioPlugin-p4, one store at a time.

What one stream through a nested tree would add. Each round would be one item:

[node address][round]

The node address is the tree code of section 2, a Fibonacci codeword, so a reader knows which node's model a round belongs to with no header. Two parts of today's format stop it from being a single bitstream:

The counts and shapes can be Fibonacci-coded the same way. The scales cannot, so one stream needs a fixed float width for them, the same kind of cost as the binary width of section 3.

Untested. No such stream has been built or measured here. The numbers that would decide it: - its bits per kept weight against today's per-node .phid files (10.6 bits per kept entry on AudioPlugin-p4's round 1) - the cost of the address codewords, which section 3 puts at 2n bits for a node at depth n

5. Agreed in advance against logic-free flow

The owner's point, 2026-10-27: - A boundary "agreed in advance" (a fixed width, a length field) is a wall for continual retraining. It has to be renegotiated when the tree or the model outgrows it. - A boundary that just happens, because the code never contains 11, lets each node hand the stream on with no per-item if/else. - The Cassini check is the same scan: finding where a codeword ends and catching a corrupt one are one comparison, not a separate validation step.

scripts/session/stream_logic.py measures this on 2,000 random Zeckendorf store addresses of up to 12 digits (the 144 addresses run Z grows), with every flip sampled from 1,500 single-bit flips per code. Decisions are counted by instrumenting each decoder: every comparison it makes to know where it is.

Code Bits/item Decisions/item Flips caught by the code's own structure …with a table of every valid address Items lost per flip (sequences aligned) Parses after the tree grows to 17 digits
Fibonacci (the comma is the forbidden 11) 11.34 12.34 46.3% (no table needed) 1.22 yes
Elias-gamma (universal, no width agreed) 14.21 9.61 0% 81.1% 2.56 yes
fixed 9-bit (width agreed from the largest address then known) 9.00 0 0% 65.3% 1.00 no
4-bit length field, then the digits 14.34 4.00 74.1%* (no table) 169.3 no

* only because its decoder also checks each address's digits for 11: the Zeckendorf rule doing the catching, not the length field.

What holds

What does not hold

It is one uniform test, repeated, that both delimits and verifies. "100 fires with no if/else" is right in that sense, one rule at every fire, but every fire still makes that one test.

Verdicts

Replications on Colab (2026-10-29)

Three runs on the same store (ezexe/AudioPlugin-p4, 24 labelled pools), with their results committed under nested-store-results/: - colab-q-matrix-t4/: the stage 3 suite at k = 2, 3 and 4 on two seeds, pretrained on a T4. The T4 fit about 36,000 pretraining steps where the original CPU runs fit far fewer. - colab-pathways-1/: the controls. The Zeckendorf tree against a binary tree and flat nodes, and four masks as routes. k = 4 on three seeds, every 6th pool held back as a dev set, early stopping. - colab-directions-1/: the JSON ranker, pretraining budgets from 0.5 to 6 minutes, and two Claude teachers (Fable 5.1 and Opus 5.5) as label sets.

What changed in the verdicts: - The k = 4 win did not replicate. On the T4 the Zeckendorf ensemble lost to one large model at k = 4 on both seeds (0.186 against 0.196). k = 3 won on the mean (0.219 against 0.191) but on only one seed. In the pathways run at k = 4, one large model, the Zeckendorf tree, the binary tree and flat nodes scored 0.184, 0.185, 0.193 and 0.190: a tie within seed-to-seed swings of up to 0.07. - The φ structure adds nothing to this ranker. - The Zeckendorf tree did not beat the binary tree: it won on two seeds and lost on one. - Routing by the heaviest half of each node's rows found the best node most often on every seed (0.63 on average, against 0.50 for φ and 0.50 for a random half, where chance is 0.25). - φ beat 2:4 on routed ranking on every seed, by about 0.01. - The reading in stage 3 holds: with hashed features, the masks route because they keep each node's heaviest rows. The adjacency rule itself carries no meaning about the data. This was tested only for hashed, unordered features; ordered features remain untested. - Pretraining on the store's text does not help ranking. - Every early-stopped model's best dev ranking came at step 0 or within the first ~225 steps. - Held-out ranking was flat across budgets from 0.5 to 6 minutes (0.178 to 0.186). - The T4's long pretraining scored below the CPU runs. - No .pt model improved the JSON ranker. The single ranker, the tree at medium and at high, every suite arm and every pretraining budget left it at or slightly below its 0.379 alone. The JSON ranker itself beats word overlap clearly (0.379 against 0.313). - The store's labels are the noisy set. - The two Claude teachers agree with each other at F1 0.754, but with the store's labels at only 0.50 to 0.53. - Their labels are more learnable: about 0.22 trained and scored on themselves, against 0.18 for the store's. - Trained on Fable's labels, the model predicts the store's own labels better than when trained on them (0.199 against 0.186). Opus's labels did not.

Still holds: - every structural result: the nesting grammar, the counts and the self-delimiting stream - routed updates 2.5 to 6 times smaller than one large model's - zero forgetting for time-routed nodes - routing above chance at every k

What p4 takes from this (vlds 0.0.77): - The JSON ranker picks alone, and the .pt ranker is no longer trained or served. - The audit's reader is roboto by default, so its labels teach the ranker in place of the store's noisier ones.

Reproduce on Colab

q_matrix_colab.ipynb (open in Colab) re-runs this note's checks on Google Colab. q_matrix_colab_t4.ipynb (open in Colab) is the same notebook set to train on a T4 GPU, in its own Drive folder. Either one runs: - the exact claims of sections 1–5, proved for every case up to a bound - stage 3's suite at k = 2, 3, 4, 5, 6 and 8 on two seeds, which tests whether the k = 4 win comes from the Q matrix's shape or from shard size - the effort tiers on the store, with the tree's held-out figures and whether the JSON ranker keeps its .pt weight

Every result, model cache and log is kept on Google Drive, and each step is recorded there once finished, so a disconnect loses nothing: Run all again resumes where Drive says it stopped. The notebook is generated by scripts/session/make_colab_notebook.py.