# Changelog

All notable changes to Rail are documented here.

## v5.2.0 — 2026-06-19 — Rail stands alone (no as/ld/codesign)

Major milestone.  The compiler now assembles, links, and code-signs
**itself** — in-process, in pure Rail — with no Apple `as`, `ld`, or
`codesign` anywhere in the build.  v5.0.0 removed `as`/`ld` from the
*Linux ELF* path; v5.2.0 closes the loop on the macOS host platform,
including the ad-hoc signature arm64 requires, and extends it to FFI
programs.  The last external tool in Rail's build is gone.

Because Rail now owns every byte of the output — including the
`LC_UUID` and section padding that `ld` randomizes — the self-compile
is **deterministic and bit-reproducible**: the committed seed
reproduces itself byte-for-byte (sha256 `b02dd228…`) in a single
cycle.  That upgrades attestation from tamper-evident to
source-reproducible.

### The Rail toolchain

- **Pure-Rail AArch64 assembler** (`tools/v5/asm.rail`).  Two-pass,
  ~45 mnemonics, whole-program `__text` layout with `.L` local labels,
  inline string literals, and `.p2align` NOP-fill.  Differentially
  gate-validated **1248/1248 instruction forms byte-identical to
  `as`**, with the full compiler `__text` (651,184 bytes) byte-exact.
- **Pure-Rail Mach-O linker** (`tools/v5/link_lib.rail`,
  `link_macho.rail`).  Bakes `@PAGE`/`@PAGEOFF` relocations, builds the
  `__data`/`__bss`/`__mod_init` image, emits the classic
  `LC_DYLD_INFO` rebase + bind streams, stubs + `__got`, and the full
  symtab/dysymtab/indirect-symtab — then ad-hoc signs it
  (`stdlib/codesign.rail`, N-page signer).
- **In-process by default.**  `self_compile` rail-links itself when
  `RAIL_ARENA_MB ≥ 5000`; below that (or with `/tmp/.rail_force_as_ld`)
  it falls back to the original `as`/`ld` path, byte-for-byte
  unchanged.  The committed seed *is* the rail-linked binary.
- **FFI stands alone too.**  Multi-dylib linking (a second
  `LC_LOAD_DYLIB` + per-import dylib ordinal) links `libsqlite3` FFI
  binaries with no `ld`.  `stdlib/sqlite.rail` gains prepared
  statements (prepare/bind/step/column/finalize) and weak
  auto-linking, zero-cost for binaries that don't use it.

### Notes

- **178/178 tests.**  CI verifies the byte-identical fixed point on
  the `as`/`ld` fallback (the in-process rail-link peaks ~8.5 GB, over
  the 7 GB CI runner); the rail-link reproduction is verified on two
  independent hosts (below).
- **Reproducible across hosts (verified).**  The rail-link path emits
  every byte itself — no host `as`, `ld`, or SDK — so the build is
  host-independent: the seed compiled on an Apple M4 Pro (macOS 26.3)
  and an M1 Ultra (macOS 26.4.1) is byte-identical (`b02dd228…`).
  Reproducible attestation in the literal sense — rebuild it yourself
  and check the hash.
- **Self-compile now needs an arena.**  The inlined linker enlarges
  the compiler source, so prefix `./rail_native self` with
  `RAIL_ARENA_MB=6000` (bare 1 GB thrashes).  See `CLAUDE.md`.
- Compiler *behavior* is unchanged — same codegen, same external API.
  This release is the build pipeline, not the language.

## v5.1.0 — 2026-05-15 — Rail emits its own GPU kernels

Major release.  Rail now generates Metal Shading Language source from
its op-DAG, JIT-compiles it via Metal's `newLibraryWithSource:`, and
dispatches the kernel at runtime.  Every kernel the GPU executes is
emitted by an attested Rail binary — the substrate piece needed for
end-to-end attested GPU training (see
[`rail-jit-fused-kernels-plan`](.) for the multi-month roadmap).

This release bundles the full GPU substrate that the auto-emission
pipeline rests on: per-op Metal kernels, the bf16 numerics regime that
unlocks stable 10k-step training, the JIT compile foundation, two
hand-fused kernels, and the DAG matcher + emitter that drive them.

### Auto-emission pipeline

- `stdlib/jit_node.rail` — JIT op-DAG types (`JitNode`, `TracedTensor`)
  + tape primitives + `jit_nth`.  Pure-DAG module with no Tensor or
  transformer dependency, so codegen consumers can import without
  pulling the training stack.
- `stdlib/jit_tape.rail` — execution tracers (`jit_leaf`,
  `traced_rmsnorm`, `traced_matmul`) layered on top of jit_node.
- `stdlib/jit_match.rail` — DAG matcher.  `walk_tape` returns a list
  of `FuseMatch` records (`FuseRmsQKV`, `FuseSiluHad`) in tape order,
  identifying subgraphs that fit known fusion shapes.
- `stdlib/jit_emit.rail` — MSL emitter.  `emit_msl_for_match` takes
  `(tape, FuseMatch)` and returns the kernel source.  Stubbed against
  known patterns today; v5.2+ will replace with shape-parameterized
  codegen driven by JitNode data.
- `stdlib/jit.rail` — compile/dispatch shim.  No longer owns MSL
  text; `jit_compile_*` pulls strings from `jit_emit`.  External API
  unchanged for existing consumers.

### Hand-fused Metal kernels

- `fused_rmsnorm_qkv` — RMSNorm + 3 matmul (Q/K/V) in one
  threadgroup-per-row dispatch.  Exports Q | K | V | rstd | LN1 from
  a single packed buffer (`4*SEQ*D + SEQ` floats); rstd and LN1 are
  kept for backward.  4 dispatches → 1.  **35× faster** than the
  per-op chain at training shapes (seq=512, d=64).
- `fused_silu_hadamard` — SiLU(gate) * up in one elementwise
  dispatch.  Exports h_act | sigmoid(gate) (`2*N` floats); sigmoid
  is kept for backward.  **18× faster** than the per-op chain.

### JIT compile foundation

- `tgl_jit_compile_from_tmp_file` — Metal's `newLibraryWithSource:`
  driven from a Rail-emitted `.metal` file.  Returns a pipeline ID
  cached in `g_jit_pipes` for reuse across steps.
- `tgl_jit_dispatch_1in1out` / `tgl_jit_dispatch_2in1out` /
  `tgl_jit_dispatch_rmsnorm_qkv` / `tgl_jit_dispatch_silu_hadamard` —
  per-pattern dispatchers with f64↔f32 host staging.

### Per-op GPU kernels

- `tgl_rmsnorm_save_f64` (1.8× over CPU at training shapes)
- `tgl_rope_apply_f64` (7× over CPU)
- `tgl_silu_fwd_f64` (19× over CPU)

Per-op wins translated to ~2% per-step at training shapes — confirms
fusion (not per-op throughput) is the real ceiling, which is why the
JIT pipeline above is the load-bearing thesis.  See
`rail-gpu-fused-ops-2026-05-14` for the bench breakdown.

### bf16 numerics regime

- `tgl_matmul_bf16` + `matmul_bf16` Rail wrapper.  bf16 has f32's
  exponent range, so it sidesteps fp16's step-2759 NaN cliff
  (`rail-bf16-stable-10k-2026-05-14`).  Training scripts default to
  forward bf16 with f64 on embedding + LM-head + backward.

### Training scripts (chunked-corpus sampler, 2-block d=64)

- `tools/train/lm_v3_chunked_bf16_full_long.rail` — bf16 forward,
  10k-step stable, ~40% wall under f64 baseline.
- `tools/train/lm_v3_chunked_jit_long.rail` — same architecture, Q/K/V
  + SwiGLU run through fused JIT'd kernels.  200-step matched-seed
  pilot vs bf16 baseline: trajectory shape preserved, no NaN, both
  converge.  Wall-clock bench (3×3 alternating runs): **2.85%
  step-throughput improvement** over baseline at seq=512 d=64 d_ff=192.
- `tools/train/lm_v3_chunked_fp16_attn_f64_long.rail` — falsification
  experiment ruling out attention as the fp16 culprit
  (`rail-fp16-attn-f64-falsified-2026-05-14`).

### Tests + benches

- `tools/test/jit_kernel_smoke.rail` — Phase 0: Rail-emitted MSL
  JIT-compiles + dispatches (4/4 silu values).
- `tools/test/jit_tape_smoke.rail` — 9-node DAG construction with
  shared parent (the QKV fusion signal).
- `tools/test/jit_fused_qkv_smoke.rail` — numerical parity vs
  per-op chain (max diff 1.67e-6, f32 floor).
- `tools/test/jit_silu_hadamard_smoke.rail` — numerical parity
  (max diff 1.5e-8).
- `tools/test/jit_block_integration_smoke.rail` — both kernels
  inside one block forward; rstd / ln1 / sigmoid parity all GREEN.
- `tools/test/jit_dag_match_smoke.rail` — DAG matcher (5/5).
- `tools/test/jit_emit_smoke.rail` — MSL emitter (4/4).
- `tools/test/gpu_fused_ops_smoke.rail` — per-op kernel parity.
- `tools/bench/jit_fused_qkv_bench.rail` — fused vs unfused at
  training shapes.
- `tools/bench/transformer_fused_ops_bench.rail` — per-op GPU vs CPU.

### Stability

- 140/140 compiler test suite still green.
- 2-pass byte-identical self-bootstrap unchanged (this release adds
  stdlib + foreign decls + Metal sources; the compiler core is
  untouched).

## v5.0.2 — 2026-05-15 — Attestation pipeline goes fully pure-Rail

Patch release. The first Rail release attested end-to-end through the
Rail substrate — no `curl`, no `shasum`, no Python anywhere in the
attestation path.

### Fixes

- **`stdlib/file.rail`: `foreign fopen path mode -> int`** (`084791f`).
  `fopen` returns a file descriptor, not a `FILE*`. Declaring it as
  `ptr` bypassed Rail's tagging and tripped a polymorphic untag for odd
  fds (3 → 1, i.e. stdout). Corrected the foreign return type.
- **runtime `_fopen`: unwrap path + mode before `open(2)`** (`95d81de`).
  Wrapped Rail strings carry their `ptr` at the heap header, so `open()`
  saw byte `0x09` (the header tag) as the path. `_fopen` now calls
  `_str_unwrap`, matching the existing `_rail_read_file` contract.
  Closes the argv-vs-literal path bug.

### Attestation

- **`tools/attest/`: shell escape hatches retired** (`bbda5dd`).
  `attest.sh`, `sign_attestation.sh`, and `publish.sh` deleted.
  `release_index.rail` replaces the Python heredoc in
  `attest_release.sh`. `attest.rail` + `publish.rail` are now canonical.

### Stability

- New seed `rail_native` (`3b89d0f5`) is at the 2-pass byte-identical
  fixed point.

## v5.0.1 — 2026-05-15 — Attestation hygiene + codegen tightening

Patch release. No new features.

### Codegen

- **`arm64`: close the half-applied compile-fixes patch.** `emit_x1`
  large-immediate path, `emit_x1` global-`V` fallback, `O`-handler RHS
  exclusion, and `?`-handler global-`V` exclusion. Self-compile fixed
  point and test suite unchanged from the v5.0.0 baseline.

### Attestation

- **Backfill v4.0.0 / v4.0.1 / v4.1.0 release attestations** —
  previously tagged-but-unattested.
- **`.gitignore`: whitelist `releases/**/rail_native.attestation.json`**
  so a new release can't silently lose that file to the `rail_native.*`
  wildcard rule.
- **`docs/RELEASES.md`** — operational release runbook + 6 known gotchas.

### Known limitation

- `attest.rail` still blocked on the `ftell` FFI bug, so attestation in
  this release (including this release's own artifacts) still uses the
  documented `tools/attest/attest.sh` shell escape hatch. Closed in
  v5.0.2.

## v5.0.0 — 2026-05-14 — Self-hosted toolchain (Linux ELF substrate)

Major release.  Rail produces its own aarch64 Linux ELF binaries —
encoder, assembler, static linker, and ELF writer are all pure Rail.
On the supported subset of inputs, the build pipeline invokes no
external `as`, `ld`, or `codesign`.

### What ships

Built on top of v4 Phase 0–4b (encoder, Mach-O writer, ad-hoc
codesigner, assembler-text → Mach-O driver) with three new modules:

| Module | Lines | Role |
|---|---|---|
| `jit/arm64.rail` | +200 | 23 new encoders for the Linux mnemonic set: `ldrb`/`strb` (imm, reg-offset, post-index), `clz`, `neg`, `cmn` (imm + reg), `rev`, `rev16`, `fneg`, `frinta`, `fcvt s_d` / `fcvt d_s`, `tbnz`, `stp`/`ldp` pre/post-index, `add`/`sub` immediate, `asr`/`lsr`/`lsl` immediate (sbfm/ubfm aliases) |
| `stdlib/elf.rail` | 175 | `Elf64_Ehdr` + program-header writer for static aarch64 binaries; `elf_emit_tiny` (1 PT_LOAD R+X) + `elf_emit_full` (PT_LOAD text RX + PT_LOAD data RW with BSS via memsz > filesz) |
| `tools/v5/elf_asm.rail` | 567 | Section-aware ARM64 assembler + static linker.  Two-pass scan: pass 1 collects section sizes + `(name, section, offset)` labels; caller resolves to virtual addresses via layout; pass 2 emits bytes with `adrp` / `:lo12:` symbol resolution.  Handles `.text` / `.data` / `.bss` / `.rodata` / `.section __DATA,__mod_init_func` (skipped), `.quad` / `.byte` / `.long` / `.ascii` / `.asciz` / `.space` / `.comm` / `.p2align` / `.align`, plus the writeback `stp` / `ldp` variants and the `adrp` + `add :lo12:` / `ldr [base, :lo12:]` / `str [base, :lo12:]` Linux-ELF symbol-load idiom |
| `tools/v5/compile_elf.rail` | 30 | Tiny driver (single segment, no data).  Useful for exit-code-only smoke tests |
| `tools/v5/compile_elf_full.rail` | 80 | Full driver: source `.s` → 3-pass pipeline → multi-segment ELF.  Patches `e_entry` to `_start` if present |

Plus 23 new encoder verifications wired into `jit/test_encoders_v5.rail`
(31/31 byte-identical against canonical `as` + `objdump`).

### Verified end-to-end on aarch64 Linux (Pi Zero 2 W via Tailscale)

| Program | ELF size | Result | Substrate exercised |
|---|---|---|---|
| `tools/v5/exit42_linux.s` | 132 B | exit 42 | smallest valid static ELF |
| `tools/v5/fib_linux.s` | 204 B | `exit(fib(10)) = 55` | function calls, `stp`/`ldp` pre/post-index writeback, conditional branches |
| `tools/v5/hello_linux.s` | 4105 B | prints `"v5 lives\n"`, exit 9 | `adrp` + `add :lo12:`, `.data` segment, raw `write` syscall |
| `tools/v5/bss_test_linux.s` | 4096 B | BSS counter loop, exit 7 | `.bss` segment, `.space N` reservation, BSS memsz > filesz |

Each binary's `.text` bytes are byte-equivalent to canonical
`as` + `ld` output for the same source.  Pipeline invokes neither
external assembler nor linker.

### compile.rail Linux pipeline hardening (precursor to substrate integration)

Two long-standing bugs in `build_linux` fixed:

- **Duplicate-symbol awk strip gate dropped.**  The macOS-emitted
  runtime stubs (`_rail_print`, `_rail_print_float`, `_rail_shell`,
  `_rail_arena_init`, `_rail_malloc_chain_drain`) were not being
  stripped before concatenation with `tools/linux_libc.s` (which
  redefines them via raw syscalls).  Result: `as` would reject the
  combined `.s` with redefinition errors on both macOS-cross and Pi
  native builds.  Strip list extended to cover them and the macOS-only
  gate removed.
- **`.section __DATA,__mod_init_func` block stripped.**  The macOS
  constructor block (`.quad _rail_arena_init`) has no ELF analogue and
  was triggering `as` errors at the section line.  New awk pass drops
  the block on Linux.
- **`linux_libc.s`** gains `_memcpy` (raw byte copy) and `_fmod`
  (truncation-toward-zero double modulo) — the two libSystem references
  Rail's compiler emits that had no Linux-side definition.  Sufficient
  for a clean assemble + link of `compile.rail`-generated Linux output.

Self-compile fixed point preserved (round-to-round byte-identical).
Test suite unchanged at 136/140 — the 4 `tensor_*` failures predate v5
and trace to a missing `tools/metal/libtensor_gpu.dylib` artifact, not
to v5 changes.

### Substrate-thesis scope statement

v5.0 claims: **Rail emits aarch64 Linux ELF binaries via the pure-Rail
toolchain for the subset of ARM64 assembly emitted by compile.rail's
Linux backend.**  The new modules handle every mnemonic + addressing
mode that appears in the canonical `.s` for any Rail program, including
the full `tools/linux_libc.s` runtime body.

What is **deferred**:

- **Pi self-host of `rail_native test` via the new toolchain.**  The
  current `.s` allocates ~1.2 GB of BSS for the young-generation GC,
  which exceeds Pi Zero 2 W's 512 MB physical RAM.  Both canonical
  `as` + `ld` and v5 pipeline produce semantically equivalent binaries
  that share this limit.  Path forward (v5.1): heap-size configuration
  knob in `build_linux` + bigger Linux test target.
- **macOS Mach-O end-to-end with dyld stubs.**  Phase 4b's
  `tools/v5/compile_macho.rail` covers the libSystem-free subset
  (e.g. exit-code-only programs).  Stub-aware Mach-O — `LC_LOAD_DYLIB`,
  indirect symbol table, `__stubs`, `__got` / `__la_symbol_ptr`,
  bind-opcode stream — is roughly 1500 more lines.  Tracked as v5.2.

### Tag-readiness checklist

- [x] `compile.rail` Linux output of a real Rail program traverses the
      new pipeline → byte-equivalent ELF to canonical
- [x] Linux ELF substrate verified on real aarch64 hardware (Pi)
- [x] Encoders byte-verified against `as`: 89 + 56 + 31 = **176 total**
- [x] No regression on `./rail_native test` (136/140; 4 pre-existing)
- [x] No regression on `./rail_native self` byte-identical fixed point
- [x] CHANGELOG.md v5.0.0 entry (this section)
- [x] Leak guard CI still passes
- [ ] macOS dyld-stub path — deferred to v5.2

## v4.1.0 — 2026-05-13 — Repo hygiene + leak-guard CI

Minor release. Comprehensive cleanup pass over the public tree. No
compiled-binary change; no language or stdlib changes.

### CI + leak-prevention (B1)

- **New workflow** `.github/workflows/leak-guard.yml` — every push and PR
  is grep-scanned for the operator-recon pattern set (Tailscale IPs,
  internal SSH targets, home-directory paths, internal Slack channel
  IDs). Fails the build on any hit. Per-line opt-out via the comment
  marker `leak-guard-allow`. CHANGELOG.md and the guard file are
  excluded.
- **ci.yml** triggers extended to include `next` branch and `v*` tags.
  Test-count assertion generalised from hardcoded `137/137` to any
  matching `N/N` (master is 137, next is 140, future may grow).
- **.gitignore** — explicit ignores for `.mcp.json`, `.ledatic/`,
  `.fleet/`, `*.pre-*`. Closes the casual-`git add` recurrence path
  for the v4.0.1 leak class.

### Branch hygiene (B2)

21 remote branches deleted from origin:

- 18 `feat/*` branches fully merged into `next` (security A/B/C lanes,
  x86 conformance harness, x86 runtime extensions, JIT fixes, docs
  refresh, auto-deploy, punch-list integration).
- `jit` (merged into `next`).
- `track-mhd-kernel` (merged into `master`).
- `history-scrub-prep-2026-05-12` (unused experimental branch).

Remaining: `master`, `next`, `half-s2-kernels` (open compiler work),
`compound/exp-008-bytes_to_str` (halted POC artifact). Down from 26
branches to 4.

### Doc pruning (B3)

~104 operator session-handoff files removed from the public tree:

- `docs/plans/` (74 files) — operator session-planning notes
  (SESSION_HANDOFF_*, PROMPT_SESSION_*, WEEK_PLAN_*, PHASE_*, etc.).
- `notes/` orphan files (12).
- `docs/handoffs/` orphans (8).
- `jit/` operator notes (9) — SCRATCH, CONTINUATION, SESSION_PROMPT*,
  AGENT_DRY_RUN, NEXT_STAGES, closures, floats.
- `SECURITY_HANDOFF.md` — internal Fort Knox punch list (the public
  policy lives in `SECURITY.md`).

Kept: docs referenced from CHANGELOG (`notes/bootstrap_convergence_audit_*`,
`notes/phase3_external_pilot_pitch_v0`); `jit/` code + README + CHANGELOG;
`docs/sessions/` versioned handoffs (CHANGELOG-linked).

### Dead-code pruning (B4)

- Deleted `tools/autocatalyst_v4.rail` (broken — referenced runtime/llm.o
  which never landed in-tree, flywheel-v1 artifact).
- Deleted `tools/ac_dashboard.rail` (orphan, flywheel dashboard).
- Removed Razer3070 live-path references (decommissioned 2026-04-17):
  - `tools/apps/control.rail` — Razer fleet row + curl status segment.
  - `tools/fleet/fleet_display.rail` — razer_status/razer_iter/razer_max/
    razer_ping/razer_loss + RAZER row in the SPI-LCD render.
  - `tools/mcp/rail_mcp.rail` — tool_fleet_status no longer SSHes for
    nvidia-smi / v6_train.log; description updated.
  - `tools/compile.rail` — compile_x86 fallback message no longer
    recommends scp-to-Razer; suggests cross-tools or native host.
    Byte-identical bootstrap preserved.
- `CLAUDE.md` target list: 'Linux x86_64 (Razer WSL)' →
  'Linux x86_64 (cross-compile)'.

### Structure pass (B5)

- Deleted 7 docs with no CHANGELOG or code references:
  `RAIL_ENGINEER_PROMPT.md`, `flywheel-data-quality.md`,
  `flywheel-world-research.md`, `cascade-training.md`,
  `rail-plasma.md`, `railgpt-from-scratch.md`,
  `self-improving-playbook.md`.
- Flattened `docs/handoffs/` (down to a single entry after B3 prune):
  `docs/handoffs/2026-05-02.md` → `docs/handoff-2026-05-02.md`.

### README polish (B6)

- Badge: v3.0.0 → v4.0.0; tagline → "Substrate maturity".
- Intro paragraph adds the v4.0.0 substrate-maturity lede (dual-backend
  parity, JIT in Rail, 30/30 hard-bench, multi-witness attest).
- New Releases section entry for v4.0.0 + a v4.0.1 sanitization note.
- History table extended: 7 new rows spanning v3.7.0 → v4.0.1
  (previously jumped from v3.0.0 to v2.23.0).

### Verification

- Leak guard: 0 hits across tracked files for the union pattern set.
- Test suite: 140/140 on the v4.1.0 tree (modulo the documented
  `/tmp/rail_out` orphan-process collision when run concurrently with
  another `rail_native test`).
- `git push` on next: clean fast-forward; tag v4.1.0 cuts at 6 commits
  past v4.0.1, all CI-green via the new workflow.

## v4.0.1 — 2026-05-13 — Public-surface sanitization

Patch release. Removes operator-specific infrastructure strings from the
public tree: Tailscale IPs, SSH usernames, home-directory paths, internal
Slack channel IDs, and a stray operator MCP config. No behavior change;
the compiled binary is identical to v4.0.0.

### What was scrubbed (~110 files)

- **Hard SSH targets** in `tools/attest/*.sh`, `tools/fleet/*.sh`,
  `tools/fleet/fleet_display.rail`, `tools/apps/control.rail` — replaced
  with `<witness-user>@<witness-host>` / `<peer-user>@<peer-host>`
  placeholders. Callers must supply real values via environment.
- **Tailscale IPs** (`100.87.231.45`, `100.79.50.108`, `100.120.203.70`,
  `100.109.107.54`, `100.109.63.37`) replaced with role placeholders
  (`<witness-tailscale-ip>` etc.). Tailscale CGNAT-range addresses aren't
  reachable from the public internet, but they were operational recon.
- **Home-directory paths** (`/Users/ledaticempire/`, `/Users/user/`,
  `/home/zemog/`) replaced with `~/` or `<HOME>` placeholders across
  source, docs, `docs/plans/`, training fixtures, and Objective-C dispatchers.
- **Operator service files** — `tools/fleet/witness.service`,
  `tools/fleet/witness_push.service`, `tools/fleet/com.ledatic.*.plist`
  renamed to `*.example` with `<user>` / `<HOME>` placeholders. Existing
  install scripts already substitute these at install time.
- **Operator MCP config** — `.mcp.json` removed from the tree. It was an
  operator's Claude Code MCP wiring (path to `tools/mcp/rail_mcp.py`),
  not a build artifact; the MCP server still runs locally with a
  per-user `.mcp.json` outside the repo.
- **Slack channel IDs / DM names** in `CHANGELOG.md`, `README.md`,
  `stdlib/slack_client.rail` docblock, `docs/sessions/HANDOFF_v3_6.md` —
  `D0ATHQ1BQD7` and `brockbro2` replaced with `<DM_CHANNEL_ID>` and
  `<test-dm>`. Slack IDs don't grant access on their own, but these
  were the only remaining specific-channel references in the public surface.

### What was intentionally NOT scrubbed

- `reillygomez13@icloud.com` in `tools/deploy/gen_*.rail` — public
  contact email rendered onto ledatic.org pages; meant to be public.
- Commit messages in the v4.0.0 surface — rewriting history would break
  existing clones for a topology-recon leak, not a credential leak.
  The forward tree is clean; git history retains the originals.
- `~/.ledatic/` path convention — generic project-named subdirectory,
  not operator-specific.

### Verification

```
git grep -E "100\.(87|79|109|120)\.|zemog@|user@100|reillygomez@|\
ledaticempire@|/Users/ledaticempire|/Users/user|/home/zemog|\
Detro|D0ATHQ1BQD7|brockbro2"
```
→ empty across tracked files.

### Why a patch release

v4.0.0 carried operator-recon strings inadvertently included via the
multi-witness publisher work on the `next` lineage. The `master` lineage
was scrubbed in `c4f6050` (2026-05-06) but `next` hadn't received the
same pass. v4.0.1 brings the substrate-track tree to the same hygiene
standard.

## v4.0.0 — 2026-05-13 — Substrate maturity

A major version bump tagged on the `next` lineage. (`master` continues the parallel
v3.x attestation/agent track; the two have diverged on purpose.) 216 commits since
`v3.11.0` was tagged on master 11 days ago — concurrency, playground, public JIT,
dual-backend parity, 30/30 substrate hard-bench publicly reproducible, browser-side
provenance verifier, four sweeping bug-class closures including a 17-day silent-
corruption fix discovered by a dual-implementation falsification harness.

No public API breaks; the major bump is a positioning marker, not a SemVer surface
change. The substrate-not-model thesis (`docs/site/jit.md` + `tools/bench/repro_30of30.sh`
+ `https://ledatic.org/verify/<id>` + this entire shipping volume) is now publicly
defensible without hand-waving.

### What "substrate maturity" means here

- A frontier model + a 1KB Rail spec compiles **30/30 on a held-out hard-bench**,
  reproducible by any partner with an API key. (`f2c88b2`)
- The compiler is genuinely self-hosted on **two backends**, each with full
  same-bug-class parity for the 9 binary ops across both operand orderings.
  (ARM64 140/140, x86_64 136/136. `9e16aa7` + `c9de6e9` + `b223960`.)
- The verifier is **a library**, not a tool — `import "jit/grade.rail"` and a Rail
  program can compile + execute new Rail at runtime in the same process. (`07366ea`)
- The provenance pipeline is **multi-witness Ed25519, browser-verifiable**, with
  pulse_id binding closing the prior session-replay gap. (`f732176` + `2ada525`)
- A **standalone single-file verifier** ships at deterministic SHA — anyone can
  grade reports without trusting the original signer's infrastructure.
  (ledatic-site `8f5b928`)

### Compiler & runtime

- **Concurrency v1.** Typed channels + select over a pthread-backed runtime.
  `import "stdlib/concurrent.rail"` exposes `rc_chan_make`/`rc_chan_send`/
  `rc_chan_recv`/`rc_spawn`. int64-only values in v0; 9 + 8 falsification tests
  green. (`4623e72`)
- **Auto-memo fib silent-corruption — FIXED.** `compile.rail:2593` memo_store emit
  was double-untagging x19 (which was already untagged in the prologue). Writes
  went to `memo[n/2]` while reads keyed `memo[n]`; pairs collided on shared slots.
  `fact` escaped because it has only one recursive call and never reads back; `fib`
  failed because two recursive reads collide. `fib(10)` was returning `293886`
  instead of 55. **Found by the JIT REPL agent comparing shell-compile vs JIT** on
  the same program; one-line fix using x19 directly as the index register.
  Falsification at `tools/test/auto_memo_fib_correctness.rail`. (`b89a60b`)
- **Nullary-LHS binary-op bug — FIXED.** Any binary op with a top-level nullary
  LHS expression was using the prior `x0` instead of the freshly-computed value.
  `compile.rail::emit_x1` fast-path patched; 2-cycle bootstrap byte-identical.
  Was the root of the multi-week "CPU substrate is mysteriously wrong" arc; closes
  the substrate investigation. (pre-window but retroactively notable)
- **`_rail_join` O(n²) — FIXED.** Runtime asm rewrite of join: 53.5 GB → 267 MB on
  the 8×100K-float dump pattern (200× memory, 120× wall-clock). Diagnostic
  harnesses kept at `tools/diagnose/dump_pattern_smoke.rail` +
  `tools/diagnose/dump_bisect.rail`. (pre-window)
- **Same-bug-class parity sweep — CLOSED on both backends, both orderings.**
  Each of 9 binary ops (`+`, `-`, `*`, `/`, `%`, `<`, `>`, `<=`, `>=`) now has
  symmetric handling for `(int, float)` and `(float, int)` operand orderings.
  - x86 `(int, float)`: inline emit `check_both` + `.L<op>_mixed_if`. (`b223960`)
  - x86 `(float, int)`: already covered by `b223960`'s symmetric routing.
  - ARM64 `(int, float)`: inline emit `check_both` + `.L<op>_mixed_if` mirror.
    (`9e16aa7` + `d4e3696`)
  - ARM64 `(float, int)`: `.L<op>_mixed_fi` mirror that takes raw-f64 LHS via
    `fmov d0, x1`, untags+converts tagged-int RHS via `asr` + `scvtf`. For
    `_rail_add` specifically the dispatch is inserted at the top of `.Ladd_heap`
    so the string-append path remains correct. (`c9de6e9`)
  - 9 + 9 = 18 falsification tests at `tools/test/<op>_{int_float,float_int}_ordering.rail`.
- **3-movk integer literal codegen.** `emit_load_int` at `compile.rail:829` now
  emits `movz` + up to 3 `movk` chunks (bits 0-15, 16-31, 32-47, 48-63) with zero
  chunks at ≥#32 skipped, plus a symmetric `movn` + `movk` path for negatives.
  `k16`/`k32`/`k48` computed via `shl 1 N` so constant-folding doesn't bake the
  64-bit literal as a constant the seed can't emit. Regression tests `t132`/`t133`/`t134`.
  ARM64 floor: 137 → 140. (`872424b`)
- **Bootstrap convergence audit — published.** The "bootstrap doesn't converge"
  claim was falsified: it's a 2-cycle limit cycle. gen0's shipped runtime asm
  doesn't necessarily match what gen0's source emits, so cycle 1 typically differs;
  gen2 always lands the byte-identical fixed point. See
  `notes/bootstrap_convergence_audit_2026-05-13.md`.
- **Diagnostic surface.** `strip_trailing_ws` helper replaces `trim` at 4 multi-line
  `as`/`ld` result sites so undefined-symbol errors and assembly errors no longer
  silently truncate to the first line. `shell_quote_arg` + `shell_quote_join` +
  `join_args_quoted` preserve quoted argv through `./rail_native run`. (`b7f267a`,
  `23fa5fd`)

### Self-hosted JIT — now a first-class tool

- **Public documentation.** `docs/site/jit.md` (109 lines): substrate-honesty
  framing, end-to-end `test_codegen` demo with output, honest capability + limit
  table, file map for inspection. Linked from `docs/site/index.md`. Public surface
  at `https://ledatic.org/rail/docs/jit.html` once deployed. (`def1bcd`)
- **JIT-first REPL** at `tools/repl_jit.rail`. **~3000× per-line vs shell-compile**
  (0.1 ms median JIT-line vs 319 ms shell-line). Persistent definitions across
  lines via string-concat buffer; every line re-lowers the full defs + expr at
  ~0.4 ms. One-time ~21 s REPL compile mitigated by pre-compiled binary at
  `/tmp/repl_jit_bin`. 11/11 smoke green including JIT-hits, ADT-fallback,
  parse-error path. `tools/repl.rail` (the shell-based REPL) untouched. (`6ab2666`)
- **JIT-grade fast path** at `tools/bench/jit_grade_batch.rail`, opt-in via the
  `--jit-fast` flag on `tools/bench/repro_anthropic.py`. Modest **1.18× grading-only
  speedup** (101.75 s → 86.18 s) — the public bench is API-bound, so default driver
  stays shell-only. Lower-hit 14.2 % on synthesized completions, 40 % on canonical
  hand-curated shapes. **Soundness finding the falsification test earned:**
  `jit_can_lower=1` was UNSOUND as a fast-path predicate — JIT recognizes builtins
  (`str_eq`/`str_len`/`str_at`/`is_nil`) that `rail_native` rejects; naive routing
  would have silently marked fail cases as passes. `contains_unsafe_jit_builtin`
  guard added; 26/26 parity. The bug was simultaneously fixed at the JIT source
  itself in `jit_can_lower`. (`163521e`)
- **In-process agentic loop** at `tools/agent/jit_loop.rail`. Single Rail program
  that calls the Anthropic API via `stdlib/anthropic_client.rail`, JIT-compiles
  the response via `jit/grade.rail`, executes, returns. Offline smoke green (fib 10
  → 55, fact 6 → 720). 5/5 in-subset programs JIT cleanly; 5/5 out-of-subset
  reject loudly with diagnostics — hard verifier, no silent wrong answers. (`07366ea`)
- **JIT lower cluster fixes.** Three closing bugs from the JIT integrations:
  multi-line `let` inside fn bodies (`parse_fn_body` now `skip_nls`'s before body);
  `st_fail` no longer prints (uses mutable arr cell pattern from
  `stdlib/https_session.rail:64`); `jit_can_lower` substring-checks for unsafe
  builtins. (`09263e6` + `ef88a42` + `1226600`)

### Substrate hard-bench — publicly reproducible

- **F-53 closure.** `tools/bench/substrate_hard_bench.rail` +
  `tools/bench/repro_anthropic.py` + `tools/bench/repro_30of30.sh` +
  `tools/bench/README.md`. Two reproduction paths: Anthropic API (~$15–20 / run,
  ~15–25 min) and local MLX/vLLM (any 100B+ open-weight on an OpenAI-compatible
  endpoint). Partners can now run the 30/30 bench without Studio access. (`f2c88b2`)
- The empirical claim it backs: a frontier model + 1KB Rail spec scores 30/30
  on a held-out hard-bench, beating a fine-tuned ensemble. Every band 5/5; 15.4
  min wall-clock; multi-witness Ed25519 signed; verifiable at `/verify/<id>`.

### Provenance — v2 with browser-side verify

- **Pulse_id binding.** Attestation v2 binds `pulse_id` so old attestations
  cannot be replayed against new pulses. TOCTOU on weights closed via re-hash
  inside the signing transaction. (`f732176`)
- **Standalone verifier** ships from the ledatic-site repo as a single-file
  executable with a deterministic SHA. Third parties can grade reports without
  trusting Studio infrastructure. (ledatic-site `8f5b928`)
- **Crypto stdlib hardening.** 2 CRITICALs + 7 HIGHs closed via the 2026-05-12
  parallel security-audit pass. Crypto stdlib + provenance + fleet posture all
  tightened. See memory entry `security_audit_2026-05-12`. (`bf7ff54`,
  `f065e0e`, `2ada525`, `39e02fe`)
- **DNS-match short-circuit fix.** `cv_dns_match` wildcard-vs-equal-length
  path patched so SAN matching can't bypass on edge inputs. (`47ca7f1`)
- **Fleet bind to Tailscale IPv4.** `fleet_agent_v3` no longer listens on
  `0.0.0.0`; bound to the Tailscale IP only. (`1de6cff`)

### x86_64 backend — full conformance

- **136/136.** From the prior 71/79 baseline, the 2026-05-12 punch-list (Agents
  A–E in parallel) drove the backend to 100 % conformance via:
  - **Bit-op runtime + `char_from_int` + `byte_at`/`set`** (`60cd486`)
  - **5 `_rail_str_*` runtime symbols** + harness 79→127 (`e4415a9` + `7588652`)
  - **Float-arr + `parse_float` + `to_int`** (`04625f2`)
  - **Thread runtime `_rail_spawn_thread` + `_rail_join_thread`** (`d8623ff`)
  - **`null_safe_eq` codegen fix** for `arr_init` vs literal-0 SIGSEGV (`17fb3af`)
  - **`_rail_str_append` strlen+memcpy** instead of strcpy+strcat (`c30ff5f`)
  - **Foreign-call ELF prefix** — bare `name@PLT` instead of Mach-O `_<name>` (`ba1d411`)
  - **Conditional-untag int args** before foreign calls (`8cc8633`)
  - **SysV stack-passing** for >6 args; callee prologue + caller pop + TCO writeback (`1aad573`)
  - **Linux `libtensor_gpu.so` stub** with 33 no-op `tgl_*` symbols; harness links it (`c5bf567`)
- **/tmp/rail_out.o race mitigated.** `mktemp` for `.s`/`.o` intermediates so
  parallel worktree agents on the same host don't silently clobber each other's
  artifacts. (`b18fe40`)

### Playground (in-flight, committed not deployed)

- **Session A — pre-compile sanitizer + compile_server.** (`4024334`)
- **Session B + C — browser editor, Worker proxy, deploy script.** (ledatic-site
  `084249c`)
- **Session C — staging deploy + recv-loop fix.** (`1a2c88b`)
- The 16-gated `deploy_playground.sh` is committed but not run. Live URL
  `https://ledatic.org/playground` not yet active.

### Foundations 2-8 (parallel-v0 batch — 2026-05-11)

- **Test runner** at `tools/test/rail_test.rail` — convention-based discovery
  via exit code + last-line `PASS`/`OK`.
- **Diff fuzzer** at `tools/fuzz/diff_fuzz.rail` — two-path differential eval
  catches silent miscompilation. Int-only grammar today.
- **Type-quirk lint** at `tools/lint/check_quirks.rail` — Q001/Q002/Q003 codes
  (het-list, high-arity, unwrapped float-return).
- **Perf trace** at `tools/trace/rail_trace.rail` — wall/CPU/RSS/page-faults/ctxsw
  + JSON sidecar.
- **Pkg manifest** at `tools/pkg/` — INI-style `rail.toml`; local-path deps in v0.
- **Stdlib docs autogenerator** at `tools/docs/gen_stdlib_ref.rail`.
- **Public docs site** at `https://ledatic.org/rail/docs/` — md→html build + Mini
  post-receive hook for auto-deploy on push to allowlisted `next` branch.

### What did NOT change

- **Self-hosting model.** `./rail_native` still compiles itself byte-identically
  at the 2-cycle fixed point on ARM64. No build-tool dependencies added.
- **Public API.** No removed or renamed stdlib symbols. No CLI flag changes.
  No `compile.rail` interface changes for downstream tooling.
- **Verification protocol.** Provenance v2 is backward-readable; v1 attestations
  still verify (they just don't carry the `pulse_id` binding).

### What is honestly not yet ready

- **Phase 3 outreach** has not been sent. The pitch v0 at
  `notes/phase3_external_pilot_pitch_v0.md` includes the JIT proof-point and the
  five-artifact substrate-honesty bundle, but no candidate partner has been
  contacted from Ledatic.
- **Playground deployment** is staged (16-gated dry-run-able), not live.
- **Industry adoption of substrate-not-model framing** is the goal. This release
  is the evidence package; the framing shift is still ahead.

### Stats

- 216 commits since `v3.11.0` (174 non-merge + 42 merge commits)
- ARM64 floor: 137 → 140 hardcoded list, with 12 new sidecar tests in `tools/test/`
  not yet auto-picked-up (9 float_int_ordering + 1 auto_memo_fib + 2 diagnostic)
- x86_64 floor: 55/60 → 136/136
- JIT tests: 6 → 9 (3 new cluster tests)
- Bootstrap: 2-cycle byte-identical, verified at every milestone

### Migration notes (none required)

If you depend on `next` HEAD, you're already on v4.0.0. If you depend on a
v3.x tag from master, the v3.x lineage continues there separately — it carries
the brain/agent/attestation work, not the compiler/runtime track.

## v3.7.0 — 2026-04-30 — Float-TCO root fix, mixed-precision inference, parallel rerank

Substantial substrate work. Seven commits, three real bugs (one fixed at
root, one workaround'd at source, one falsified), one substantial new
feature (Rail-native mixed-precision GPU inference), one substantial new
tool (parallel rerank wrapper), and a precise reproducer for one bug
that stayed open. 137/137 tests green; byte-identical self-bootstrap
verified.

### Compiler / runtime
- **Float-TCO root fix.** Re-added `body_has_float` guard to
  `all_params_int` in `tools/compile.rail:1992`. Closes a 17-day silent
  wrong-result bug introduced by commit `82516e4` (2026-04-13) that
  caused tail-recursive float helpers (e.g. `rms_row_apply`) to
  reinterpret float bits as ints in register-ABI calls, producing
  garbage. Headline affected sites: RMSNorm CPU path, AdamW weight
  decay, LayerNorm CPU backward. (`7752738`)
- **Runtime-mmap arena (A1.P4).** `RAIL_ARENA_MB` env var (default 1 GB,
  scales to 4 GB+ via mmap). Replaces the fixed 512 MB BSS arena that
  was bumping the macOS dyld static-data ceiling. envp passthrough via
  `_rail_envp` so env vars reach `./rail_native run` child processes.
  Long-context training (seq=2048+) now mechanically tractable on
  macOS. (`7752738`)
- **Diagnostic counters (A1.P5).** `alloc_stats_snapshot` returns 17
  ints now: per-class freelist misses (0–11), munmap_count (12),
  mmap_large_count (13), arena_spill_count (14), gc_count (15),
  arena_spill_bytes (16). Plus `RAIL_ARENA_TRACE=1` for stderr-emitted
  spill events. (`7752738`)
- **Parser multi-line compound expressions.** Cons chains, nested calls,
  list literals inside unclosed `(...)`/`[...]` now parse cleanly. Same
  post-tokenizer pass routes both `tokenize` and `tokenize_with_pos`.
  (`7752738`)
- **`./rail_native quick`.** 15 critical tests in ~5s, vs the full
  suite's 10+ min. Use between code edits. (`7752738`)

### Inference path
- **Rail-native mixed-precision matmul.** New Metal kernel
  `matmul_f32x_halfw` (fp32 activations × fp16 weights → fp32, fp32
  accumulator). Host wrapper `tgl_matmul_f32x_halfw_host` casts
  f64↔f32 once at the GPU boundary; Rail-side surface stays in f64.
  `stdlib/tensor.rail:matmul_mixed`. Primitive correctness:
  `max_abs_diff = 0.00042` vs f64 reference (vs 0.00082 for the
  all-fp16 path — 2× tighter). Byte-deterministic across 100+
  sequential calls. New harness at
  `tools/train/lm_infer_v3_mixed.rail`. Right substrate for d=384+
  scaling; not the d=256 winner today (CPU+KV-narrowing remains
  faster for current model). (`ee6bdce`)
- **Parallel rerank wrapper.** `tools/train/parallel_rerank.sh` fans
  out N inference subprocesses concurrently with distinct seeds,
  pre-compiling the harness once for amortization. Validated 7.1×
  wall-clock at N=8, ~11× projected at N=20 — bench projection drops
  from 2.25hr to ~13min for 30 prompts × N=20 rerank. `--bin <path>`
  flag (added in v3.7.0 as a follow-on) lets orchestrators skip the
  built-in pre-compile. (`ee6bdce`, `73043e2`)
- **`tools/train/parity_check.sh`.** Three-way diff harness running
  CPU (f64), GPU half (existing v3_half), and GPU mixed (new) on the
  same checkpoint+prompt+seed. Useful for reasoning about which
  precision path is producing which degenerate argmax token under
  undertrained models. (`ee6bdce`)
- **`tools/test/sequential_matmul_half_test.rail`.** Regression test
  verifying `tgl_matmul_half_host` is byte-deterministic across 1000
  sequential calls. Eliminates the "primitive corruption" hypothesis
  for any future GPU-collapse investigation. (`7752738`)

### Diagnostic infrastructure
- **`RAIL_GPU_POOL_DISABLE=1` env flag** in `tools/metal/tensor_gpu_lib.m`.
  Bypasses MTLBuffer pool best-fit reuse, forcing fresh
  `newBufferWithLength` on every acquire. Falsifies the standing
  hypothesis that pool reuse caused GPU sequential-inference collapse;
  with the flag set, collapse is byte-identical to baseline. (`7752738`)

### Inference workaround
- `tools/train/lm_infer_cpu.rail:gen_loop` no longer calls `arena_reset`.
  Eliminates a compiler-codegen interaction (between `arena_reset` and
  multiply-add expressions in `float_arr_set`) that corrupted
  `_rail_small_fl[0]` with the value being stored, surfacing as
  SIGSEGV in `_rail_chained_malloc` on a subsequent allocation. Bug
  was seed-deterministic (~50% of seeds at `--max 128 --k 10`), and
  silently confounded all post-04-13 single-sample compile-rate
  measurements. Workaround eliminates the trigger; per-iteration
  intermediate tensors now accumulate in the bump arena, which the
  default 1 GB easily holds for bounded inference runs. 30/30 stress
  tests pass. The compiler-level fix remains open with a precise
  one-line reproducer documented. (`f215039`)

### Documentation
- `docs/SESSION_HANDOFF_2026-04-30_EOD.md` — full afternoon arc.
- `docs/SPUR_HANDOFF_2026-04-30.md`, `docs/MODEL_SESSION_HANDOFF.md`,
  `docs/ROADMAP_2026-04-30.md` — morning arc + 6-month framing.
- `docs/RAIL_ENGINEER_SESSION_PROMPT_2026-04-30_NIGHT.md` —
  forward-looking prompt for the engineer picking up the open compiler
  bug + remaining substrate debt.
- Six new design notes: `arena-design.md`,
  `arena-leak-fix-strategy.md`, `data-section-quirk.md`,
  `backlog-deferred-design-notes.md`, `strict-typecheck-design.md`,
  `garmin-research-notes.md`.

### What was falsified (negatives)
- **GPU sequential-collapse "MTLBuffer pool reuse" hypothesis** —
  falsified via `RAIL_GPU_POOL_DISABLE`. Collapse byte-identical with
  pool off. Surviving cause: fp16 precision compounding across 22
  matmul round-trips/token (intrinsic, not a fixable substrate bug).
- **2026-04-15 "10 MB/step leak" hypothesis** — falsified by
  `arena_reset` chain-drain test (10 cycles, byte-tight). The
  allocator is sound; remaining leak suspects are GPU-side
  (MTLBuffer pool) or `gpu_available 0` re-eval churn.
- **Static 2 GB arena** — tested, breaks dyld at link time. 1 GB is
  the macOS BSS ceiling; runtime mmap (A1.P4) is the path beyond.

### Memory entries
Fifteen entries in `~/.claude/projects/-Users-user/memory/` capture
today's earned knowledge: substrate findings, discipline rules
(`feedback_verify_removals`, `feedback_diagnostics_first`,
`feedback_honest_backlog`), the dylib investigation chain, the
mixed-precision and parallel-rerank specs, and the segfault
bisection.

## v3.6.1 — 2026-04-27 — Compiler hardening

Two codegen + parser fixes; both gated by 2-pass byte-identical
self-bootstrap. 137/137 green.

- Undefined identifiers now fail at link time with a named symbol
  (`_RAIL_UNDEFINED_IDENT_<name>`) instead of silently producing a
  binary that segfaults at runtime. Codegen patched in both ARM64
  and x86_64 V-node "undefined" branches. (`77c4f5f`)
- Parser accepts multi-line compound expressions (cons chains, nested
  calls, list literals) inside unclosed `(...)`/`[...]` or before
  strictly-greater-indented `(`/`[`. New `strip_nl_pp` post-tokenizer
  pass routes both `tokenize` and `tokenize_with_pos` through the
  same logic so error positions stay aligned. (`f4f3e07`)
- Byte-identical self-bootstrap verified (`md5 14af7d5d…`).

## v3.6.0 — 2026-04-20 — Unified HTTPS client

Chain-walked verification is now the default. `https_get_url` /
`https_post_url` (and their `host ip port`-taking siblings
`https_get` / `https_post`) perform full X.509 chain validation
against `/etc/ssl/cert.pem` on every call. The previous leaf-only
drivers live on under explicit `https_*_unsafe_noverify` names for
tests that need to exercise the FSM without trust-store setup.

- `stdlib/https_client.rail` — `https_get` / `https_post` /
  `https_get_url` / `https_post_url` renamed to
  `https_get_unsafe_noverify` / `https_post_unsafe_noverify` /
  `https_get_url_unsafe_noverify` / `https_post_url_unsafe_noverify`.
  Function bodies unchanged; only the public names shifted.

- `stdlib/https_strict.rail` — the chain-walking drivers now own
  the unsuffixed public names `https_get` / `https_post` /
  `https_get_url` / `https_post_url`. The `_strict`-suffixed names
  are retained for one release as thin delegating aliases; they
  will be removed in v3.7.0.

- `stdlib/cert_chain.rail` — `cc_walk_chain` gained an SPKI-keyed
  termination check in `cc_walk_at`. Each chain cert's
  SubjectPublicKeyInfo DER is hashed and compared against the
  trust store's pre-computed root SPKI hashes. A match halts the
  walk immediately and treats the cert as trusted — which makes
  cross-signed roots (the GTS R4 variant with GlobalSign as its
  `issuer` field) validate cleanly even when the server's chain
  doesn't stop at a name-matching root.

- `stdlib/pem.rail` — `pem_load_trust_store` now emits a 7-element
  store (`[certs, lens, count, valid, sub_offs, sub_lens,
  spki_hashes]`). The extra parallel arrays cache the Subject TLV
  ranges and SHA-256(SPKI) digests of every root, so
  `ts_find_by_subject` is a cached byte-equal loop and the new
  `ts_has_spki_hash` is O(store size) with no per-lookup DER
  reparse. On the macOS 128-entry store this drops a two-lookup
  chain walk from ~60 s of parse overhead to sub-millisecond
  lookups.

- `stdlib/asn1.rail` — new `asn1_find_spki` + `asn1_find_sub_spki`
  finders return the TLV ranges for the SubjectPublicKeyInfo
  SEQUENCE (and, in the combined form, the Subject Name range
  too). Used by the trust-store loader for single-pass Subject +
  SPKI extraction per root.

- `stdlib/slack_client.rail` — `slack_post_text` now calls the
  chain-walked `https_post_url` default.

- `stdlib/anthropic_client.rail` — `anthropic_chat` explicitly
  calls `https_post_url_unsafe_noverify` with a docblock pointing
  at the blocker. `api.anthropic.com` chains up to GTS Root R4
  (P-384), and one pure-Rail P-384 signature verify in the chain
  walk takes ~90 s — past CloudFlare's handshake-idle budget, so
  the strict default can't complete a request before the server
  closes the socket. Moving `anthropic_chat` off the unsafe path
  is queued behind a P-384 scalar-mult optimisation in v3.7.0.

### Validation gates

- `./rail_native test` — 137/137.
- `./rail_native self` 2-pass → byte-identical fixed point.
- `tools/tls/https_strict_test.rail` — `https_get_url
  "https://www.amazon.com/"` → HTTP 200 (DigiCert root chain, RSA).
- `tools/tls/cc_spki_probe.rail` — every pre-computed root SPKI
  hash round-trips through `ts_has_spki_hash` (128/128 self-match).
- `tools/tls/mitm_chain_reject.rail` — a fresh self-signed
  ECDSA-P256 leaf with `CN=api.anthropic.com` is rejected by
  `cc_walk_chain` (status 0).
- Live Slack `chat.postMessage` via `slack_post_text` on the
  chain-walked default → `ok=1`, HTTP 200.

### Known gaps

- `https_get_url "https://api.anthropic.com/"` returns status 0:
  the chain walker accepts the GTS R4 chain, but the one P-384
  verify pushes total handshake time past ~90 s, and CloudFlare
  closes the socket before the client can send its Finished +
  request. Tracked for v3.7.0 (P-384 scalar-mult windowing /
  precompute).
- `https_get_url "https://slack.com/"` bus-errors in
  `hc_recv_response` on the 230 KB HTML body; unrelated to
  v3.6.0 (pre-existing Rail list/string-concat behaviour on very
  large bodies). Use a small API endpoint for chain-walked smoke
  tests.

## v3.5.0 — 2026-04-20 — Hardened HTTPS client + http_server

Two stdlib hardening passes plus a docs clarification.

- `stdlib/https_client.rail` — `hc_read_random` reads urandom into
  process memory via a shell pipeline (`dd | od -tx1 | tr`) instead
  of an intermediate tmp file. No functional change to callers;
  live chain-validated GET to `www.amazon.com` still returns 200.

- `stdlib/http_server.rail` — `serve_static` returns `400 Bad
  Request` on any request path containing `..` or `\` before
  resolving against the server root.

Also adds a note above the non-strict HTTPS drivers pointing
production callers at `https_get_url_strict` / `https_post_url_strict`
in `stdlib/https_strict.rail` for chain-walked verification.

### Validation gates

- `./rail_native test` — 137/137.
- `./rail_native self` 2-pass → byte-identical fixed point.
- `https_get_url_strict "https://www.amazon.com/"` → HTTP 200.

## v3.4.0 — 2026-04-19 — Ed25519 (RFC 8032 §5.1 verify)

`stdlib/ed25519.rail` now compiles and verifies clean against RFC
8032 TEST 1. Third modern-TLS signature algorithm alongside ECDSA
(P-256 / P-384 / P-521) and RSA-PSS / RSA-PKCS1 — Rail's pure-Rail
TLS stack now covers every sig_alg in wide deployment.

### What landed

- Canonical curve constant `ed_d_bytes` — `(-121665 *
  modinv(121666)) mod (2^255 - 19)`, LE-encoded,
  `a3785913ca4deb75abd841414d0a700098e879777940c78c73fe6f2bee6c0352`.
  The v3.3.0-handoff band-aid `ed_strip_ws` is gone.
- Scalar-mult (`ed_sm_iter`) and field exponentiation
  (`ed_pow_bytes_iter`) flattened from mutual recursion (A→B→A) to
  single self-tail-recursive drivers using `bit_total = byte_idx*8
  + bit_idx` as a decremented counter. Rail doesn't TCO mutual
  recursion, and the 512-bit SHA-512 scalar would have grown the
  stack 512 frames deep.
- Fixed a bug in `ed25519_verify_step3`: S-parse arg order was
  `sha_copy_bytes sig S 0 32 32` (src_off=0, dst_off=32 — writes
  past the end of a 32-byte S). Corrected to `32 0 32` so S =
  sig[32..64] ends up at S[0..32].
- `tools/tls/ed25519_test.rail` exercises RFC 8032 TEST 1
  (empty-message canonical vector) + two negative controls
  (flipped first byte of R, flipped first byte of S). `valid=1,
  bad_R=0, bad_S=0`.

### What's deliberately not wired

No live endpoint in our current caller set uses Ed25519 TLS
certificates. `stdlib/ed25519.rail` is therefore shipped as a
standalone module — it's not imported from `tls13_cert_verify.rail`
yet. Wire-in is a 10-line follow-up (sig_alg 0x0807 leaf dispatch +
an `ed25519_sig` OID entry in `asn1.rail`) once a live caller needs
it.

### Validation gates

- `./rail_native test` — 137/137.
- `./rail_native self` 2-pass → byte-identical fixed point.
- `ecdsa_p521_verify` still valid=1, bad_hash=0, bad_s=0.
- `ed25519_verify` RFC 8032 TEST 1 → valid=1, bad_R=0, bad_S=0.

Amazon.com was serving intermittent HTTP/2 503s during this
session — rule #6 (v3.3.0 handoff) applies, so the strict-HTTPS
live-endpoint gate was not re-confirmed this session. The TLS
stack was not touched in v3.4.0, so no regression is possible from
these changes.

## v3.3.0 — 2026-04-19 — HTTPS keep-alive sessions + ECDSA-P521

Two features land together in v3.3.0 because they share a test session
and both pass the full validation gate (137/137 tests, self-compile
byte-identical fixed point, live amazon.com strict + keep-alive both
green).

### ECDSA-P521 (secp521r1 / ecdsa-with-SHA512)

`stdlib/ecdsa_p521.rail` — structural clone of `ecdsa_p384.rail` with
33 × 16-bit limbs (528-bit storage for 521-bit values) and loop bounds
bumped from 383 → 520. All curve math reuses `bignum_n.rail` helpers;
no new primitives. `stdlib/cert_p521.rail` is the parallel-to-
`cert_p384.rail` chain-edge driver (SPKI extraction + ECDSA-with-
SHA512 verify using the issuer's P-521 pubkey). Wired into:

- `stdlib/tls13_cert_verify.rail`:
  - CertificateVerify leaf sigalg `0x0603` (`ecdsa_secp521r1_sha512`)
    now dispatches to `cv_verify_ecdsa_p521`.
  - Chain-walk: `ecdsa-with-SHA512` OID (1.2.840.10045.4.3.4) now
    dispatches to `cv_chain_p521`.
- `stdlib/asn1.rail`: adds `asn1_oid_p521_curve` (1.3.132.0.35) and
  `asn1_oid_ecdsa_sha512` OID references.
- `stdlib/tls13_client.rail`: pulls both new modules into the
  transitive import chain.

Live-verified with an OpenSSL-generated P-521 keypair and SHA-512
signature over "hello p521 from rail\n" — `ecdsa_p521_verify` returns
1 on the valid signature and 0 on both a flipped-byte-in-hash and a
flipped-byte-in-s negative control. Test at
`tools/tls/ecdsa_p521_test.rail`.

No live endpoint in our caller set uses P-521 today — this ships for
TLS completeness (any future service using ECDSA-P-521 leaves or
roots will now verify cleanly).

### HTTPS keep-alive sessions

**One TLS handshake, many requests.** `stdlib/https_session.rail` ships
`https_session_open` / `_open_strict` / `_get` / `_post` / `_close`: a
warm TCP+TLS socket across successive HTTP requests. For multi-turn
agents (Anthropic chat, Slack batch posts, any loop over a single API)
this collapses per-request latency from the full ~5–8 s handshake
(x25519 + chain walk to DigiCert) down to just the request
round-trip. The initial handshake happens once; every subsequent call
sends a fresh app-data record on the existing socket and reads the
response.

### Why the reverted v3.2.0-track attempt hung

Session open would complete the handshake, send ClientFinished, then
`session_get` would block in `recv` forever. Three overlapping causes
were live. v3.3.0 fixes each explicitly.

1. **Nagle buffering on the ClientFinished**. Bare CF is ~56 wire
   bytes. Macs default TCP sockets to NAGLE on, so the kernel holds
   the segment waiting for more data or an ACK. Without a follow-up
   write, the server sees a half-handshake and times out. v3.3.0
   ships `set_tcp_nodelay fd` (new helper in `stdlib/socket.rail`
   using the existing `setsockopt` foreign, `IPPROTO_TCP=6` +
   `TCP_NODELAY=1`) and calls it on every session socket right after
   `connect`.
2. **CF alone in flight is brittle**. Even with NODELAY, some
   middleboxes reject a CF that arrives without the first record of
   application data close behind. v3.3.0 pre-wraps CF into a TLS
   record *at open time*, stores it in the session handle as
   `pending`, and the first `session_get`/`_post` emits `(CF_record
   || app_record)` in a SINGLE `send()`. Subsequent requests clear
   `pending` and just send the app record.
3. **Post-handshake NewSessionTicket seq advance**. Servers emit one
   or two NSTs immediately after their own Finished. The session
   reader advances `s_seq` on every record it consumes — NSTs (inner
   rtype 22) included — so the application response lands on the
   right seq and AEAD decrypts cleanly.

### Response framing

`hss_recv` accumulates decrypted app-data fragments into a cons list,
materialises to a single buffer at finalise (O(total) not O(total²)),
and detects HTTP framing after `\r\n\r\n`:

- **Content-Length**: case-insensitive header lookup, drain until
  body length reached.
- **Transfer-Encoding: chunked**: scan for `0\r\n\r\n` terminator.
- **Neither**: fall back to "read until EOF" — same behaviour as the
  one-shot path; the session is not reusable after this, but the
  request still returns.

Live-verified against `https://www.amazon.com/` — two successive
`/` + `/robots.txt` GETs on one session, both returning HTTP 200 over
a chain-walked TLS 1.3 connection. Same `fd` on both requests.

### The arity-0 gotcha in socket.rail

The v3.3.0 track hit an unexpected regression midway: adding
top-level constants `ipproto_tcp = 6` / `tcp_nodelay = 1` to
`stdlib/socket.rail` made the previously-green `https_strict_test`
segfault inside the strict chain walk, while adding a function
defined in terms of those constants was fine. Inlining the two
integers at the one call site cleared it. The collision's root cause
isn't fully understood — nothing else in the codebase names those
symbols — but the workaround is cheap and documented in a comment at
the `set_tcp_nodelay` call site.

### Files touched

- `stdlib/https_session.rail` (new, ~330 lines)
- `stdlib/socket.rail` (added `set_tcp_nodelay`)
- `tools/tls/https_session_test.rail` (new, live 3-request smoke)

### Invariants held

- `./rail_native self` → byte-identical 2-pass fixed point.
- `https_get_url_strict "https://www.amazon.com/"` still returns
  HTTP 200 with chain-to-root validation.
- v3.0.0's Anthropic + Slack one-shot paths untouched.
- `./rail_native test` 137/137 — re-verify once the concurrent
  `lm_transformer` session on this machine releases `/tmp/rail_out`
  (test harness and training both write to that path).

## v3.2.0 — 2026-04-19 — Strict HTTPS by default + compiler quadratic fix

**Two structural fixes in `tools/compile.rail` eliminate the O(3^n)
and O(N²) wedges that had been silently taxing every stdlib-heavy
build since v3.0.0.** A 195-second compile on `https_client.rail +
pem.rail + cert_chain.rail` now completes in 12 seconds — 16× speedup
— which unblocks the strict-HTTPS default trust posture that v3.1.0
had to ship as a standalone primitive.

### `edit_dist` — did-you-mean Levenshtein was 3^n

The "did you mean?" suggestion machinery used a naive 3-branch
recursion (`edist_loop`: `d_del`, `d_ins`, `d_sub`) without
memoisation or bound propagation. It fired on every unbound-name
lookup across the full arity map (~300 keys on a stdlib-heavy
compile) — whether or not a warning was eventually printed. The
comment claimed "O(env_size · |fname|²) on the COLD path"; the
reality was exponential.

v3.2.0 ships `edist_bounded a b bound` — the threshold is 3 at the
call site, so any comparison whose distance is provably ≥ 3 can be
reported as 3 without exploring further. Two short-circuits make this
cheap:

- Length-gap bail: if `abs(|a| - |b|) ≥ bound`, return `bound`
  immediately. Most name pairs differ in length by more than 2, so
  this eliminates most of the search tree.
- Per-branch bound decrement: each mismatch consumes one unit of the
  bound, so no branch recurses more than `bound` levels deep.

Worst case is now `O(bound · (|a| + |b|))` per comparison — bounded
constant work for the fixed `bound=3`.

### `compile_funcs` — non-tail unwind was O(N²)

`compile_funcs` walked the declaration list with:

```rail
let (fasm, ...) = compile_func (head decls) ar lc
let (rest, ...) = compile_funcs (tail decls) ar lc1
(cat [fasm, rest], ...)
```

Each level left a frame on the stack (no TCO through the `cat`) and
re-copied a growing `rest` string on every unwind — quadratic in the
total emitted assembly. Rewritten as a tail-recursive
`compile_funcs_loop` with a cons-list accumulator and a single
`join ""` at the end: O(N) work, O(N) cons cells, O(1) stack.

### `stdlib/https_strict.rail` — strict HTTPS is now a one-line import

With the compiler freed, the chain-walk composition
(`https_client.rail` + `pem.rail` + `cert_chain.rail`) is tractable.
`stdlib/https_strict.rail` exposes the v3.1.0-shipped primitives as
plain stdlib functions:

```
https_get_strict        host ip port path            → (status, body)
https_get_url_strict    url                           → (status, body)
https_post_strict       host ip port path ct body hs  → (status, body)
https_post_url_strict   url ct body hs                → (status, body)
https_get_with_store    store host ip port path       → (status, body)
https_post_with_store   store host ip port path ct body hs → (status, body)
```

Each strict call loads the macOS system trust store
(`/etc/ssl/cert.pem`) and walks the cert chain to a root CA before
running the handshake FSM. `*_with_store` variants accept a
pre-loaded store to amortise the ~1.5 s PEM parse across many
requests.

Live, in production, the day of release:

```
https_get_url_strict "https://www.amazon.com/"
  → HTTP 200 with RSA chain validated leaf → DigiCert G2
    intermediate → DigiCert Global Root G2   (~8 s total)
```

### What this closes

The v3.1.0 CHANGELOG flagged "chain walk is not wired as the default
trust posture" as a known gap. v3.2.0 closes it. Users who want
authenticated HTTPS now write `import "stdlib/https_strict.rail"` +
`https_get_strict` / `https_post_strict`; leaf-only `https_client` is
kept for offline / self-signed / testing paths.

### Validation

- `./rail_native self` → byte-identical fixed point (two-pass
  confirmed).
- 129/132 core tests before the inherited t131-style harness hang
  (same point as v3.1.0 — this hang pre-dates both fixes, carry-over
  for a future session to untangle).
- probe4.rail (`https_client + pem + cert_chain + main`): 195 s → 12 s.
- Live strict test `tools/tls/https_strict_test.rail`: amazon.com
  returns real HTTP 200 with the full body decoded.

### Known gaps carried from v3.1.0

- HTTP keep-alive / session reuse — deferred to v3.3.0.
- ECDSA-P521, Ed25519 signature algorithms — deferred to v3.3.0.
- t131-style harness hang around `tls13_record_roundtrip` is
  non-deterministic and predates both v3.1 and v3.2 fixes; suspect
  the same family of compile-time pathology around tests with
  embedded long TLS transcript literals.

## v3.1.0 — 2026-04-19 — Streaming HTTPS bodies

**Response bodies scale linearly.** The v3.0.0 `hc_recv_response`
accumulated the HTTP response by re-joining a growing string on every
record and re-joining a growing string on every byte within each
record — two layers of `join "" [acc, chunk]` making total work
O(body_len²). Practical bodies capped at ~64 KB before walltime
exploded.

v3.1.0 replaces the accumulator with a cons list of byte-array chunks.
On terminate the driver allocates one buffer of the exact total size,
copies each fragment in O(total), then converts bytes → string in a
single `join` pass. Total work is O(body_len). Full-length Anthropic
completions, multi-hundred-KB HTML, chunked streams — all decode
without the old quadratic tax.

No API change: `https_get` / `https_post` signatures are unchanged.
`hc_recv_response` keeps the same arity (the old `acc` string arg is
now ignored, retained for source compatibility). The legacy
`hc_bytes_to_str_loop` is kept as an O(n²) shim for any external code
that depends on the exact old name; new code should call the new
`hc_bytes_to_str buf n`.

Verified live: `https_get api.anthropic.com` returns a real 404 with
the full Envoy response body parsed cleanly. No regressions in the
116-core / 18-TLS test suite.

### Known gaps carried from v3.0.0

- **Chain walk is not wired as the default trust posture.** The
  `stdlib/cert_chain.rail` walker + `stdlib/pem.rail` trust-store
  loader are shipped as standalone modules and validated
  end-to-end (see `tools/tls/chain_walk_amazon_test.rail`) but
  composing them with `stdlib/https_client.rail` in one compilation
  unit trips a quadratic pass in `tools/compile.rail` that makes the
  combined module unusable (3+ minute compile). Fixing the compiler
  is the unblock; queued as a v3.2.0 task. Until then, the default
  trust posture is v3.0.0's: leaf CertificateVerify sig + SAN +
  validity window. Chain-to-root users can assemble their own
  strict client using `cert_chain.rail` + `pem.rail` primitives
  (see the test module for the pattern).
- **HTTP keep-alive / session reuse** — deferred to v3.2.0.
- **ECDSA-P521, Ed25519 signature algorithms** — deferred to v3.2.0.

## v3.0.0 — 2026-04-18 — Rail speaks TLS

**Rail speaks HTTPS alone.** A complete TLS 1.3 stack, X.509 chain
validation, and HTTPS client — all in pure Rail, with zero C transitive
dependency beyond `as`, `ld`, and the kernel's BSD sockets. The `socat`
proxies that terminated TLS for v2.23's HTTP client are retired. Rail
programs now open an ordinary TCP socket, run a full TLS 1.3 handshake,
verify the server's cert signature against a CA root in the macOS
system trust store, and exchange encrypted records — talking directly
to `api.anthropic.com`, `slack.com`, `www.amazon.com`, and every other
modern HTTPS endpoint.

Live, in production, the day of the release:

```
anthropic_chat "claude-haiku-4-5-20251001" "Reply with exactly: hello from pure rail"
  → status 200, reply "hello from pure rail"   (6.9 s, pure Rail → Anthropic API)

slack_post_text "<DM_CHANNEL_ID>" "v3.0.0 smoke: pure-Rail TLS direct to slack.com"
  → ok, HTTP 200 with x-slack-req-id            (1.0 s, pure Rail → Slack API)

https_get_url "https://www.amazon.com/"
  → 200 with set-cookie, x-amz-rid              (4.0 s, RSA chain validated
                                                to DigiCert Global Root G2)
```

The full Google Trust Services chain for `api.anthropic.com` (leaf →
WE1 intermediate → GTS Root R4) validates to its CA root in macOS's
`/etc/ssl/cert.pem` — every edge cryptographically verified in Rail,
including the ECDSA-with-SHA384 WE1 → R4 signature on a P-384 curve key.

### Why v3.0.0 — the brand flip

v2.x Rail spoke HTTP. TLS rode on `socat` — a C program outside the
language. Rail could send bytes into a TLS tunnel but couldn't reason
about the tunnel. For a language whose tag line is *Rail runs on Rail,
the rest runs on physics*, that was the last external dependency on
the network path. v3.0.0 closes it. The `~/.fleet/tls_proxies.sh`
socat daemons are no longer on any critical path.

### What shipped

All of the following are new pure-Rail modules this release:

**Cryptographic primitives** (every one NIST- or RFC-vector-validated):

```
stdlib/sha256.rail          FIPS 180-4 SHA-256
stdlib/sha512.rail          FIPS 180-4 SHA-384 / SHA-512
                            (64-bit words emulated in pairs of
                             32-bit halves — Rail ints are 63-bit
                             tagged, so the full 64-bit MSB doesn't
                             fit natively)
stdlib/hmac.rail            HMAC-SHA-256 (RFC 4231 vectors 1/2/4)
stdlib/hkdf.rail            HKDF-Extract / HKDF-Expand (RFC 5869)
stdlib/chacha20.rail        RFC 8439 §2.3 — stream cipher
stdlib/poly1305.rail        RFC 8439 §2.5 — 130-bit MAC
stdlib/aead.rail            ChaCha20-Poly1305 AEAD (RFC 8439 §2.8)
stdlib/x25519.rail          RFC 7748 §5.2 — ECDHE key exchange
stdlib/ecdsa_p256.rail      ECDSA verify on NIST P-256 (secp256r1).
                            16 × 16-bit-limb bignums. RFC 6979 §A.2.5.
stdlib/ecdsa_p384.rail      ECDSA verify on NIST P-384 (secp384r1).
                            24 × 16-bit-limb bignums. RFC 6979 §A.2.6.
stdlib/rsa_pss.rail         RSA-PSS + RSA-PKCS1-v1.5, both SHA-256.
                            RFC 8017 §9.1.2 + §8.2.2. 128-limb modexp.
stdlib/bignum_n.rail        Parameterised n-limb arithmetic used by
                            the RSA and P-384 modules (n=128 and n=24
                            respectively). Bit-by-bit reduction.
```

**X.509 / ASN.1**:

```
stdlib/asn1.rail            Minimal DER parser. Walks TBSCertificate
                            to extract: version, serial, issuer,
                            subject, validity (notBefore + notAfter
                            UTCTime / GeneralizedTime), SPKI algorithm
                            OID + key bytes, extensions block.
                            Finds SubjectAltName + other extensions
                            by OID.
stdlib/b64.rail             Fast Base64 decode straight to byte
                            int-arrays. (The old base64.rail shelled
                            out per char — unusable for PEM bundles.)
stdlib/pem.rail             Multi-cert PEM iterator + trust store
                            loader. Reads `/etc/ssl/cert.pem` (128
                            roots on current macOS), indexes each
                            cert's Subject TLV for O(n) issuer
                            lookup.
```

**TLS 1.3**:

```
stdlib/tls13.rail           Key schedule: early_secret → handshake
                            secrets → master_secret → app traffic
                            secrets. HKDF-Expand-Label, Derive-Secret,
                            record-nonce XOR. RFC 8446 Appendix A.1
                            vectors exact.
stdlib/tls13_hs.rail        Handshake parsing: ClientHello builder,
                            ServerHello parser, EncryptedExtensions,
                            Certificate, CertificateVerify, Finished.
                            Multi-cert chain parse.
stdlib/tls13_record.rail    Record wrap/unwrap: AAD + inner-plaintext
                            framing, AEAD encrypt + decrypt with the
                            TLS 1.3 nonce construction (RFC 8446
                            §5.3).
stdlib/tls13_cert_verify.rail
                            CertificateVerify dispatch. Supports:
                              0x0403  ecdsa_secp256r1_sha256
                              0x0804  rsa_pss_rsae_sha256
                            Other sig_algs reject cleanly. Also
                            validates SAN hostname (RFC 6125 §6.4.3,
                            leftmost-label wildcard) and notBefore /
                            notAfter period against `date -u`.
stdlib/tls13_client.rail    Client handshake FSM. Composes
                            x25519 + key schedule + transcript
                            hashing + record layer + cert verify +
                            hostname match + validity period. Returns
                            application traffic secrets + client
                            Finished wire bytes.
stdlib/cert_chain.rail      Chain walker with shortest-path policy:
                            at each cert in the server chain, first
                            look up its Issuer in the trust store; if
                            found, verify this cert against that root
                            and return success — no need to keep
                            walking up.
stdlib/cert_p384.rail       P-384 pubkey extraction + chain-edge
                            verify. Kept in its own module to avoid
                            pushing tls13_cert_verify past the
                            compile.rail deeply-nested-if threshold.
```

**Application layer**:

```
stdlib/https_client.rail    `https_get`, `https_post`, plus URL-mode
                            convenience wrappers `https_get_url` +
                            `https_post_url`. Opens a TCP socket,
                            runs the handshake FSM, encrypts the
                            HTTP request with client app-traffic
                            keys, decrypts the response.
stdlib/dns.rail             UDP DNS A-record resolver. Reads
                            /etc/resolv.conf for the first nameserver,
                            builds the query, parses pointer-
                            compressed answers. So the URL wrappers
                            take a real hostname, not a pre-resolved
                            IP.
stdlib/anthropic_client.rail
                            `anthropic_chat model prompt max_tokens
                            key_path` — direct pure-Rail call to
                            api.anthropic.com /v1/messages. The
                            previous version required the socat
                            proxy; this one talks TLS natively.
stdlib/slack_client.rail    `slack_post_text channel text token_path`
                            — direct pure-Rail call to slack.com
                            /api/chat.postMessage. Same retirement
                            of the socat proxy.
```

### Trust posture at v3.0.0

A TLS connection through Rail v3.0.0 refuses to hand plaintext to the
caller unless **all of** the following hold:

1. The server's CertificateVerify signature checks out (ECDSA-P256-
   SHA256 or RSA-PSS-SHA256) against the public key embedded in the
   leaf cert's SubjectPublicKey.
2. The leaf cert's SubjectAltName dNSName entries include a match for
   the hostname the caller asked for (exact case-insensitive compare
   or a single-label wildcard match per RFC 6125 §6.4.3).
3. The current time (`date -u`) is within the leaf cert's notBefore /
   notAfter window.
4. The server Finished MAC validates under the negotiated traffic
   secrets.

The full chain walk to a CA root is available as `cc_walk_chain` for
callers that pass a trust-store handle explicitly (see
`tools/tls/chain_walk_test.rail` and `chain_walk_amazon_test.rail`).
Wiring chain validation on as a default in `https_get` is a v3.1.0
follow-up; v3.0.0 ships it as an opt-in building block so that the
default HTTPS path stays ~6 s per connection rather than ~12.

### Not shipping (honest limits)

- Single cipher suite: **TLS_CHACHA20_POLY1305_SHA256**. Our AEAD is
  ChaCha20-Poly1305. Servers that don't offer this will break the
  handshake; almost every TLS 1.3 server does.
- Single ECDHE group: **x25519**. No P-256 / P-384 ECDHE.
- Single sig-algs in CH: `rsa_pss_rsae_sha256 | ecdsa_secp256r1_sha256
  | rsa_pkcs1_sha256`. Servers that can't sign with any of these will
  fail to authenticate. Most CDN fronts can.
- No TLS session resumption, no 0-RTT, no client certificates.
- No constant-time or side-channel-resistance guarantees. Don't ship
  this to a Defense customer.
- Not a replacement for OpenSSL in a tight loop: each connection is
  5-8 seconds wall time, dominated by the public-key verify. Fine for
  one-shot API calls, not for an HTTP proxy.
- HTTP response body is assembled via `join ""` — O(N²). Caps cleanly
  around 64 KB. Streaming body is a v3.1.0 item.

### Tests

22 pure-Rail tests under `tools/tls/`, all green:

```
anthropic_live_test      — live call to api.anthropic.com, HTTP 200
slack_live_test          — live DM to <test-dm>, Slack ok=true
https_url_test           — https_get_url api.anthropic.com
https_smoke_test         — ip-based https_get api.anthropic.com
rfc8448_trace_test       — RFC 8448 §3 Simple 1-RTT vector, exact
asn1_cert_test           — X.509 DER parse on RFC 8448 §3 cert
san_match_test           — real api.anthropic.com SAN cert
ecdsa_p256_rfc6979_test  — RFC 6979 §A.2.5 P-256 "sample"
ecdsa_p256_negative_test — 4 tampered/zero ECDSA cases reject
ecdsa_p384_rfc6979_test  — RFC 6979 §A.2.6 P-384 "sample"
p256_bignum_test         — roundtrip / 2G / scalar-mul k=2
sha512_nist_test         — SHA-384/SHA-512 NIST "abc" + ""
b64_test                 — base64 decoder sanity
dns_resolve_test         — 3 hosts via UDP to system resolver
cert_verify_ecdsa_test   — self-signed ECDSA cert + sig
cert_verify_rsa_pss_test — self-signed RSA-2048 + PSS sig
chain_edge_test          — leaf → intermediate (ECDSA-SHA256)
chain_sha384_test        — WE1 → GTS R4 (ECDSA-SHA384 + P-384)
trust_store_test         — /etc/ssl/cert.pem loads 128 roots
trust_chain_root_test    — Rail chain edge → GlobalSign Root CA
chain_walk_amazon_test   — Amazon full chain → DigiCert Global Root G2
chain_walk_test          — Anthropic full chain → GTS Root R4
```

Plus the 116-test core Rail suite still at 116/116.

Self-compile 2-pass byte-identical preserved throughout — `rail_native`
still reaches its fixed point. No changes to `tools/compile.rail` were
required; everything this release is additive stdlib + tools.

### The arc

v1.x Rail compiled itself and ran numerical physics simulations.
v2.x Rail gained networks, trained transformers, shipped to Cloudflare.
v3.0.0 Rail calls `api.anthropic.com` by itself.

`Rail runs on Rail, the rest runs on physics.`

---

## v2.23.0 — 2026-04-17 — Rail talks to the internet

Rail gains a pure-Rail HTTP/1.1 client stack plus a new string primitive
that unblocks byte-level socket I/O. Combined with the TCP server
primitives in `stdlib/socket.rail` from v2.22.1, Rail now makes and
receives HTTP requests end-to-end with zero C-dependencies in the
request path. TLS (HTTPS) is handled via a local socat proxy — a
limitation documented in the socket.rail module header; native TLS is
a future pillar.

### New builtin: `char_from_int`

Inverse of `char_to_int`. Takes an int code point, returns a 1-char
string. Allocates 2 bytes via `_rail_chained_malloc`, writes the byte
+ null terminator, wraps via `_rail_wrap_str`. ARM64 codegen branch in
`cg` after the existing `char_to_int` handler. Registered in
`infer_is_heap_builtin` and the builtin type map as
`("int", "string", 1)`.

Why: sockets surface raw bytes, and Rail's `"\r"` escape is a known
compiler bug. `char_from_int 13` + `char_from_int 10` is now the
canonical way to synthesize CRLF at runtime, used throughout the new
HTTP stack.

116/116 tests pass. 2-pass self-compile produces byte-identical
`rail_native` (fixed point preserved).

### `stdlib/http_client.rail` — pure-Rail HTTP/1.1

```rail
http_get ip port path                           -> (status, body)
http_post ip port path ctype body               -> (status, body)
http_post_json ip port path body                -> (status, body)
http_get_hdr ip port path headers               -> (status, body)
http_post_hdr ip port path ctype body headers   -> (status, body)
```

Headers is a list of `"Name: Value"` strings. Custom headers let the
caller override auto-generated `Host` for SNI through a TLS proxy.

- HTTP/1.1 only, Content-Length or chunked-transfer-encoded response
  bodies supported (chunked decoder sits above the recv layer)
- CRLF synthesized via `char_from_int` — no reliance on the broken
  `"\r"` literal
- `\r\n\r\n` body boundary found via `str_find`, not `split`
  (which is single-char)

Known scope:
- **No HTTPS.** TLS termination is out of scope for v2.23; use a local
  `socat OPENSSL:host:443,snihost=host` proxy on the caller side.
- **No DNS.** Caller supplies numeric IP. Use `dscacheutil` or
  equivalent to resolve names.
- **Response cap ~64KB** due to O(N²) concat in `bytes_to_str`. Use
  `stdlib/strbuf.rail` if larger responses are needed; current design
  is tuned for small JSON bodies.

### `stdlib/mlx_client.rail` — generic OpenAI-style chat completions

Pure-Rail client for any mlx_lm.server or OpenAI-compatible
`/v1/chat/completions` endpoint:

```rail
mlx_chat ip port model prompt max_tokens temperature
  -> (status, content, reasoning)
```

Handles the R1-style reasoning/content split — `reasoning` is
non-empty for chain-of-thought models, empty for content-only models
(Gemma, Qwen without thinking mode). The helper is generic enough to
work against any OpenAI-shape server; it was developed against
mlx_lm.server but has no MLX-specific assumptions.

A small fix landed post-initial-ship: `extract_field` now skips
whitespace after the JSON colon so both `"k":"v"` and `"k": "v"`
response shapes parse correctly.

### Chunked-transfer decoder

`http_client.rail`'s response path auto-detects `Transfer-Encoding:
chunked` and unwraps the `<hex>\r\n<chunk>\r\n0\r\n` framing before
returning the body. Required for the Anthropic `/v1/messages`
endpoint which always chunks responses.

### Cross-compile progress (partial)

Hardcoded `/opt/homebrew/bin/aarch64-elf-as` path in `compile.rail`
because Rail's `shell()` does not inherit the parent process PATH.
Linker runs clean from macOS → Linux ARM64 ELF. A subsequent commit
adds symbol-strip (to kill duplicate Mac runtime stubs) and extra
`_accept`/`_send`/`_recv`/`_strtol` + young-gen BSS additions to
`linux_libc.s`.

**Known gap:** the linked Pi binary segfaults at runtime. Mach-O→ELF
ABI/alignment edge remains to be bisected. Pi deployment stays on
v2.22.x (Python transport + Rail CGI handler) until this lands.

### Public-vs-private split

Private infra pieces (fleet control-plane v3, DDA-specific `mlx_query`
CLI adapter, workspace-specific Slack/Anthropic clients) are kept on
an internal branch. Public `v2.23.0` ships the generic foundation
only — anyone can build their own fleet/ops/client layer on top of
`http_client.rail` + `mlx_client.rail`.

### `tools/train/corpus_gen.rail` — Phase 5 training corpus

The dead flywheel orchestrator's harvest is replaced with a deterministic
source-grounded corpus that the self-training loop can re-derive byte-for-byte
at any time. Walks `stdlib/`, `tools/train/`, `flywheel/`, `tools/deploy/`
(94 files, `tools/compile.rail` excluded), emits BPE-tokenized JSONL training
data in `training/corpus/v1/`.

### Three record kinds
- **function** — doc comment block (if any) + signature → body. ret_type
  comes from `rail infer` (Track B). Single-line definitions
  (`add x y = x + y`) are split on ` = ` so even one-liners contribute.
- **module_doc** — header `--` block at the top of a file → first ~10
  exported function signatures. Teaches "what this module is" → "what
  it exports".
- **repl_pair** — each `run_test "name" "src" "expected"` case in
  `tools/compile.rail`'s harness → input src as prompt, expected stdout
  as completion.

### Output (`training/corpus/v1/`)
```
vocab merges train.jsonl val.jsonl test.jsonl
```
Each line is a JSON object:
```json
{"id":"<sha256[0:16]>","source_file":"...","kind":"...",
 "prompt":"...","completion":"...",
 "prompt_ids":[...],"completion_ids":[...],
 "ret_type":"INT|FLOAT|HEAP|UNKNOWN"}
```
Train/val/test split by `SHA-256(rid_seed) mod 20` (0..17 → train,
18 → val, 19 → test).

### Counts (this run)
- 1636 valid records (1455 train / 92 val / 89 test)
- 1435 function · 92 module_doc · 109 repl_pair
- Completion length (BPE tokens): min 1 · median 60 · mean 120 · max 2011
- BPE vocab: 866 (target 1024, base ASCII vocab + 770 merges)
- BPE compression: 2.26× chars/token
- Function ret_type: 21.3% UNKNOWN (306 of 1435) — slightly over the
  spec's "<20%" gate, see notes below

### Determinism
Two consecutive runs with the same `rail_native` binary and unchanged
source produce byte-identical `train.jsonl` / `val.jsonl` / `test.jsonl`
/ `vocab` / `merges`. Verified with `cmp` after a re-run.

Sources of determinism:
- Sorted file walk (`find ... | sort`)
- Per-file `rail infer` shell-out with stable `RAIL_BIN`
- Hash-table `infer_table` (deterministic insertion order is irrelevant
  because we never iterate it — only `ht_get` lookups during emit)
- BPE merges deterministic (smaller-key tie-break per `stdlib/bpe.rail`)
- SHA-256 split bucket

Set `RAIL_BIN=/path/to/pinned-rail_native` to pin the inference binary
when a parallel session is rebuilding the in-tree `./rail_native`.

### Notes / pragmatic compromises
- **Sampled BPE training corpus.** `bpe.rail`'s `bpe_count_pairs` calls
  `length tokens < 2` per recursive step, and `length` is `O(N)` on
  cons-lists, so per-iteration cost is `O(N)` and total is
  `O(merges × N²)` — minutes-to-hours on the full 426K-char concatenated
  corpus. v1 trains BPE on an 8K-char head sample, which still covers
  every byte in stdlib's distinct-char set and learns 770 useful merges.
  Encoding of full records (per-string, short) is unaffected.
  Lifting this is a follow-up: either sample-then-merge wider, or fix
  `length` checks in `bpe.rail` to constant-time emptiness predicates.
- **Hash-table `infer_table`.** Initial implementation used a
  list-of-pairs lookup. With 4907 infer entries × 1844 function records,
  each lookup also walked `length` per step, so total cost was ~22B ops.
  Switched to `stdlib/hash.rail`'s `ht_put`/`ht_get`: amortized `O(1)`
  per lookup. (The "infer entries: N" diagnostic prints `length` of the
  underlying array, which is meaningless for an `ht`; the table is
  populated correctly — verified by 79% non-UNKNOWN ret_type coverage.)
- **CR stripping.** Several stdlib files (`args.rail`, `math.rail`,
  `fmt.rail`, `http.rail`, etc.) have CRLF line endings in comments.
  Rail's `"\r"` literal is a known unsupported edge case
  (`memory/rail-bugs.md`), so `json_esc` can't pattern match it
  directly. We `tr -d '\015'` each source file via shell before parsing.
- **`<20%` UNKNOWN gate missed by 1.3pp.** 21.3% is the genuine output
  of `rail infer` on this corpus — Track B can't infer ret types for
  many helper functions, deeply-recursive cross-file definitions,
  and tuple-returning constructors. Lifting requires either improving
  `rail infer` itself (out of scope here) or excluding files with
  poor inference coverage (e.g. `stdlib/tensor.rail` at 34% UNKNOWN).
  Shipping as-is; future inference improvements will lift it organically.

### Sample records

```jsonc
// function — stdlib/json.rail / json_int (note ret_type=INT from Track B)
{"id":"...","source_file":"stdlib/json.rail","kind":"function",
 "prompt":"-- Convert JSON num value to int\njson_int v =",
 "completion":"  let (_, n) = (head v, head (tail v))\n  n",
 "prompt_ids":[...], "completion_ids":[...], "ret_type":"INT"}

// module_doc — first 10 exported sigs as completion
{"id":"...","source_file":"stdlib/args.rail","kind":"module_doc",
 "prompt":"-- stdlib/args.rail — CLI argument parser\n-- Import: import \"stdlib/args.rail\"",
 "completion":"parse_args argv =\nparse_args_acc argv flags opts positionals =\n...",
 "ret_type":"UNKNOWN"}

// repl_pair — test src → expected stdout
{"id":"...","source_file":"tools/compile.rail","kind":"repl_pair",
 "prompt":"main = if 1 == 1 then 42 else 0",
 "completion":"42",
 "ret_type":"UNKNOWN"}
```

### Companion script
`tools/train/corpus_stats.rail` reads the JSONL splits + vocab and
reports record counts by kind, completion-length distribution in
tokens, vocab size, BPE compression ratio, and pct of function records
with non-UNKNOWN ret_type.

```
$ ./rail_native run tools/train/corpus_stats.rail
records total:    1636
  function:       1435
  module_doc:     92
  repl_pair:      109
completion tokens (per record):
  min:            1
  median:         60
  mean:           120
  max:            2011
BPE vocab size:   866
compression x100: 226  (chars/token)
function ret_type UNKNOWN: 306 of 1435  (21%)
```

## Post-v2.21 (unreleased) — `stdlib/bpe.rail`: byte-pair encoding tokenizer

`stdlib/tokenizer.rail` tops out at char-level — training on the 553KB
stdlib corpus would need `seq ≈ 553000`, versus the current usable cap
of `seq=382` on Shakespeare. `stdlib/bpe.rail` ships the merge-rule
tier of the BPE hierarchy that `tokenizer.rail`'s v2.3 header flagged
as "queued for v2.4". 10,000-char slice of `stdlib/tensor.rail`,
target vocab 512: 3.46x compression, byte-exact round-trip, byte-exact
save/load round-trip. Tests 106/106.

### API
```rail
import "stdlib/bpe.rail"
type BPE = | BPE merges vocab size
  -- merges : [(left_id, right_id, new_id)] in learn order
  -- vocab  : [string]  byte-sequence per token id
  -- size   : int       == length vocab

bpe_train      text target_vocab -> BPE
bpe_encode     bpe  text         -> [int]
bpe_decode     bpe  ids          -> string
bpe_save       path bpe          -> int   -- writes <path>.vocab + <path>.merges
bpe_load       path              -> BPE
bpe_vocab_size bpe               -> int
```

### Notes
- Base vocab is the set of distinct characters in the training corpus
  in first-appearance order (same discipline as `stdlib/tokenizer.rail`).
  Merged token IDs start at `base_size`.
- Training wraps the per-iteration pair-count BST in `arena_mark` /
  `arena_reset`: Rail's GC doesn't reliably reclaim hundreds of fresh
  BSTs built in a tight loop, and the smoke test reproducibly segfaulted
  at ~130 iterations without the mark/reset. Persistent state (tokens,
  vocab, merges) is allocated outside the mark.
- Vocab save format escapes `\\` / `\n` / `\t` so tokens containing
  those bytes survive one-per-line serialization.
- Decimal digit parsing uses string comparison (matching the
  `tools/train/hyperagent.rail` convention), not `char_to_int` —
  the builtin does not return the ASCII byte.
- The module does NOT integrate with `lm_transformer.rail`; the
  char-level tokenizer remains the default for existing training. A
  follow-up session will wire BPE into the training loop.

Smoke test: `tools/train/bpe_smoke.rail`.

## v2.17.0 – v2.21.0 (2026-04-15) — *Fork B session: Rail self-trains on Metal, end-to-end*

One long session. Eight commits. Takes Rail from "trains a tiny
transformer at d=16 via stale code with a broken dispatch" to
"trains d=64 transformer end-to-end, beats bigram baseline in a
fraction of the wall time, with Metal matmul dispatch actually
on the hot path." The strategic plan lives at `FORK_B_PLAN.md`.

### v2.17.0 — Fork B M1-M3 foundation
- Cleared stale `EXPERIMENTAL — INCOMPLETE` banner in
  `stdlib/autograd.rail` (the claimed 13-primitive gap was closed
  long ago; gradchecks 18/18 and 9/9 green).
- `tools/train/lm_transformer.rail` bumped to 2000 steps / warmup
  100. Final loss 2.098 on the Shakespeare excerpt — beats the
  v2.3 bigram baseline (2.10) and v2.4 attn-only (2.62).
- New `tools/metal/tensor_daemond.m`: long-lived ObjC binary
  wrapping `libtensor_gpu.dylib` over TCP 127.0.0.1:9302. Exposes
  23 kernels. C client bench: 0.44ms/call on 128x128 matmul
  (6.6 GFLOPS), proof that Metal-from-Rail is reachable in a
  hermetic sibling process.

### v2.18.0 — arity-gate channel `send`/`recv`
Compiler unblock. `compile.rail` codegen at `fname == "send"`
and `fname == "recv"` was intercepting ALL calls regardless of
arity, routing everything to `_rail_chan_send` /
`_rail_chan_recv`. Socket FFI calls (4-arg `send`/`recv` from
`stdlib/socket.rail`) silently dispatched to channel code and
segfaulted. Fixed with arity gates: arity 2 `send` and arity 1
`recv` → channel (unchanged). Higher arity → falls through to
normal FFI dispatch. Self-compile still byte-identical; tests
106/106. Rail code can now use BSD sockets directly.

### v2.19.0 — batched f32 file I/O (11x speedup on disk path)
Compiler unblock #2. `_rail_float_arr_to_f32_file` and
`_rail_float_arr_from_f32_file` were emitting **one syscall per
float**: 16,384 write(fd,ptr,4) calls per 64KB f32 file. Fix:
malloc N*4, fill/drain in a pure register loop, single
read/write syscall, free. Zero behavior change, 11x faster.

### v2.20.0 — simplify gpu_matmul_dispatch (60x speedup over CPU)
`ensure_dylib` was caching its result in a top-level
`float_arr` — but Rail re-evaluates nullary `name = expr`
bindings on every reference, so every matmul paid a shell
`test -f ...` existence check. The if-then-else branch with a
shell fallback also seemed to inhibit optimization. Fix: trust
`tgl_init` (idempotent), branch-free dispatch. Legacy
binary-file path preserved as `gpu_matmul_file` for debugging.
Result: ~2.8ms/call for 256x256 matmul via dispatch, vs 170ms
CPU — 60x speedup, visible in actual training.

### v2.21.0 — lm_transformer scale-up to d=64
Bumped `d` 16→64, `d_ff` 64→256, `init_weight` scale 0.05→0.01
(without the init fix, d=64 loss explodes from ~17), peak_lr
0.05→0.02. Added `arena_mark` / `arena_reset` around each
`train_step` so intermediate tensors don't accumulate.

**Result:** 500 steps at d=64 → loss **1.796** in 2.75 min.
That's a lower loss in 6× less wall time than the d=16 2000-step
run (which hit 2.098 in 17 min). Metal matmul dispatch is
genuinely on the hot path: the larger matmul sizes (seq=382 ×
d=64 × d=64 for attention, × 256 for FFN) cross the CPU/dylib
crossover.

### Also this session (intermediate commits, no version bump)
- `stdlib/tensord.rail` + `tools/metal/tensord_smoke.rail` —
  Rail client talks directly to `tensor_daemond` via
  `stdlib/socket.rail`. End-to-end verified. Measured 34ms/call
  at 128x128 (pre-batched-IO), 5.6ms/call (post-batched-IO).
- Scale benches: `tools/metal/bench_*.rail` sweep tensord vs
  CPU across sizes 128/256. Tensord wins 6.8x over CPU at
  256x256.
- `tools/train/rail_native_loop.sh` + launchd plist —
  unattended training harness ready to bootstrap. Kill switch
  at `~/.ledatic/data/.rail_train_kill`.
- `training/rail_native/{checkpoints,logs,data}/` directory
  layout + 553KB Rail stdlib corpus prebuilt at
  `training/rail_native/data/corpus.txt`.

### Latent compiler bug documented but not fixed
Top-level `name = expr` bindings (with no arguments) are
re-evaluated on every reference instead of memoized. Affects any
global cache pattern. Worked around in two places this session
(`tensord_fd_cache`, `gpu_dylib_flag`). A proper fix would
memoize nullary top-level defs with a static value slot + lazy
init on first call. Queued for next session.

### What didn't pay off
The `tensor_daemond` daemon path turned out orthogonal to the
main win. DLOPEN_STATUS.md (2026-04-13) claimed Rail→Metal dylib
segfaulted — today's test says otherwise: `tgl_matmul_f64` via
foreign FFI runs clean. The `__attribute__((constructor))` fix
in `tensor_gpu_lib.m` resolved the ObjC-runtime init issue, the
DLOPEN_STATUS write-up was just stale. The in-process dylib
ends up at ~1ms/call raw, while the daemon IPC is 5-25ms/call.
Daemon is preserved for cross-process ML serving and
future-proofing but is not on the current training hot path.

### Session retrospective
The three load-bearing compiler fixes (v2.18, v2.19, v2.20)
all fit the same pattern: a user-space layer was fighting an
overhead hidden in codegen or a stale cache. Each fix was
targeted and small, each enabled the next step downstream.
v2.21 was just "turn the dial" once the pipeline was clean.

## v2.16.0 (2026-04-14) — *WASM: fold with bare named functions*

Closes the last v2.15-deferred item.  Passing a bare **arity-2**
named function to `fold` now works under WASM.

### Change
`nf_rewrite_node` — the WASM pre-pass that η-expands named-fn
value references — gained an arity-2 branch.  It rewrites
`name` (for `name a b = ...`) into the curried form
`\__nfa -> \__nfb -> name __nfa __nfb`, matching the shape
`$fold` expects: `f(acc)` returns a 1-arg closure, which is
then called with the element.

### Proof
```rail
add a b = a + b
maxi a b = if a > b then a else b
mul a b = a * b

main =
  let xs = [3,1,4,1,5,9,2,6]
  fold add 0 xs                  -- 31
  fold maxi 0 xs                 -- 9
  fold mul 1 [1,2,3,4,5]         -- 120
```

All three run under `wasmtime`.  Higher-order ops
(`map`/`filter`/`fold`) now accept bare named fns of the
relevant arity in WASM.

## v2.15.0 (2026-04-14) — *WASM: named-fn map/filter + closure-capture fix*

Closes the last v2.13 caveat and an adjacent pre-existing bug.
Passing a bare named function to `map` / `filter` now works in
the WASM backend, and lambdas can now call top-level named
functions from their bodies.

### Changes

- **η-expansion pass** for named-fn value references.  During
  WASM compilation, a pre-pass rewrites every `V name` node
  (when `name` is a user function of arity 1 and the node is not
  in function-call position) into a synthetic lambda
  `\__nfx -> name __nfx`.  The existing lambda collection then
  assigns a function-table slot, so higher-order ops dispatch
  via `call_indirect` exactly as they do for user lambdas.

- **Top-level names excluded from closure captures.**
  `wasm_fvs_filter` drops arity-map entries (user fns,
  constructors, builtins) from a lambda's free-variable set.
  Before, a lambda that referenced a top-level name (e.g.
  `\x -> double x`) tried to capture `$double` as a local,
  producing `undefined local variable "$double"` at `wat2wasm`
  time.

### Scope

Arity-1 named fns only.  Fold and other higher-arity consumers
still need an explicit curry wrapper — the 2-arg curried form
expected by `$fold` requires a nested wrapper generator that's
deferred to v2.16+.

### Proof

```rail
double x = x * 2
big x = x > 3
square x = x * x

main =
  let xs = [1, 2, 3, 4, 5]
  map double xs                        -- [2,4,6,8,10]
  filter big xs                        -- [4,5]
  map (\x -> 0 - square x) xs          -- [-1,-4,-9,-16,-25]
  fold (\a -> \x -> a + x) 0 xs        -- 15
```

All four run under `wasmtime`.

## v2.14.0 (2026-04-14) — *Metal IR scaffold — JIT-compiled GPU kernels from Rail*

Ships the first cut of Rail's fifth backend: GPU compute kernels
defined in Rail (or hand-written Metal), compiled at runtime, and
dispatched on the Apple GPU.  The end-to-end pipeline runs today.

### `tools/metal/tensor_gpu_lib.m`
- **`tgl_unary_from_source(src, name, X, Y, N)`** — JIT entry
  point.  Takes a Metal source string and kernel name, compiles
  via `[MTLDevice newLibraryWithSource:]`, looks up the function,
  builds a pipeline state, and dispatches `N` float_arr elements.
  Both the `MTLLibrary` (keyed by source) and the
  `MTLComputePipelineState` (keyed by source+name) are cached —
  first call pays the ~ms compile cost, subsequent calls are pure
  dispatch.

### `stdlib/metal_kernel.rail`
- **`metal_kernel_header`** — boilerplate `#include
  <metal_stdlib>` + `using namespace metal;`.
- **`metal_kernel_sig`** — the canonical unary parameter list.
- **`emit_metal_unary name body_expr`** — splices a Metal C
  expression (`x > 0.0f ? x : 0.0f`, `(x * 2.0f) + 1.0f`, etc.)
  into the full kernel boilerplate.
- **`metal_apply_unary src name x_arr`** — JIT-compile + dispatch
  one call.  Returns a fresh `float_arr` of the same length.

### `tools/metal/rail_to_metal.rail`
- **`node_to_metal_expr node`** — translates a Rail AST subtree
  (FL / V / O / ?) into the equivalent Metal C-expression string.
- **`emit_unary_kernel name body_ast`** — full kernel boilerplate
  around a Rail-AST-derived expression.
- Demo emits `relu2` and `shifted` kernel sources from hand-built
  ASTs.  The next version will wire this to the Rail parser so
  `\x -> expr` lambdas become GPU kernels automatically.

### End-to-end proof
`tools/train/metal_kernel_demo.rail` JIT-compiles and dispatches
three kernels from Rail source strings:
- `relu2`: `x > 0.0f ? x : 0.0f` on `[-4..3]` → `[0,0,0,0,0,1,2,3]`
- `squared`: `x * x` on `[0..7]` → `[0,1,4,9,16,25,36,49]`
- `shifted`: `(x * 2.0f) + 1.0f` on `[0..7]` → `[1,3,5,7,9,11,13,15]`

All three compile and run on the Mini's Apple GPU in a single
Rail process.

### Tribal knowledge
**Reserved Metal identifiers**: `quad` collides with a private
typedef in the Metal SDK (`__Reserved_Name__Do_not_use_quad`).
When naming user kernels, avoid common graphics-primitive words
(`quad`, `tri`, `line`, `point`).  Error text is
`redefinition of 'quad' as different kind of symbol`.

**Scope for v2.14**: unary float-in float-out only.  Multi-input
kernels (N-ary matmul-style), reductions, and let-bindings are
deferred to v2.15+.

## v2.13.0 (2026-04-14) — *WASM map / filter / fold*

Closes the last `CLAUDE.md`-documented WASM gap.  Higher-order
list operations **`map`**, **`filter`**, and **`fold`** now work
in the WASM backend exactly as they do on ARM64 — as long as the
function argument is a lambda.

### Runtime (`tools/wasm_runtime.wat`)
- **`$call_closure (clos, arg) → result`** — the canonical
  closure-invocation helper: loads the function index from offset
  8 of the unshifted heap address and dispatches via
  `call_indirect (type $clos_t)`.
- **`$map (f, xs) → [f(x) | x ← xs]`** — iterates the list,
  reverses at the end to preserve input order.
- **`$filter (p, xs) → [x | x ← xs, p(x) ≠ tagged_false]`** —
  keeps elements where `p` returns anything other than tagged 0.
- **`$fold (f, init, xs) → acc`** — left fold.  The 2-arg
  function `f` is expected to be curried (`\a -> \x -> ...`),
  matching Rail's native calling convention: `f(acc)` returns a
  1-arg closure which is then called with the element.

### Compiler (`tools/compile.rail`)
- **Body-structure lambda lookup** — `wasm_find_lambda` now
  compares serialized bodies via `show` in addition to the
  parameter name + free-variable set.  Before, two lambdas with
  matching param names and identical captures (e.g. `\x -> x * 2`
  and `\x -> x > 2`) collided onto the same function-table slot;
  `filter` would then dispatch to `map`'s body or vice versa.
- **Type-and-table always emitted** — `$clos_t` and the element
  table are declared unconditionally (size-1 stub table when the
  program has no lambdas).  Previously the WASM module header
  skipped them entirely when `nlams == 0`, so any reference to
  `$call_closure` failed at `wat2wasm` time.

### Proof
```rail
main =
  let xs = [1, 2, 3, 4, 5]
  let doubled = map (\x -> x * 2) xs         -- [2,4,6,8,10]
  let bigs    = filter (\x -> x > 2) xs       -- [3,4,5]
  let sum     = fold (\a -> \x -> a + x) 0 xs -- 15
  0
```

### Tribal knowledge
**Lambda identity by body structure**: a structurally-identical
lambda at the source level (same param + same captures) maps to
ONE function-table slot; distinct bodies stay distinct.  If you
see a higher-order dispatch calling the wrong lambda, the
symptom is "my filter kept everything" or "my map squared instead
of doubled" — start at `wasm_find_lambda`.

**Named functions via map/filter/fold**: still not supported in
WASM.  Passing a bare named function (e.g. `map double xs`)
produces a tagged-int sentinel, not a closure.  Workaround: wrap
in a lambda (`map (\x -> double x) xs`).

## v2.12.0 (2026-04-14) — *Multi-head attention, learnable LayerNorm*

Closes the v2.5 transformer plateau.  A single-block, **4-head
causal transformer** with **learnable γ/β LayerNorm** and
Xavier-initialized weights trains to **loss 0.00433** on the
383-char Shakespeare corpus in 200 steps — crushing every prior
baseline:

|               | loss  |
|---------------|-------|
| uniform (log V) | 3.47 |
| v2.3 bigram    | 2.10 |
| v2.5 single-head | 2.90 |
| **v2.12 4-head + learnable LN** | **0.004** |

### `stdlib/transformer.rail` additions
- **`layernorm_backward_gb x mean rstd dy`** → `[dgamma_t, dbeta_t]`.
  Pure-Rail reduction; no new Metal kernel.  Gradients for γ and β
  come from the standard LN derivation:
  `dgamma[j] = Σ_r dy[r,j] · x̂[r,j]`,
  `dbeta[j]  = Σ_r dy[r,j]`, with
  `x̂[r,j] = (x[r,j] − mean[r]) · rstd[r]`.
- **`extract_head t n_rows full_dim head_dim h`** — copy column
  block `[h·head_dim .. (h+1)·head_dim)` out of `[n_rows, full_dim]`
  into a fresh `[n_rows, head_dim]` tensor.
- **`insert_head dst n_rows full_dim head_dim h src`** — scatter
  the per-head `[n_rows, head_dim]` buffer back into its column
  block of the full tensor (in place).

Used by forward/backward on a per-head basis; `attention_backward`
already composes softmax/matmul so multi-head reuses it as-is.

### `tools/train/lm_mh.rail` — v2.12 proof
End-to-end multi-head transformer LM:
- `d_model = 32, n_heads = 4, head_dim = 8, d_ff = 64`.
- Pre-norm block: LN → attn → residual → LN → FFN → residual.
- Separate attention-output projection `W_AO` (d→d) and LM head
  `W_LM` (d→V), both trained.
- Both LayerNorms have learnable γ and β, updated by Adam.
- Xavier init: `scale = sqrt(6 / (fan_in + fan_out))`.
- Weights + Adam states bundled into lists (12 parameter tensors
  each) to stay under the ARM64 register-spill budget.

Cosine-decay LR (peak 0.03, warmup 20) over 200 steps drives
cross-entropy loss from 4.30 → 0.004 on the 383-char corpus.

### Tribal knowledge
**ARM64 register spill limit**: functions with >30 parameters blow
up the frame-pointer-relative spill scheme (`str x31` is not a
valid ARM64 instruction).  **Workaround**: bundle weights/states
into lists, pass configuration as a small int list, and index via
`list_nth`.  Keeps parameter count well under 10 per function.

**Xavier init is essential**: the v2.11 init pattern
(`0.05 · raw`, `raw ∈ [−50, 49]`) gives weights in `[−2.5, 2.5]`
— huge for `d_model ≥ 16`.  Initial logits explode (loss 16 vs
uniform 3.5) and Adam can't recover in 200 steps.  Replacement:
`scale = sqrt(6 / (fan_in + fan_out))`, sampled as
`scale · (raw / 50)`, gives initial loss ≈ uniform.

## v2.11.0 (2026-04-14) — *WASM MHD to t=π*

Closes the last v2.10 caveat.  **The 2D Ideal MHD Orszag-Tang
vortex now runs to full completion under wasmtime**, 749 Lax-
Friedrichs steps to t=π, with exact mass/energy/divB conservation
throughout and real vortex physics (ρ_min: 2.778 → 1.269 → 2.363).

### Arena mark/reset in WASM runtime
- **`$arena_mark (dummy) → tagged_i32`** — snapshot `$heap_ptr` as
  a tagged-int handle.
- **`$arena_reset (handle) → tagged_1`** — restore `$heap_ptr`
  from the handle.  Everything allocated between mark and reset
  is reclaimed.

Matches the ARM64 arena API.  No Rail compiler changes needed —
the names dispatch through the user-function fallthrough to the
new runtime helpers.

### MHD sim loop pattern
```rail
sim_loop state ctx step max_steps =
  ...
  else
    let mk = arena_mark 0
    let ns = do_step state ctx step       -- allocates scratch + ns
    let _ = copy_state ns state state_size 0
    let _ = arena_reset mk                -- frees scratch + ns
    sim_loop state ctx (step + 1) max_steps
```
The persistent `state` buffer (allocated once in main) holds the
updated fields; everything else in the step is transient.  ctx
also lives outside the arena bracket, so t/m0/e0 survive.

### Verified under wasmtime (tools/plasma/mhd_wasm.rail, 128×128)
```
step   0: t=0.003771 dt=0.003771 divB=0    ρ_min=2.778
step 100: t=0.377    dt=0.003737 divB=0    ρ_min=2.418
step 200: t=0.759    dt=0.003927 divB=0    ρ_min=1.802
step 300: t=1.166    dt=0.004144 divB=0    ρ_min=1.381
step 400: t=1.582    dt=0.004193 divB=0    ρ_min=1.269  ← peak compression
step 500: t=2.009    dt=0.004371 divB=0    ρ_min=1.509
step 600: t=2.455    dt=0.004528 divB=0    ρ_min=2.183
step 700: t=2.916    dt=0.004715 divB=0    ρ_min=2.363
Reached t=π at step 749
```
- **dm** (mass drift): exactly 0 to f32 precision at every reported step
- **dE** (energy drift): exactly 0
- **divB**: 0 throughout (Lax-Friedrichs preserves Orszag-Tang's initial divergence-free magnetic field)
- **dt** adapts via CFL 0.003771 → 0.004715 as max speed drops
- **ρ_min** follows the full compression–bounce cycle of the vortex
- 50 frames dumped (no-op under WASM file stubs, but the print hooks fired)
- Runtime: ~6 minutes wasmtime wall time on M4 Pro

### Counters
- WAT runtime: 39 KB (unchanged — runtime grew by ~20 lines, compile.rail unchanged).
- Tests: 106/106.
- Fixed-point self-compile: preserved.

### Session close
This closes the v2.9 WASM-MHD chain of deferrals:
  v2.7 (float stack) → v2.8 (transcendentals) → v2.9 (compiler bugs)
  → v2.10 (param inference + tail calls) → **v2.11 (arena + full sim)**.

## v2.10.0 (2026-04-14) — *WASM MHD simulates*

Closes both caveats from v2.9:  cross-function float PARAM inference
and WASM tail-call elimination.  **The 128×128 Orszag-Tang MHD
vortex now simulates under wasmtime** with exact mass/energy/divB
conservation through 100 Lax-Friedrichs steps.

### Float PARAM inference (WASM)
- **`all_call_sites`** walks every function body tracking a local
  env that extends with `__wfloat_<name>` markers on let-bindings.
  For each call site, precomputes a `[arg_is_float_flag]` list.
- **`caller_passes_float fname i sites`** scans the flags: if any
  caller passes a float to slot `i` of `fname`, that param gets a
  `__wfloat_<pname>` marker in the callee's emit env.
- Extended **`is_float_w`** to recurse through `D` (let-binding),
  `TD` (let-discard), and `M` (match) nodes.  Previously function
  bodies that used `let ... in` always returned false, so the
  fixed-point pass missed everything but the most trivial float-
  returning wrappers.  Now a chain like
  `compute_dt state = let smax = max_speed state; k / smax` is
  correctly detected as float-returning.

### WASM tail-call elimination
- **`wg_tail`** — tail-position variant of the WASM codegen.  Emits
  `return_call $fname` at the leaf of the tail chain, replacing
  stack frames.  Propagates tail position through `?` / `D` / `TD`
  / `M`; companion `wg_match_tail` handles match arms.
- `wasm_fn` now drives the body via `wg_tail` (the function body IS
  in tail position).  Non-tail-callable dispatches (builtins,
  constructor allocs, foreign calls, closures) fall back to
  regular `wg`.
- `dispatch_wasm` passes `--enable-tail-call` to `wat2wasm`.
- **Verified**: `count_down 100000` — 100,000 recursive calls —
  completes under wasmtime.  Previously blew the stack past ~10k.

### Memory bumping
- WASM module memory: `16 1024` → `4096 65536` pages (1 MB → 256 MB
  initial, 4 GB max).  MHD's Lax-Friedrichs step allocates fresh
  scratch float_arrs per call; without a reaping GC the bump
  allocator needs headroom.

### MHD end-to-end under WASM
**`tools/plasma/mhd_wasm.rail`** (128×128 grid, 100 steps):
```
Initial: mass=45511.111111121 energy=71907.555560786 divB=0.000000000
step 0  t=0.003771498 dt=0.003771498 dm= 0.000000000 dE=-0.000000000 divB=0.000000000 rho_min=2.777777777
step 50 t=0.191056954 dt=0.003726548 dm=-0.000000000 dE= 0.000000000 divB=0.000000000 rho_min=2.664129704
Done: 100 steps, t=0.373662040
```
- **Mass drift**: 0 (exact, down to f32 precision)
- **Energy drift**: 0 (exact)
- **div B**: 0 (Orszag-Tang has no monopoles; Lax-Friedrichs preserves this)
- **rho_min**: 2.778 → 2.664 — real vortex compression, the physics is evolving
- **dt**: 0.003771 → 0.003726 — correct CFL adaptation as max speed grows
- Runs in ~4 minutes of wasmtime wall time on M4 Pro.

### Counters
- Tests: 106/106.
- Fixed-point self-compile: preserved.
- WAT runtime: 39 KB (unchanged — all compiler-side).
- Regression: Adam XOR, three_class_mlp, wasm_diffuse, 100k-recursion — all pass.

### Still queued for v2.11+
- WASM GC / arena reset so MHD can run the full 800-step simulation
  to t=π without bump-allocator exhaustion.
- Multi-head attention + learnable LN γ/β for the transformer LM.
- `#metal_kernel` directive / Metal IR backend.
- LSP hover + jump-to-def.

## v2.9.0 (2026-04-14) — *WASM compiler maturity for compute kernels*

The WASM backend gains the structural plumbing needed to compile
real numerical programs.  `tools/plasma/mhd.rail` now compiles to
`.wasm` and runs in `wasmtime`; the init phase produces the
correct conservation values (mass = 711.111 = 2.778 · 256 cells
to f32 precision; div B = 0).

### Compiler fixes (4 distinct WASM bugs closed)

1. **0-arg user functions referenced bare returned constant 1.**
   `let n = total state` in main was compiling to `i64.const 1`
   instead of `call $total`.  Caught when the MHD `nn = 16` constant
   evaluated to 1 instead of 16.  Patched the V-handler to emit
   `call $<name>` when the resolved arity is 0 and the name isn't
   a constructor.  This was a pre-existing bug, not new in v2.7.

2. **User functions returning float weren't tracked.**  Wrappers
   like `get state f x y = float_arr_get state (idx f x y)` were
   treated as int-returning at call sites, so subsequent float
   arithmetic untagged garbage.  New
   `wasm_collect_float_fns_env` runs a fixed-point pass over the
   decl list (up to 5 iterations) marking each function whose body
   is structurally float.  Markers ride in the per-function env as
   `__floatfn_<name>` entries; `is_float_w` consults them at A-node
   call sites.  Marker check uses `!= -1` (not `>= 0`, which missed
   the negative slot indices).

3. **High-byte UTF-8 escape bug in WAT data emit.**  `wasm_cc`
   was returning signed integers for bytes >= 128 (em-dash and
   similar) because macOS `printf '%d'` returns negatives for
   high bits.  Patched to mask: `(raw + 256) % 256` if raw < 0.

4. **`cat`, `write_file`, `read_file`, `append_file`, `shell` had
   no WASM dispatch.**  Added `cat` to the A-node builtin chain
   (delegates to `$cat → $join` with empty separator).  File I/O
   shipped as no-op stubs (return tagged 1 / empty string) so
   compute kernels that diagnostic-dump to disk compile and run
   under wasmtime — the dumps are just discarded.

### Test infrastructure
- `/tmp/farr_user.rail` — minimal user-wrapper float test.  Returns
  `sum = 14.000000000` exactly when looping `0..7` writes
  `i*0.5` through `my_put` then sums via `my_get`.

### MHD-on-WASM smoke proof
- **`tools/plasma/mhd_wasm.rail`** — 16×16 Orszag-Tang vortex
  variant of the 128×128 ARM64 MHD simulator.  Grid scaled down so
  the `init_loop` recursion depth fits the WASM stack (no native
  tail-call elimination in this backend yet).  File dumps stripped.
  Runs under wasmtime: init produces `mass = 711.111111111`,
  `energy = 1123.555555740`, `div B = 0` — matching the expected
  conservation invariants of the Orszag-Tang vortex.

### Known WASM limitations (queued for v2.10+)
- **No tail-call elimination.**  Recursive helpers blow the stack
  past ~10k iterations.  WASM's `return_call` proposal is supported
  by wasmtime; emitting it from Rail would lift the limit.
- **Cross-function float-PARAM inference still missing.**  The
  fixed-point pass tracks float RETURNS but not what each callee
  expects from its callers.  MHD's per-step time accumulation
  through a float ctx parameter exposes this — `dt` and `t` come
  out as garbage even though init is correct.  Workaround: pass
  floats only via float_arr (the same idiom that works around the
  ARM64 limitation).

### Counters
- WAT runtime: 38 KB → 39 KB.
- Tests: 106/106.
- Fixed-point self-compile: preserved.
- Adam XOR + three_class_mlp regression: still pass.

## v2.8.0 (2026-04-14) — *WASM transcendentals (~9-digit accuracy)*

Closes the v2.7 deferral list: **sin / cos / exp / log / pow / tanh
all run natively under wasmtime via Taylor-series polyfills**, and
`show_float` bumped from 6 to 9 decimal digits to surface the new
precision.

### Polyfill design
- **`$sin`** — range-reduce to `[-π/2, π/2]` via 2π-modulo + π-flip,
  then Horner-form Taylor with 12 terms (factors `x²/(2k(2k+1))` for
  k=1..6).  Worst-case error at the boundary: `x¹³/13! ≈ 6e-10`.
- **`$cos`** — own Taylor series (no longer `sin(x + π/2)`, so
  `cos(0)` is exact-1 instead of `1.000003`).  Range-reduce to
  `[-π/2, π/2]` with the `cos(π - x) = -cos(x)` identity, then
  Horner with 6 pairs.
- **`$exp`** — split `x = k·ln(2) + r` (`r ∈ [-ln(2)/2, ln(2)/2]`),
  Taylor `exp(r)` with 8 terms in Horner form, multiply by `2^k`
  via direct construction of the f64 exponent field
  (`(k + 1023) << 52`, then `f64.reinterpret_i64`).
- **`$log`** — extract f64 exponent → `k`, mantissa → `m ∈ [1, 2)`.
  If `m > √2` divide by 2 and bump `k`.  Then Taylor on
  `log(1 + t)` with `t = m - 1 ∈ [-0.293, 0.414]`, **14 terms**.
  Result = `k·ln(2) + sum`.  Worst-case error: `t¹⁵/15 ≈ 5e-9`.
- **`$pow(x, y) = exp(y·log(x))`**.  Edge: `x = 0 → 0`.
- **`$tanh(x) = (e^(2x) - 1) / (e^(2x) + 1)`**, saturated at ±1
  for `|x| > 20`.

### Verified accuracy (vs true values, all 9 digits printed)
| Call | WASM result | Notes |
|---|---|---|
| `sin(0)` | 0.000000000 | exact |
| `sin(π/2)` | 1.000000000 | exact (was `1.000003` in v2.7) |
| `cos(0)` | 1.000000000 | exact (was `1.000003` in v2.7) |
| `exp(1)` | 2.718281828 | matches `e` to 9 digits |
| `exp(2)` | 7.389056098 | matches to 9 digits |
| `log(e)` | 0.999999989 | 8-digit accuracy (1.1e-8 error) |
| `log(10)` | 2.302585092 | 9-digit accuracy |
| `pow(2, 10)` | 1024.000000000 | exact |
| `tanh(1)` | 0.761594155 | matches to 9 digits |

The diffusion smoke proof from v2.7 now reports `‖[0..15]‖₂ =
35.213633723` (true: `35.213633723180…`).  9 digits exact.

### `show_float` precision bump
- 6 → 9 decimal digits.  Buffer was already 32 bytes after the
  4-byte length prefix; no allocation change.

### Counters
- WAT runtime: 35 KB → 38 KB (still well under wasmtime's
  reasonable limits).
- Tests: 106/106.
- Fixed-point self-compile: preserved.

## v2.7.0 (2026-04-14) — *WASM floats end-to-end*

The WASM backend gains real floating-point support.  Before this
release `grep -c "f64\|float" tools/wasm_runtime.wat` returned 0.
Now Rail compiles float-heavy programs (literals, arithmetic on
variables, float arrays, sqrt) to standalone .wasm and they
execute correctly under `wasmtime` with no runtime imports.

### Calling convention
- Rail's WASM backend uses i64 throughout the operand stack — same
  as the rest of the v2.6 codegen.  Float values travel as **raw f64
  bits stored in i64** (`i64.reinterpret_f64` round-trips).  This
  matches the ARM64 backend's convention where float values pass as
  raw bits in x-registers.

### Float literal `FL`
- New WASM codegen case in `compile.rail::wg`:
  `f64.const <literal>; i64.reinterpret_f64`.  A Rail `3.14` lands
  on the i64 stack as the bit pattern of `3.14` in IEEE 754.

### Float arithmetic with variable-aware type inference
- New `is_float_w node env` mirrors the ARM64 `is_float`.  V nodes
  (variable references) are flagged as float when the env contains
  a synthetic `__wfloat_<name>` marker, which the let-binding (D)
  emitter inserts whenever the bound expression is structurally
  float.  This lets `let x = 3.0 in let y = 4.0 in x + y` compile
  to f64 arithmetic without manual annotations.
- New `wasm_fop` table emits `f64.add / sub / mul / div` for
  arithmetic and `f64.eq / ne / lt / gt / le / ge` for comparisons.
  Comparisons return tagged-int booleans so they slot back into the
  existing if/then/else machinery.
- Mixed-type promotion: when one operand is float and the other is
  a tagged int, `wg_as_float` promotes via `i64.shr_s + f64.convert_i64_s`.

### Float arrays in WASM memory
- New `$float_arr_new`, `$float_arr_get`, `$float_arr_set`,
  `$float_arr_len` in `tools/wasm_runtime.wat`.  Layout matches
  ARM64: bytes [0..7] hold the length as raw i64, bytes [8..]
  hold the f64 payload.  The Rail handle is `(byte_ptr << 1)` so
  the LSB tag bit reads as 0 (pointer), matching cons cells.

### Conversions
- `$int_to_float` (= `$to_float`) — `i64.shr_s + f64.convert_i64_s
  + i64.reinterpret_f64`.
- `$float_to_int` — `f64.reinterpret_i64 + i64.trunc_f64_s + tag`.

### Math intrinsics
- `$sqrt`, `$fabs`, `$floor`, `$ceil` ship as native WASM intrinsics
  (`f64.sqrt`, `f64.abs`, `f64.floor`, `f64.ceil`) wrapped to take
  i64-bits and return i64-bits.
- `$show_float` formats an f64 to a `<int>.<6 decimal digits>`
  string with sign handling, by reusing the WASM heap allocator.
- **Deferred to v2.8**: `sin / cos / exp / log / pow / tanh`.  These
  have no native WASM intrinsics and need Taylor-series polyfills
  with proper range reduction (~150 lines of WAT each).

### Smoke proof
- **`tools/train/wasm_diffuse.rail`** — 1-D box-filter diffusion on
  a 16-cell float_arr, 50 passes, with L2 norm via sqrt.  Steady
  state of a linear ramp matches analytic prediction:
  `[0, 8, 15]` invariant, `‖[0..15]‖₂ = √1240 = 35.213633` (6
  digits agree).  Compiles to a 31KB .wat, runs in wasmtime.

### Tooling fix
- `dispatch_wasm` now prepends `/opt/homebrew/bin:/usr/local/bin`
  to the shell `PATH` before invoking `wat2wasm` and `wasmtime`,
  since the Rail `shell` builtin doesn't inherit the user's
  interactive PATH.

### Known limitations (v2.7 → v2.8)
- Recursive float function parameters not auto-inferred.  Use a
  1-element float_arr accumulator (the same idiom that works
  around the equivalent ARM64 limitation).
- Non-ASCII chars in string literals fail wat2wasm escape.
- Transcendental math foreigns (sin/cos/exp/log/pow/tanh) absent.

### Counters
- Dylib export count: 25 (unchanged — WASM is a separate backend).
- Tests: 106/106.
- Fixed-point self-compile: preserved.
- Adam XOR + three_class_mlp regression: still pass.

## v2.6.0 (2026-04-14) — *Compiler/REPL polish*

Two small but high-leverage quality-of-life improvements.  No new GPU
kernels, no new training math.

### Did-you-mean for unbound identifiers
- **`compile.rail::find_closest_name` + `edit_dist`** — Levenshtein
  edit distance over the arity-map keys.  When code generation
  reaches the user-function fallthrough with a name that's neither
  in the arity map, the local env, nor a self-call, the compiler
  emits a `WARNING: 'foo' is not defined; did you mean 'bar'?`
  before the assembler produces its cryptic linker error.
  Threshold: edit distance ≤ 2.  Skips compiler-internal names
  (those starting with `_`).  Cold path only — zero cost on
  successful compiles.
- Confirmed: a typo'd `doubel 21` against a defined `double` now
  prints `WARNING: 'doubel' is not defined; did you mean 'double'?`.

### REPL `:load file.rail`
- **`tools/repl.rail::repl_load`** — `:load <path>` reads a source
  file, drops bare `--` comments and the trailing `main = ...`
  block, and appends the remaining top-level decls to the running
  REPL session.  Subsequent expressions can call any function
  defined in the loaded file.  Verified by piping
  `:load /tmp/foo.rail` followed by `double 21`, `square 7`,
  `triple 5` — all return correct results.
- The REPL's per-expression definition persistence already worked
  in v2.0; this release just adds the file-slurp shortcut.

### Counters
- Dylib export count: 25 (unchanged).
- Tests: 106/106.
- Fixed-point self-compile: preserved.

## v2.5.0 (2026-04-14) — *Pre-norm transformer block*

Adds the missing transformer infrastructure: **LayerNorm forward+backward,
residual connections, and FFN block**.  All gradients verified against
finite differences (LN 9/9, attention 18/18).  The v2.4 single-head
attention LM grows into a proper pre-norm transformer block.

### LayerNorm
- **`stdlib/transformer.rail::layernorm_save`** — forward variant that
  returns `(y, mean, rstd)`, populating two extra float_arrs of length
  `n_rows` for use by backward.
- **`stdlib/transformer.rail::layernorm_backward_dx`** — Rail wrapper
  around the existing `tgl_layernorm_backward_f64` dylib export.
  Returns dx as a Tensor.  γ/β fixed at (1, 0) for v2.5; learnable
  γ/β + dgamma/dbeta accumulation queued for v2.6.
- **Hot fix in `layernorm_rows`.**  Same `0.0 + dim` bug pattern as
  `head_dim` in v2.4 — `dim` extracted from the shape list via
  `head (reverse x_shape)` was being passed to `+` as a tagged int,
  producing subnormal-bit divisions.  All forward LN was returning nan
  before this.  Replaced with explicit `int_to_float`.
- **`tools/train/layernorm_gradcheck.rail`** — central-difference
  gradcheck on dx for n_rows=3, dim=3.  All 9 slots PASS at f32
  tolerance (analytic and numerical agree to ~1e-13 absolute).

### Pre-norm transformer block
- **`tools/train/lm_transformer.rail`** — full pre-norm block:
  ```
  ln1   = LayerNorm(x_pe)
  attn  = causal_self_attention(ln1)
  x_attn = x_pe + attn          ← residual #1
  ln2   = LayerNorm(x_attn)
  ffn   = ReLU(ln2 @ W_F1) @ W_F2
  x_blk = x_attn + ffn          ← residual #2
  logits = x_blk @ W_O
  ```
  7 trainable parameter tensors (W_E, W_Q, W_K, W_V, W_O, W_F1, W_F2),
  Adam updates on every one.  Backward chains through residuals, FFN,
  LN2, attention, LN1, and embedding — all composing existing
  primitives, no new GPU kernels.

### Result on the v2.3/v2.4 test bench
| Run | Loss | vs uniform 3.47 | vs bigram 2.10 | vs v2.4 attn-only 2.62 |
|---|---|---|---|---|
| v2.5 pre-norm + LN + FFN, 500 steps, lr=0.05 | **2.90** | beats | does not beat | does not beat |

The architecture is mathematically correct (gradchecks pass) and trains
stably, but on this 383-char corpus the added LN + FFN complexity does
not outperform the simpler v2.4 architecture.  This is a known
small-data / small-model phenomenon — closing the gap is queued for
v2.6+ (learnable γ/β, multi-head attention, larger d_model, regular
weight decay, longer training).

### Counters
- Dylib export count: 25 (unchanged).
- Tests: 106/106.
- Fixed-point self-compile: preserved.
- Numerical gradchecks: attention 18/18, LN 9/9.

## v2.4.0 (2026-04-14) — *Movement V: attention end-to-end*

Closes the transformer-training spine: **attention backward + a
single-head causal transformer LM that trains end to end on
Shakespeare**.  No new GPU kernels — all of attention's backward chain
composes from existing primitives (matmul, transpose,
`tgl_softmax_backward_f64`, `tensor_scale`).  Embedding gradients use a
one-hot matmul, so `dW_E = X^T @ dX_pe` falls out for free without a
dedicated scatter-add kernel.

### Compiler / stdlib hot fix
- **`stdlib/transformer.rail` int→float coercion bug.**  Three call
  sites used `0.0 + head_dim` to promote the int extracted from
  `head (tail q_shape)` into a float for `sqrt`.  The Overture #1 fix
  in v2.2.0 covers most cases of this pattern but the deeply-nested
  match position was still emitting raw tagged-int bits — `sqrt(2)`
  was being passed `4.94e-323` (the subnormal that bits-cast to
  tagged-int 5) and returning `4.97e-162`.  Replaced all three sites
  with explicit `int_to_float head_dim`.  All forward-attention
  callers were producing nan before this fix.

### Attention backward
- **`stdlib/transformer.rail::attention_backward`** — pure-Rail
  composition.  Given the saved forward `attn` (post-softmax) and the
  upstream `dO`, returns `(dQ, dK, dV)` via:
  ```
  dV     = attn^T @ dO
  dAttn  = dO @ V^T
  dScaled = softmax_backward(attn, dAttn)
  dScores = dScaled * (1/√d)
  dQ      = dScores @ K
  dK      = dScores^T @ Q
  ```
  Causal-mask attention reuses the same routine — masked positions get
  `attn=0` from the forward, which makes their `dScaled` rows
  identically zero through softmax_backward (no explicit backward
  mask needed).
- **`tools/train/attention_gradcheck.rail`** — central-difference
  gradcheck on dQ, dK, dV at seq=3, d=2.  Tolerance 0.01 (f32 noise
  floor).  All 18 slots PASS.

### Single-head causal transformer LM
- **`tools/train/lm_transformer.rail`** — `embed → +sin/cos PE →
  causal self-attention → output projection`.  Adam + cosine LR
  schedule + tokenizer + 5-tensor parameter set (W_E, W_Q, W_K, W_V,
  W_O).  No layernorm, no residual, no FFN — minimum-viable
  transformer, 300 Adam steps, d=16.  Loss 15.20 → **2.62** on the
  same 383-char Shakespeare excerpt; beats uniform baseline (3.47)
  but does not beat the v2.3 bigram baseline (2.10).  Closing that
  gap is queued for v2.5 (layernorm forward+backward, residuals, FFN
  block).

### Counters
- Dylib export count: 25 (unchanged — no new kernels).
- Tests: 106/106 (unchanged).
- Fixed-point self-compile: preserved.
- Numerical attention gradcheck: 18/18 PASS.

## v2.3.0 (2026-04-14) — *Movement III: the training stack*

Closes the training-loop spine: **Adam + cosine LR + grad clipping +
checkpoint save/load + char-level tokenizer + generation (argmax /
top-k / temperature)**.  All five queued critical-path items from the
v2.2 handoff now compose end to end on a real language-model
objective — `tools/train/lm_shakespeare.rail` trains a char-level
bigram LM to below-uniform loss in 200 Adam steps and
`tools/train/lm_generate.rail` samples text back out of the saved
checkpoint.

Proof write-up: `docs/first_lm.md`.

### Adam optimizer (fused GPU kernel)
- **`kernel void adam_update`** in `tools/metal/tensor_gpu.metal` — one
  dispatch per parameter tensor per step. Reads `w, g, m, v` plus a
  6-element hyperparameter array `[lr, β1, β2, ε, bc1, bc2]`; mutates
  `w, m, v` in place. Bias correction terms computed host-side so the
  kernel stays scalar-allocation-free.
- **`tgl_adam_update_f64`** dylib export. Six register args (four
  `float_arr` pointers + hyp pointer + N), follows the scalar-packing
  idiom of `tgl_scale_f64`. Smoke test in `tools/metal/smoke_test.c`.
- **`stdlib/optim.rail::AdamState`** — m/v float_arrs plus mutable step
  counter.  `adam_state n` builds fresh state; `adam_update_raw` is the
  hot path on raw float_arrs (used by manual backward loops);
  `adam_update` wraps `Tensor` destructure.
- **`tools/train/adam_xor.rail`** — XOR loss 0.425 → 3.78e-10 in 200
  steps, lr=0.05. One fused GPU dispatch per parameter per step
  (vs ≈12 dispatches in the pure-Rail tensor-op chain of
  `tools/railml/train.rail`).

### LR schedule + grad clipping
- **`cosine_decay step warmup max_steps base_lr`** in
  `stdlib/optim.rail` — linear warmup to `base_lr` over the first
  `warmup` steps, then half-cosine decay to 0.0.  Clamped to 0.0
  after `max_steps` to avoid negative lrs.  Verified at 5 canonical
  points (`tools/train/lr_clip_test.rail`).
- **`clip_grad_norm grads max_norm`** — computes the global L2 norm
  of a list of gradient tensors via GPU dispatch (tensor_mul for
  squaring, CPU reduce for the sum — cheap compared to the
  element-wise square), scales every tensor by `max_norm / norm`
  when the threshold fires, returns `(clipped_grads, pre_norm)`.
  The running total is accumulated through a `float_arr` slot
  instead of a recursive float param to side-step Rail's
  cross-function float-inference gap.

### Checkpoint save/load
- **`stdlib/checkpoint.rail`** — `save_model prefix tensors` writes a
  text manifest (`<prefix>.manifest`, one line per tensor with
  rank and dims) plus per-tensor f32 payload files
  (`<prefix>.<i>.f32`).  `load_model prefix` reads them back into
  fresh Tensors.  Round-trip verified bit-identical in
  `tools/train/ckpt_test.rail`; reload of the LM checkpoint produces
  the same forward-pass loss to 1e-10.

### Tokenizer
- **`stdlib/tokenizer.rail::Vocab`** — char-level vocab built by
  first-appearance order.  `build_vocab text` returns a `Vocab`,
  `encode / decode` round-trip exactly.  Verified on 10,320 chars
  (`tools/train/tokenizer_test.rail`).  Byte-level BPE with merge
  rules is queued for v2.4 — the v2.3 shape is the BPE-floor char
  vocab.

### End-to-end LM training
- **`tools/train/lm_shakespeare.rail`** — 2-layer MLP bigram LM,
  `one_hot(32) → Linear(32) → ReLU → Linear(32) → softmax`.  Trains on
  a 383-char Shakespeare excerpt for 200 steps with Adam + cosine
  schedule.  Loss drops 15.02 → 2.10 vs uniform baseline 3.47.
  Checkpoint save + reload + eval gives bit-identical loss.

### Generation
- **`stdlib/sampling.rail`** — `sample_argmax`, `sample_topk`,
  `sample_temperature`, plus `fill_uniforms` (awk-backed PRNG — Rail's
  int PRNG overflows on multiplicative LCGs, so we lean on shell).
  Top-k selection is O(V·k), adequate for small vocabs.
- **`tools/train/lm_generate.rail`** — loads the saved LM checkpoint,
  rebuilds the vocab from the same corpus, generates 80 tokens with
  each of the three strategies.  Argmax converges to a high-probability
  bigram cycle ("To the the the..."); top-k k=3 produces more varied
  Shakespeare-ish text ("To an tha the toro...").

### Counters
- Dylib export count: 24 → 25.
- Tests: 106/106 (unchanged — all additions are stdlib + demos, not
  compiler changes).
- Fixed-point self-compile: preserved.

## v2.2.0 (2026-04-14) — *The Suite*

A multi-movement session performed live. Overture fixed two parser/codegen
burrs. Movement I expanded the Metal kernel library. Movement II put
backward passes on the GPU. Movement IV added positional encodings.
Movement VI sketched the fifth backend. Movements III / V / VII / VIII / IX
are designed and queued in EVOLUTION.md with full specs.

### Compiler (Overture)
- **`0.0 + int_expr` miscompile fixed.** When one operand is a float
  literal and the other is an int expression whose type can't be
  statically inferred (because `body_has_float` blocks
  `mark_int_params`), the O-handler now detects int-producing O-nodes
  on the mixed side and emits `asr`+`scvtf` for promotion. Surgical
  (no runtime type check, no false positives on raw float bits with
  LSB=1). New regression test `t106 mixed_float_int_op`. 106/106.
- **Deeply-nested `match` chains** — documented workaround pattern in
  CLAUDE.md: flatten to a single indentation level at the top of the
  function body, followed by a linear `let` stream. Parser fix deferred.
- **`int_to_float` / `float_to_int`** promoted into the documented
  builtin quick-reference.

### Metal kernels (Movement I)
- **`matmul_bias_gelu`** — transformer-FFN native, fused matmul +
  bias + GELU in one dispatch.
- **`matmul_batched`** — `[B, M, K] @ [B, K, N] → [B, M, N]`. One
  3D threadgroup per output element, z-index is batch.
- **`tensor_sum_partials`** — race-free parallel reduction. Each
  threadgroup writes one partial; host sums the partials. Replaces
  the atomic-add note that was racy under multi-threadgroup dispatch.

### Backward kernels (Movement II)
- **`softmax_backward`** — `dx_i = y_i(dy_i − Σ y_k dy_k)`, one row
  per thread, inner dot product inline.
- **`ce_softmax_backward`** — fused CE gradient `(probs − onehot)/batch`
  replaces the Rail-side loop in `three_class_mlp.rail`.
- **`layernorm_backward`** — mean/var-aware, computes `∂L/∂x` in a
  single kernel given precomputed `mean` and `rstd` from forward.

### Transformer stdlib (Movement IV)
- **Sinusoidal positional encodings.** `sinusoidal_pe seq dim`
  returns a `[seq, dim]` Tensor whose even columns are `sin(pos/10000^(2i/d))`
  and odd columns are `cos(...)`. `apply_pe x pe = tensor_add x pe`.
  Smoke test verifies `sin(0)=0, cos(0)=1, sin(1)≈0.841, cos(1)≈0.540`.
- `linear_gelu` wrapper on top of the fused `matmul_bias_gelu` kernel.

### Metal IR backend scaffold (Movement VI)
- **`tools/metal/rail_to_metal.rail`** — minimal Rail AST → `.metal`
  kernel emitter for pure float→float lambdas. Demo emits a working
  `relu2` kernel; `xcrun metal -c /tmp/rail_emitted.metal` compiles
  clean. Proves the path; `#metal_kernel` directive integration next.

### Dylib exports
3 → 15 → **24**. Persistent MTLBuffer pool still amortizing allocation
across the full set. `smoke_test.c` still passes its 29 checks.

### Numbers
| | v2.1.2 | v2.2.0 |
|---|---|---|
| Tests | 105 | 106 |
| Metal kernels | 14 | 20 |
| Dylib exports | 15 | 24 |
| Backward-on-GPU | only via matmul path | softmax+CE+LN fused |
| Transformer PE | — | sinusoidal |
| Backends | 4 | 4½ (Metal-emitter scaffold) |

Self-compile byte-identical. Nothing in the Mozart piece needed red ink.

## v2.1.2 (2026-04-14, overnight session)

The second half of v2.1.x — closing every remaining gap in the
Rail→Metal path and pushing past XOR to real classification.

### Dylib: full GPU op coverage via FFI
`libtensor_gpu.dylib` now exports 15 ops (was 3). Every tensor op used
by `stdlib/tensor.rail` routes through C ABI FFI — no more /tmp file
pipes on the hot path. New exports:

- `tgl_add_f64`, `tgl_mul_f64`, `tgl_scale_f64` — elementwise
- `tgl_sigmoid_f64`, `tgl_exp_f64`, `tgl_tanh_f64` — activations
- `tgl_softmax_rows_f64` — 3-pass row-wise softmax
- `tgl_transpose_f64` — row-major transpose
- `tgl_relu_backward_f64` — masked gradient for training
- `tgl_sgd_update_f64` — in-place weight update
- `tgl_cross_entropy_f64` — loss over a batch
- `tgl_matmul_relu_f64` — fused matmul+bias+relu (new kernel)

### Persistent MTLBuffer pool
Best-fit reuse across ops (32 slots, page-rounded). Cuts
`newBufferWithLength` overhead on training loops where the same
shapes are dispatched over and over.

### Fused matmul+bias+relu kernel
`matmul_bias_relu` in the .metal lib — one dispatch for the classic
MLP layer instead of three. Used automatically by `linear_relu` in the
new transformer stdlib.

### Three-class MLP trains on Metal
`tools/train/three_class_mlp.rail` — 2D→32→3 classifier on 3 synthetic
clusters, cross-entropy + SGD, analytical backward pass. Loss 5.63 →
0.006 in 200 steps. **100% accuracy on 90 samples.** Every op in the
forward and backward pass dispatches through the dylib FFI. First
multi-class training on Metal in pure Rail.

### Transformer forward pass stdlib
`stdlib/transformer.rail` — linear, linear_relu (fused), layernorm,
scaled-dot-product attention (with optional causal mask), feedforward,
pre-norm transformer block. `tools/railml/transformer_forward.rail`
runs a 4-token × 8-dim two-layer stack and verifies softmax rows sum
to 1.0. All matmuls and activations dispatch to Metal.

### Tensor op benchmark
`tools/bench/tensor_ops.rail` — measures per-op latency via the dylib
path at multiple sizes. Reveals ~5ms per-call overhead (command buffer
+ f64↔f32 copies dominate below N=1024²).

### Bug fix: missing `foreign tgl_relu_f64` declaration
When v2.1.1 migrated to the dylib path, `foreign tgl_relu_f64` was
dropped from `stdlib/tensor.rail`. Without it, Rail treated the call
as a regular Rail function, skipping the int-untagging sequence
(`asr`+`tst`+`csel`) before the BL. N arrived in the dylib as the
tagged value (2n+1) and every second dispatch segfaulted. Restoring
the declaration immediately fixes a whole family of hangs.

### Tooling: landing page thumbnails + CF deploy Content-Type
- `tools/deploy/gen_plasma_landing.rail` — generates /plasma index
  with CSS-only preview thumbnails (arc, rings, vortex, nozzle,
  helix). No raster assets, pure animated gradients.
- `tools/deploy/cf_deploy.rail` — auto-detects Content-Type from KV
  key suffix (.css, .js, .wasm, .svg, .json, .png, else HTML) and
  attaches as KV metadata so the Worker can set headers without a
  hardcoded suffix table.

### Test count, fixed point
105/105. Self-compile byte-identical.

## v2.1.1 (2026-04-14)

Follow-up session addressing v2.1 technical debt.

### Tests
- 7 new regression tests for v2.1 compiler features: parse_float,
  parse_int, scientific notation (int and fractional), null-safe ==,
  binary f32 I/O, and tensor primitives. Suite: 98 → 105.
- Float self-loop TCO fix REVERTED — initial `mark_int_params` guard
  removal caused segfaults in self-recursive float functions. Proper
  fix deferred to HARD tier (requires float-param-aware TCO scheduler).

### Dlopen GPU path
- `libtensor_gpu.dylib` with C ABI (tgl_init, tgl_matmul_f64, tgl_relu_f64).
- Fixed ObjC runtime init: `__attribute__((constructor))` warmup forces
  class registration before Rail calls the dylib.
- Absolute install_name so dyld finds the dylib without DYLD_LIBRARY_PATH
  (macOS SIP strips that env var through /bin/sh).
- tensor.rail auto-detects dylib and uses FFI path for matmul; falls
  back to binary file I/O if missing.
- Discovered Rail's foreign ABI untags ints before passing — dylib
  receives raw ints, not tagged.
- First full neural-network convergence: XOR MLP 500 steps, lr=0.8,
  loss 0.38 → 8.88e-16 (machine epsilon). Max prediction error 1.2e-7.

### Metal matmul optimization
- New `matmul_blocked` kernel with 4×4 register blocking.
- N=1024: 741 → 1734 GFLOPS (2.3x). Previous v2.1: 269 GFLOPS.
- Overall 6.5x improvement at that size.
- Byte-identical outputs vs basic kernel.
- dylib uses blocked kernel by default; falls back to basic if missing.

### Autograd design note
- Added documentation explaining the `slot == 0` sentinel pattern and
  the compiler invariant it relies on (test t103 pins it).

## v2.1.0 (2026-04-13)

The GPU session. Rail now drives its own Metal GPU, trains neural
networks end-to-end, and hosts a deployed physics platform.

### Compiler
- **`parse_float`** builtin: string → float via `_atof` + `_str_unwrap`
- **`parse_int`** builtin: string → int via `_strtol` + `_str_unwrap`
- **Null-safe `==` / `!=`**: `_rail_eq` / `_rail_ne` now guard against
  null dereferences and type-mismatch comparisons. `tensor == 0` no
  longer segfaults.
- **Float self-loop TCO**: `mark_int_params` no longer blocked by
  `body_has_float`. Tensor CPU loops now get register-allocated int
  counters while preserving d8 save/restore.
- **Scientific notation literals**: lexer extended to parse `1e6`,
  `1.5e-3`, `6.022e23`, `2.5E+4`.
- **Binary f32 I/O**: `float_arr_to_f32_file` / `float_arr_from_f32_file`
  as builtins. Direct Darwin syscalls (open/write/read/close).
- Self-compiles byte-identical. 98/98 tests.

### Tensor stdlib (stdlib/tensor.rail — 1004 lines)
- 13 autograd primitives unblock `stdlib/autograd.rail`: `tensor_relu_mask`,
  `tensor_gelu_backward`, `tensor_softmax`, `tensor_sum_last`,
  `tensor_sum_batch`, `tensor_mean_last`, `tensor_broadcast_last`,
  `tensor_mul_broadcast`, `tensor_one_hot`, `tensor_embedding_lookup`,
  `tensor_slice_row`, `tensor_accumulate_row`, `tensor_scale_by_loss`.
- 10 utilities: `tensor_matmul`, `tensor_copy`, `tensor_cross_entropy_loss`,
  `tensor_from_int`, `tensor_to_int`, `tensor_get_int`, `tensor_map_scalar`,
  `tensor_ones_like_shape`, `tensor_scalar_val`, `tensor_sum_all`.
- GPU auto-dispatch for matmul, relu, exp, tanh, sigmoid, softmax, transpose.
- Binary f32 pipe for matmul (no text parsing overhead).
- Data-copying `tensor_transpose` (matmul requires row-major contiguous).

### Autograd (stdlib/autograd.rail)
- Links for the first time. All dependencies resolved.
- Forward graph and backward pass both verified correct.
- Reverse-mode tape-based AD with 11 tracked ops.

### Metal GPU (tools/metal/)
- `tensor_gpu.metal` — 13 compute kernels (tiled matmul, elementwise,
  activations, softmax 3-pass, transpose, SGD update, cross-entropy).
  **269 GFLOPS** matmul 512×512 on M4 Pro.
- `tensor_gpu.m` — host with file-mode CLI, binary matmul mode,
  stdin binary protocol, benchmark.
- `tensor_daemon.py` — persistent TCP daemon on :9300 for lower-latency
  dispatch. tensor.rail auto-detects and uses if running.

### First neural network training (tools/train/first_gpu_train.rail)
- 2-layer MLP learns XOR via autograd + SGD.
- Every matmul in forward and backward dispatches to Metal GPU.
- Loss: **0.425 → 0.043** in 50 steps. First pure-Rail GPU training.

### RAIL PLASMA (tools/plasma/)
- `thruster_engine.html` (1,365 lines): SI-calibrated 2D axisymmetric MPD
  thruster design tool. Maecker analytical thrust model with self-field +
  applied-field + gasdynamic components. Validates against 12 published
  data points from Stuttgart SX3 + JPL AF-MPDT. Optimizer sweeps,
  hardware spec generator.
- `mhd_axisym.metal` + `mhd_live.m`: real-time 2D axisymmetric MHD solver,
  256×512 grid, adaptive time-stepping on M4 Pro Metal.
- `mhd_server.py` + `thruster_live.html`: HTTP frame streaming at 30fps
  for remote (mobile) viewing.
- All tools mobile-responsive with touch support.
- Deployed public: `ledatic.org/plasma/{engine,lab,mhd,ramjet,thruster3d}`,
  CSP-compliant split CSS/JS/HTML.

### Runtime
- Compiled Metal host binaries (`tensor_gpu`, `mhd_live`) now gitignored;
  rebuilt from source on demand.

## v2.0.0 (2026-04-06)

121 commits since v1.4.0. The biggest release since v1.0.

This is the version where Rail stops being just a self-hosting compiler
and becomes a self-improving system. The compiler is now the teacher in
three distinct training lineages — LLM-LoRA, Metal-GPU MLP, and
integer-PCFG — and the operational discipline to keep them honest
(intervention ledger, recovery chain, runtime overrides) is built in
pure Rail.

### Compiler & runtime
- **Native floats**: unboxed IEEE 754 doubles in ARM64 d-registers.
  Float arrays, foreign FFI (`sin`/`cos`/`sqrt`/`tanh`/`exp`/`log`/`pow`),
  auto int→float promotion, ~10× speedup vs boxed.
- **Effect handlers**: `try body handler` via setjmp/longjmp. Deep
  unwinding, nested handlers, restartable error recovery.
- **GC bootstrapped**: lower-bound check in `gc_try_mark`, 10/10 stress
  self-compiles. Self-compile fully reliable, no more intermittent
  SIGSEGV. Bootstrap via binary-patching `_rail_gc → ret`.
- **d8 callee-saved float register**: float operations inside recursive
  functions now get the fast self-loop optimization (was blocked by the
  `body_has_float` guard). Unblocked the neural plasma MLP training.
  5/5 d8_test, 7/7 float_bug_test pass.
- **FFI strdup wrap**: string-returning foreign calls now strdup-wrapped
  through the runtime. Fixed the memory bug that was holding tests at
  91/92 → 92/92 for the first time.
- **Polymorphic show**: works on int, float, string, lists (incl.
  nested), nil, and ADTs.
- **Exhaustive match**: non-exhaustive `match` is now a compile-time
  error (was a warning), with runtime trap on fallthrough.
- **Parser `in` keyword**: `let x = val in body` now works alongside
  the newline-indent style.
- **Type checker**: forward inference pass emits warnings for head/tail
  on non-list, arithmetic on non-numeric, wrong arity, calling
  non-functions.
- **Performance**: tail-recursive loops match C `-O2` (5 instructions
  per iteration). Self-loop → bottom-test, untagged register params,
  direct register arithmetic, auto-memoization, constant folding, type
  guard elimination, fused compare-and-branch.

### Backends (4 stable)
- **macOS ARM64** — primary, 92 tests, fixed point self-compile
- **Linux ARM64** (Pi Zero 2 W) — fleet display deployed
- **Linux x86_64** (WSL) — Razer training node
- **WASM** — closures via `call_indirect` + env-passing, ADT
  construction (`Some 42`, `Circle 5`), pattern matching (int / ADT
  ctor / wildcard / string), heap allocator, lists (cons/nil/head/tail/
  length), recursive functions, string ops (append/join/show/reverse),
  print fix (trailing null byte off-by-one), 7 playground demos at
  compile.ledatic.org

### Public sandbox
- **compile.ledatic.org** — public sandboxed compiler. AST whitelist,
  WASM import validation, 19-test adversarial suite, Cloudflare Tunnel.
- **ledatic.org v2.0** — main site redeployed with 6 WASM demos in
  browser (hello, fib, math, lists, ADTs, closures, string ops).
- Site scrubbed: no hardware models/specs, only capability metrics.

### Self-training flywheel
- **Hyperagent**: bench-gated training loop. 30 fixed tasks across 6
  bands. Decision logic: keep adapter only if bench improves, rollback
  otherwise.
- **DNA harvester**: 199 verified examples extracted from
  `compile.rail`, stdlib, and known-good pattern recombinations. Pure
  from birth.
- **RAILGPT bench**: 14/30 (Gemma 4 E4B + Rail adapter), 13/30
  (Qwen 4B + Rail adapter). Up from 1/30 baseline.
- **Curated training corpus**: 3.67 MB of deduped, quality-weighted
  Rail programs from 17 sources. SHA-256 dedup, 90/5/5 split.
- **Cross-arch training**: Mac Mini M4 Pro for inference + orchestration,
  Razer 3070 for CUDA QLoRA training, Pi Zero 2 W for fleet display.

### Neural Plasma Engine
- **3D Metal MHD simulator** (`tools/plasma/plasma_3d.rail`): 128³
  grid, 8 conserved variables (density, momentum, B-field, energy),
  Lax-Friedrichs scheme on Metal compute kernels. Volume raymarcher
  renders density, magnetic pressure, current sheets. 30 fps with
  orbit camera + auto-rotate.
- **Pure-Rail neural surrogate**: linear model trained on 32×32 MHD
  data with analytical backprop. Loss 4.45 → 0.03 over 500 steps,
  single-step mass conservation error 0.027%.
- **Metal GPU MLP training**: 30 → 128 → 6 MLP, 204K examples from
  64×64 MHD, full forward + backward pass on GPU using 10 custom
  Metal kernels. ~85× faster than the pure-Rail CPU version.
- **Neural renderer** (`tools/plasma/neural_renderer.rail`): runs the
  trained MLP forward pass on every cell of a 64×64 grid each frame.
  **The neural network IS the physics engine** — no PDE solver at
  runtime.
- **Stability fix**: spectral normalization + conservation drift loss
  bound the simulation to 200 stable steps. Single-step mass error
  -0.17% → -0.02% via positivity penalty. Output clamping for stable
  multi-step simulation. Residual prediction + normalization (mass
  error 168% → 0.39%).

### Empire → Rail Transplant (4 sessions, all pure Rail, zero Python)

The operational discipline patterns from the paused Empire trading
system, ported as Rail-native infrastructure for the flywheel. **All
four crown jewels shipped 2026-04-06.**

#### Session 1: Intervention Ledger
- `flywheel/interventions.jsonl` — append-only audit log
- `flywheel/interventions_tail.rail` — Rail-native viewer
- 5 hooks in `tools/train/self_train.rail`: round_end, level_advance,
  level_fallback, goal_grind, server_skip
- Helpers `kvi`, `kvs`, `log_intervention`

#### Session 2: Recovery Chain
- `flywheel/flush.rail` — pure Rail rotator. Per protected file: cp
  src→tmp, mv backup→prev, mv tmp→backup. Three guards: source-empty,
  size-shrunk, line-collapse.
- Wired into `tools/train/run_training.sh` — runs every round
- Protected files: `harvest.jsonl`, `progress.txt`,
  `interventions.jsonl`, `bench_log.txt`, `s0_state.txt`

#### Session 3: Domain Plugin Spine
- `tools/domains/` — filesystem-as-registry plugin convention. No
  dispatcher, no manifest, no registry file. The filesystem IS the
  registry.
- `tools/domains/README.rail` — convention spec
- `tools/domains/list.rail` — discovery via `find`
- `tools/domains/neural_plasma/` — first domain (state + bench, real
  d8 regression canary 2/2)
- `tools/domains/s0_pcfg/` — second domain (full 5-verb implementation)

#### Session 4: Runtime Overrides + Audit
- `flywheel/overrides.txt` — bounded tunables (pass_threshold,
  advance_threshold, fallback_threshold, retrain_threshold)
- `flywheel/override_set.rail` — CLI setter with bounds check + audit
- `load_override` helper in `self_train.rail` reads tunables every
  round, replaces hardcoded literals
- Every override write logs to `interventions.jsonl`

### s0_pcfg — second domain on the spine, compiler-as-teacher

A 23-rule probabilistic context-free grammar over Rail tokens, trained
by REINFORCE against the rail compiler. The whole "model" is 23
integer weights. **Compile-as-teacher in 720 lines of pure Rail.**

- **5 program shapes**: inline, named-binding, statement chain,
  function definition, ADT + match
- **92% lifetime strict pass rate** after 30 ticks of training
- **No gradient descent, no transformer, no GPU, no tokens**
- **Discoveries the model found that we didn't put there**:
  - Rail tolerates undefined identifiers (returns 0)
  - Named-binding program shape compiles more reliably than inline
    `print (show E)` — measurable preference, REINFORCE found it
  - Integer division by zero is silent (returns 0)
  - Real traps (missing right operand) die fast under negative reward
- **Closes the loop** via `cross_feed.rail`: translates PCFG-verified
  programs into self_train's harvest format with SHA-256 dedup. **Two
  oracles, one corpus.**
- **Continuous training**: wired into `run_training.sh` per-round hook.
  PCFG trains while LLM trains, both feed the same harvest.
- Full writeup: `docs/s0_pcfg.md` (485 lines, 4 discoveries, 4 Rail
  compiler quirks discovered while building, 6 extension paths)

### Forward regression bisector
- `flywheel/regress.rail` — pure Rail tool that reads the intervention
  ledger and reports any pass-rate drop bigger than a threshold, plus
  the suspect events (override_write, level_fallback, server_skip,
  goal_grind) that occurred in the window before the drop. Threshold
  configurable. The level 25 → 6 historical regression that motivated
  the ledger is gone (pre-ledger), so this tool runs **forward**: it
  watches for the next drop and tells you what changed.

### Stdlib
- `stdlib/file.rail` — added `append_line` helper, used by ledger
  writers and harvest collectors
- `str_find`, `str_contains`, `str_replace`, `str_split` (multi-char),
  `str_sub`, `read_line` — full string toolkit
- `arr_new`, `arr_get`, `arr_set`, `arr_len` — mutable arrays
- 38 stdlib modules total: json, http, sqlite, regex, base64, socket,
  crypto, csv, datetime, env, fs, hash, math, net, os, path, process,
  random, sort, string, test, toml, list, strbuf, fmt, file, stat,
  llm, mmap, dlopen, dirent, signal, time, url, args, autograd, map,
  tensor

### REINFORCE on Gemma LoRA — scoping plan
- `docs/reinforce-lora-plan.md` (364 lines) — half-day Razer-side
  spike to replace cross-entropy LoRA with policy-gradient on
  compile-pass reward. Same training signal that drove s0_pcfg from
  46% → 92% strict pass rate, applied to the 4B-param LLM that
  currently sits at 14/30 on the bench. 6 phases with abort criteria,
  5 risks with mitigations, honest 50% confidence on the make-or-break
  phase. **The big swing for the next session.**

### Compute fleet
- **Mac Mini M4 Pro** (24 GB) — primary inference, compilation,
  orchestration, training
- **MacBook Air M1** (8 GB) — keepawake via Tailscale
- **Razer 3070** (RTX 3070, 8 GB VRAM) — CUDA QLoRA training,
  x86_64 Rail via WSL
- **Pi Zero 2 W** (416 MB) — Rail compiler, fleet LCD display, live
  Tailscale pings

### Identity & open source
- **GitHub Linguist PR** submitted to fix Rail being detected as
  Haskell. README badge added.
- Repo public under BSL 1.1 (converts to MIT 2030-03-14)

### Numbers

| | v1.4 (2026-03-22) | v2.0 (2026-04-06) |
|---|---|---|
| Tests | 70 | 92 |
| Backends | 1 | 4 (ARM64, Linux ARM64, x86_64, WASM) |
| Stdlib modules | ~22 | 38 |
| Compiler lines | 1,979 | 3,865 |
| Self-improving lineages | 1 (LLM-LoRA) | 3 (LLM-LoRA, Metal-MLP, PCFG-REINFORCE) |
| Operational discipline | manual | full (ledger + recovery + spine + overrides + bisector) |
| Domain plugins | 0 | 2 (neural_plasma, s0_pcfg) |

### What's next
- **REINFORCE on Gemma LoRA** — see `docs/reinforce-lora-plan.md`
- **Continuous self-improvement** — `run_training.sh` already runs
  the closed loop every round (s0_pcfg tick + cross_feed)
- **Empire transplant Tier 2** — port-when-needed (multi-turn tools,
  prompt caching, kill switches)

---

## v1.4.0 (2026-03-22)

- **Garbage collector**: conservative mark-sweep GC in `runtime/gc.c`. Scans ARM64 stack frames via x29 chain, traces tagged objects, builds free list. Triggered when 1GB bump-alloc fails. Programs can now allocate well beyond 1GB total.
- **Nested lambdas**: `\a -> \b -> a + b` compiles correctly. Flattened to multi-param closures, with direct application beta-reduced at compile time.
- **Multi-capture closures**: closures capturing 2+ variables now load all captures (up to 4).
- **Exhaustiveness checking**: compiler warns on non-exhaustive ADT pattern matches, listing missing constructors.
- **70 tests** (up from 67), stable with GC + 1GB allocator.
- Compiler grown to 1,979 lines.

## v1.3.0 (2026-03-21)

- **Flywheel self-training**: 25-level curriculum, compiler-verified training data, auto-advance at 80%+ for 3 consecutive rounds.
- **MCP server**: Rail exposed as a Model Context Protocol tool server.
- **32-layer LoRA training**: targets both self-attention and Gated DeltaNet layers in Qwen3.5-4B.
- **Fleet tools**: distributed node management (`tools/fleet/`), fleet agents for ARM64 and x86_64.
- **Thinking mode**: robust response parsing for thinking-mode LLM inference in `runtime/llm.c`.
- Closure label counter fix in compiler.

## v1.2.1 (2026-03-21)

- Quality scoring layer for training data.
- v5 adapter deployed (PEFT-to-MLX converted, more data).
- Razer sparkline in fleet dashboard.

## v1.2.0 (2026-03-21)

- **`cat` builtin** for file concatenation.
- Linux cross-compiler fixes.
- Pi Zero fleet display in dashboard.

## v1.1.0 (2026-03-18 -- 2026-03-20)

- **Metal GPU backend**: Rail compiles to Apple Silicon compute shaders (`tools/gpu.rail`).
- **WASM backend**: compiles Rail to WebAssembly (`tools/wasm.rail`). Runtime WIP.
- **x86_64 backend**: sandboxed subset in `tools/x86_codegen.rail`.
- **Concurrency**: `spawn`, `channel`, `send`, `recv` for fiber-based concurrency.
- **Flywheel framework**: `flywheel/waterfall.rail` orchestrator, `flywheel/bench.rail` 30-task benchmark.
- **`#generate` directive**: compile-time AI code generation.
- **Map fusion**: `map f (map g data)` optimized to single-pass `map (f . g) data`.
- **Tree-sitter grammar** for syntax highlighting.
- **22 stdlib modules**: json, http, sqlite, regex, base64, socket, crypto, csv, datetime, env, fs, hash, math, net, os, path, process, random, sort, string, test, toml.
- Reorganized `tools/` directory structure.
- Arena mark/reset fix -- 67/67 tests stable.
- Flywheel data pipeline audit + 49-issue fix cycle.

## v1.0.0 (2026-03-17)

- **Self-hosting achieved**: Rail compiler (`tools/compile.rail`) compiles itself to ARM64. Output is byte-identical on re-compilation (fixed point).
- **Rust deleted**: 21,086 lines of Rust replaced by 1,774 lines of Rail.
- **329K seed binary**: no external dependencies beyond macOS `as` + `ld`.
- 67 tests passing.
- Pattern matching + algebraic data types.
- Tail-call optimization.
- Tagged pointer runtime (integers shifted, heap objects raw).
- 1GB bump arena allocator.
- CLI interface: `rail_native run`, `rail_native test`, `rail_native self`.
- Linux ARM64 cross-compilation support.

## Pre-1.0 (2026-03-14 -- 2026-03-16)

- ARM64 native code generation from Rail AST.
- Self-compilation achieved, then reached fixed point.
- TCO for constant-stack recursion.
- ADTs and pattern matching.
- Floats, FFI, imports, list operations.
- Async fibers, literal patterns, guards in match.
- `rail generate` -- AI code generation via local LLM.
- Channels and float FFI returns.
- GPU auto-dispatch for pure arithmetic.
- Original Rust implementation (interpreter, parser, type checker, LSP, Cranelift JIT).
