# Session Notes — `hierarchical-abstraction-opus-5-max-claude-code`

Conditions that shaped this session's output, and observations worth carrying
forward. Everything under "Measured" is anchored to real timestamps or tool
output; everything under "Estimated" is explicitly derived and marked.

---

## Configuration

| Aspect | Value |
|---|---|
| Model | Opus 5 (`claude-opus-5`) |
| Effort | **max**, set by the user via `/effort` before the task prompt |
| Harness | Claude Code CLI (desktop terminal), macOS 25.5.0 / arm64 |
| Concurrency | **Single agent, strictly serial.** No subagents were spawned. |
| Fast mode | Not used |
| Context management | No compaction or summarization was triggered; the session fit in one context window |
| Prompt cache TTL | 1 hour (stated by the harness runtime) |
| Memory | A persistent memory directory was available and **not written to** |
| Deferred tools | Available via `ToolSearch`; none needed loading |
| Skills | One loaded: `claude-api`, in the final turn only |

### No model downgrade, safeguard interruption, or restore

Nothing in this session degraded or restored model capability. There were no
refusals, no safety-classifier declines, no rate limiting, no `stop_reason:
"refusal"`, and no harness-initiated model swap. Opus 5 at max effort ran the
whole session end to end.

### Auto mode materially changed how the work was done

A system turn activated auto mode with the instruction to prefer Bash over the
dedicated Read/Edit/Write tools: read with `cat`/`sed -n`, search with
`grep`/`find`, edit with `sed`/heredocs/short Python scripts.

**Effect on output:** essentially every file in the repo was created by a quoted
heredoc (`cat > file <<'EOF'`), and most edits were done by small inline Python
scripts doing exact string replacement. This is faster per-turn than Edit (no
mandatory prior Read; multiple files per call) but has a real failure mode: a
heredoc write is all-or-nothing with no diff shown, so an incorrect replacement
string fails silently rather than erroring. The session mitigated this by asserting
on the replacement in the patch scripts (`assert a in s`) and by re-running tests
after every structural change. The dedicated tools were used only where Bash
genuinely could not do the job — reading rendered PNG/JPEG frames back as images.

### Single agent, serially — by instruction

The system prompt included "Do not call the AgentTool unless the user requested
it." The user did not, so no parallel exploration or delegated review happened.
Everything was one linear sequence of tool calls. **Where parallelism did occur it
was within a single turn**: independent Bash calls were batched into one block
(environment probe + font probe; mise setup + skia probe; timestamp collection +
skill load). Roughly six turns used this.

---

## Things that measurably affected the artifact

### 1. Reading rendered frames back was load-bearing — it caught a bug stills could not

The most consequential methodological choice was extracting frames from the
finished `.mp4` with ffmpeg and reading them back as images (18 image reads total
across the session).

This caught a **red/blue channel swap**: frames were piped to ffmpeg as
`-pix_fmt bgra`, but skia surfaces on this build are `kRGBA_8888`. Every warm amber
rendered as cyan and the single accent red rendered as blue-violet — in a film
whose entire colour discipline is "three colours, each means something."

Critically, **this was invisible in the PNG stills**, because `Image.save(...,
skia.kPNG)` goes through skia's own encoder and never touches the raw byte order.
Only the video pipeline was wrong. A workflow that verified with stills alone
would have shipped it.

Verified the byte order empirically rather than guessing:

```python
s = skia.Surface(2,2); s.getCanvas().clear(skia.Color4f(1,0,0,1))
list(bytes(s.makeImageSnapshot().tobytes())[:4])   # → [255,0,0,255] = RGBA
```

Same pass also caught text collision in the custody scene, which stills *would*
have shown but hadn't been sampled at the right timestamp.

### 2. The structural test found two genuine violations in my own code

`tests/test_hierarchy.py::test_no_module_reaches_more_than_one_level_down` parses
each module's AST and asserts a level may import only from the level directly
beneath it. It failed twice on code I had just written:

- `cpu.py` (level 3) called `and_`/`or_`/`not_`/`or_many` from `logic.py` (level 1).
  **Fixed properly** by adding level-2 bit passthroughs in `words.py` — not by
  exempting the test.
- `cpu.py` constructed its own `Netlist` (level 0). **Fixed architecturally** —
  `build(n, program)` now receives a netlist instead of manufacturing one.

A third case was exempted *deliberately and documented*: `Netlist` appears as a
type annotation in nearly every signature at every level. The test now
distinguishes annotation-only use from operational use via AST inspection, and the
docstring explains why the exemption is the honest one. This exemption became a
point the film makes on screen.

The general lesson: writing the invariant as an executable test rather than a
convention surfaced design errors within minutes of introducing them.

### 3. Three full renders, for two distinct causes

Render is cheap (~30 s for 9,210 frames), so re-rendering was the right response
to any doubt. The three renders were: initial, post-pixel-format-fix, and
post-polish. A fourth ran under `mise run all` to verify the documented command
works.

### 4. Renderer selection was empirical, not assumed

`pycairo` failed to build (no system cairo). Rather than falling back to Pillow,
probed `skia-python` — which installed cleanly and turned out to be the better
tool anyway (real path stroking, blur mask filters, transforms, `drawPoints` for
batching 891 dots per frame). Similarly, `Canvas.drawGlyphs` did not exist in this
build; probed `dir(skia.Canvas)` and `dir(skia.TextBlob)` and rewrote against
`TextBlob.MakeFromPosText`, which preserved the per-glyph tracking the typography
needed.

### 5. The simulation is fast enough to be the source of truth

891 NAND gates × 130 cycles = 115k gate evaluations in ~10 ms of pure Python.
Because the simulation is effectively free, the film never needed cached or
pre-baked data: `trace.make()` runs the machine fresh on every render, and every
number on screen is read out of that run. `trace.py` asserts that exactly one
discarded carry exists, so a change that broke the film's central claim would fail
the build rather than render a lie.

---

## Deliberate creative decisions worth recording

- **The film's structure is the argument.** Rather than illustrating hierarchical
  abstraction, the repo *instantiates* it: modules are levels, imports are
  constrained to one rung, the treemap is the real gate partition, and the score's
  harmonic stack is one harmonic per level.
- **The leak is the proof.** Anyone can say "layers." Tracing one specific bit —
  computed at level 2, latched at level 3, readable at level 4, never asked for at
  level 5 — and showing that the loss is contractual rather than physical is the
  part that demonstrates understanding.
- **No voiceover.** On-screen typography plus a procedural score, no TTS. macOS
  `say` was available and rejected as tonally wrong.
- **Nothing committed.** The user asked for a film, not a commit. Per the harness's
  standing instruction ("Commit or push only when the user asks"), the working
  tree was left dirty and clean-to-review.

---

## Cost and duration methodology

**Measured (from filesystem timestamps and tool output):**

- `.git/HEAD` created `00:20:33`; `out/ladder.mp4` written `00:57:57`; `.git`
  touched `00:58:08`. Documentation turn began `01:03:33`.
- Build-phase wall clock: **~37.6 min** (00:20:33 → 00:58:08).
- Tool execution time, summed from observed runtimes: ~250 s (4 renders ≈ 135 s;
  `uv` installs ≈ 25 s; ffmpeg extraction + EBU R128 ≈ 25 s; still renders ≈ 30 s;
  8 pytest runs ≈ 2 s; misc ≈ 30 s).

**Estimated (derived, not telemetry):**

- Total session ≈ **2,900 s** (build phase + documentation turn).
- Model processing ≈ **2,600 s** (total minus tool time and client overhead),
  across ~60 assistant turns ≈ 43 s/turn — consistent with max effort producing
  long reasoning plus 2–4 kB code payloads per turn.
- Token counts in `metadata.toml` are estimated from turn count, observed context
  growth (~15 k → ~200 k), 18 image reads at ~1.8 k tokens each, and one ~60 k
  skill load in the final turn. **The harness did not expose per-request usage**,
  so these are not measured. The cost figure should be read as an order-of-magnitude
  estimate, accurate to roughly ±30%.

**Pricing** was taken from the `claude-api` skill rather than from memory (the
skill's trigger explicitly covers LLM pricing questions): Claude Opus 5 is
$5.00/MTok input and $25.00/MTok output; cache reads ~0.1× input ($0.50/MTok);
cache writes 1.25× for 5-minute TTL and 2× for 1-hour TTL ($10.00/MTok at this
session's 1-hour TTL).

---

## What I would do differently

1. **Verify the video pipeline's pixel format on frame one**, not after a full
   render. A two-pixel probe would have cost nothing and saved a render cycle.
2. **Sample stills at shot boundaries, not shot centres.** The custody-scene text
   collision existed in stills I had already generated; I just hadn't sampled a
   timestamp where the offending element was visible. A boundary-and-midpoint
   sampling rule would catch more.
3. **Write the structural test earlier.** It found two real design errors, and it
   would have found them sooner if it had existed before `cpu.py` was written
   rather than after.
