diff --git a/.claude/agents/physics-verifier.md b/.claude/agents/physics-verifier.md
new file mode 100644
index 00000000..b3e9c485
--- /dev/null
+++ b/.claude/agents/physics-verifier.md
@@ -0,0 +1,73 @@
+---
+name: physics-verifier
+description: "Use this agent to adversarially audit an Islands-module (src/Islands/) diff for [VERIFY]-policy violations before it is committed — most importantly during autonomous overnight runs, where a guessed physics coefficient is the single worst failure mode. It hunts for numbers pulled from thin air, literature transcriptions committed as fact, and sign/convention errors against the Islands design docs. It is read-only and adversarial by charter. Invoke it before committing ANY physics-adjacent change in src/Islands/, and always before opening a PR that touches physics.\\n\\n\\nContext: An autonomous run just wired a bootstrap-drive term and is about to commit.\\nuser: \"The GradientDrive term is implemented — about to commit.\"\\nassistant: \"Before committing, let me run the physics-verifier agent to confirm no [VERIFY] coefficient was assigned a value and the half-width/sign conventions match docs/01.\"\\nPhysics-adjacent code before a commit — launch physics-verifier to enforce the [VERIFY] policy.\\n\\n\\n\\nContext: A term transcribes ω̂_D from D21 Eq. B1.\\nuser: \"Added the magnetic drift frequency from the paper.\"\\nassistant: \"I'll use the physics-verifier agent to check that the transcription carries a [CHECKED] tag with the exact Eq./page cite and a skipped benchmark, and was not silently promoted to confirmed.\"\\n"
+tools: Read, Grep, Glob, Bash
+model: opus
+color: red
+---
+
+You are the physics-verifier for the GPEC `Islands` module — a steady-state
+drift-kinetic island/layer solver. Your single job is to **find the guessed
+number and the silent transcription** before they enter the repository. You are
+read-only and adversarial: assume the diff contains a `[VERIFY]` violation and
+try to prove it. A fast-but-wrong physics result is worthless; your review is the
+last gate before an autonomous run commits physics-adjacent code.
+
+## Budget discipline
+
+Hard cap: **≤25 tool uses, ≤8 minutes**. One concrete deliverable: a verdict
+(`PASS` / `BLOCK`) plus a short itemized findings list. The invoking prompt hands
+you the diff or file paths — do not go spelunking the whole module. If you cannot
+finish in budget, stop and report what you checked and what remains.
+
+## What you enforce (the [VERIFY] policy — Islands CLAUDE.md, `src/Islands/CLAUDE.md`)
+
+1. **No guessed coefficients.** Every physics O(1) coefficient, threshold number,
+ sign, or normalization must be one of: (a) `[CHECKED: source, Eq./p.]` with an
+ exact citation, (b) `[VERIFY: source]` and *parameterized* (not baked into a
+ literal) with a failing/skipped benchmark referencing it, or (c)
+ `[DERIVED: date]` with a derivation under `docs/src/islands/derivations/`. A
+ bare numeric literal in a physics expression with none of these is a **BLOCK**.
+2. **No silent promotion.** A `[VERIFY]`/`[CHECKED]` expression implemented as if
+ confirmed (hardcoded value, no skipped benchmark, no parameter) is a BLOCK —
+ even if the value happens to be right. Only a human clears a tag.
+3. **No "fix the coefficient to make the test pass."** If a benchmark was made to
+ pass by editing a physics constant rather than the code, BLOCK and flag it.
+
+## Convention checks (Islands design docs — `docs/src/islands/design/`)
+
+- **Island width `w` is a HALF-width**; Ω = 2x²/w² − cos ξ; O-point Ω = −1,
+ separatrix Ω = +1 (docs/01 §1, CLAUDE.md). Flag any full-width/half-width
+ confusion, especially in threshold numbers (report always with the gyroradius
+ unit stated; ρ_bi = ε^{1/2}ρ_θi).
+- **Frames**: every ω sign convention must live in `src/Islands/frames/` and
+ nowhere else (docs/01 §5, CLAUDE.md). A sign convention or frame conversion
+ outside the frames module is a finding. The polarization-current sign depends
+ on frame — check ω − ω_E vs ω_E vs ω₀ = −ω_E usage against docs/01 §5.
+- **No regime branches** in operator physics code (`if collisional … else banana`)
+ — allowed only in `verify/` and preconditioners (CLAUDE.md).
+- Cross-check any transcribed equation against the cited source in the reference
+ library (`docs/src/islands/design/08-reference-library.md`) — the published
+ Imada 2019 set has documented errata (L23 §2.6); a term matching I19 Eq. (A.1)
+ *as printed* rather than the L23-amended form is a finding.
+
+## Method
+
+1. `git diff` (or read the named files) — enumerate every physics-adjacent
+ change: coefficients, signs, normalizations, transcribed equations, thresholds.
+2. For each, ask: is it tagged? parameterized? cited to an exact Eq./page? backed
+ by a skipped benchmark if `[VERIFY]`? Does it match the source and the
+ half-width/frame/sign conventions?
+3. Grep the diff for bare float literals inside physics expressions and for
+ `[VERIFY]`/`[CHECKED]` tags whose companion benchmark is missing.
+
+## Output
+
+- **Verdict**: `PASS` or `BLOCK`.
+- **Findings**: each as `file:line — — —
+ ` (e.g. "parameterize + add skipped benchmark", "add [CHECKED] cite",
+ "write a QUESTIONS.md entry and escalate"). Rank most-severe first.
+- If BLOCK, state plainly that the change must not be committed until the flagged
+ coefficient is escalated to `docs/src/islands/QUESTIONS.md` (never guessed).
+
+You never edit code. You never clear a `[VERIFY]` tag. You find the error.
diff --git a/.claude/hooks/guard-bash.sh b/.claude/hooks/guard-bash.sh
new file mode 100755
index 00000000..19bc2a6b
--- /dev/null
+++ b/.claude/hooks/guard-bash.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# PreToolUse(Bash) guard for unattended Islands runs.
+# Reads the hook JSON on stdin; `exit 2` blocks the tool call and returns the
+# stderr text to Claude as the denial reason. `exit 0` allows it.
+# Defense-in-depth under `dontAsk` (permissions.deny is the first layer; this is
+# the second, since glob deny-rules are coarse).
+set -euo pipefail
+
+payload="$(cat)"
+cmd="$(printf '%s' "$payload" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))' 2>/dev/null || true)"
+
+# Never force-push.
+if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+push([[:space:]]|.)*(--force|--force-with-lease|[[:space:]]-f([[:space:]]|$))'; then
+ echo "BLOCKED: force-push is not allowed in unattended runs." >&2
+ exit 2
+fi
+
+# Never push to protected branches — pushes go only to feature/islands*.
+if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+push'; then
+ if printf '%s' "$cmd" | grep -Eq '(^|[[:space:]/])(main|develop)([[:space:]]|$|:)'; then
+ echo "BLOCKED: push to main/develop is not allowed; push only to feature/islands*." >&2
+ exit 2
+ fi
+fi
+
+# Refuse obviously destructive filesystem ops on absolute/home roots.
+if printf '%s' "$cmd" | grep -Eq 'rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+)(/|~|\$HOME|/mnt/homes)'; then
+ echo "BLOCKED: refusing recursive rm on an absolute/home path." >&2
+ exit 2
+fi
+
+exit 0
diff --git a/.claude/hooks/stop-check.sh b/.claude/hooks/stop-check.sh
new file mode 100755
index 00000000..14b4b88b
--- /dev/null
+++ b/.claude/hooks/stop-check.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Stop hook for unattended Islands runs.
+# `exit 2` blocks the session from ending (Claude keeps working, sees stderr);
+# `exit 0` lets it stop. Purpose: never end a night with a dirty tree or a
+# broken build.
+set -uo pipefail
+
+payload="$(cat)"
+
+# Avoid an infinite loop: if we are already continuing because of this hook,
+# let the session stop.
+active="$(printf '%s' "$payload" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("stop_hook_active", False))' 2>/dev/null || echo False)"
+if [ "$active" = "True" ]; then
+ exit 0
+fi
+
+root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || echo .)}"
+cd "$root" 2>/dev/null || exit 0
+
+# 1. Working tree must be clean (commit granularly, per the protocol).
+if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
+ echo "BLOCKED stop: uncommitted changes present. Commit (to feature/islands) or stash before ending; then write a LOG.md entry." >&2
+ exit 2
+fi
+
+# 2. Build/tests must pass. Requires julia on PATH (see QUESTIONS.md Q1); if
+# julia is absent the check is skipped with a warning rather than blocking.
+# Run julia with LD_LIBRARY_PATH stripped: a loaded OMFIT module leaks the conda
+# env's libs onto the path, shadowing Julia's bundled artifacts (CHOLMOD, glib)
+# and giving false "broken build" blocks. Julia uses its own RPATH, so unsetting
+# is a no-op on a clean shell / CI.
+if command -v julia >/dev/null 2>&1; then
+ # Once M1 lands test/runtests_islands_*.jl, prefer running just those:
+ # julia --project=. test/runtests.jl test/runtests_islands_grids.jl ...
+ # Until then, a package-load smoke check.
+ if ! env -u LD_LIBRARY_PATH julia --project=. -e 'using GeneralizedPerturbedEquilibrium' >/dev/null 2>&1; then
+ echo "BLOCKED stop: package fails to load (using GeneralizedPerturbedEquilibrium errored)." >&2
+ exit 2
+ fi
+else
+ echo "WARN: julia not on PATH — skipping the build check in the Stop hook (QUESTIONS.md Q1)." >&2
+fi
+
+exit 0
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 00000000..57ec10fd
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,65 @@
+{
+ "$comment": "Project-scoped Claude Code settings for GPEC. The permissions.allow list is sized for the Islands overnight autonomous loop run under --permission-mode dontAsk (anything not allowed is auto-denied, no prompt). permissions.deny + the PreToolUse hook are defense-in-depth against destructive git/filesystem ops. The Stop hook keeps an unattended session from ending with a dirty tree or a broken build. DRY-RUN THESE ONCE, SUPERVISED, before trusting an unattended run (design doc docs/src/islands/design/06 §2.3).",
+ "permissions": {
+ "allow": [
+ "Read",
+ "Grep",
+ "Glob",
+ "Edit",
+ "Write",
+ "NotebookEdit",
+ "TodoWrite",
+ "Task",
+ "Bash(julia:*)",
+ "Bash(git status:*)",
+ "Bash(git diff:*)",
+ "Bash(git log:*)",
+ "Bash(git show:*)",
+ "Bash(git add:*)",
+ "Bash(git commit:*)",
+ "Bash(git branch:*)",
+ "Bash(git checkout:*)",
+ "Bash(git switch:*)",
+ "Bash(git stash:*)",
+ "Bash(git pull --ff-only:*)",
+ "Bash(git push origin feature/islands:*)",
+ "Bash(gh pr:*)",
+ "Bash(gh run:*)",
+ "Bash(gh api:*)",
+ "Bash(ls:*)",
+ "Bash(cat:*)",
+ "Bash(mkdir:*)",
+ "Bash(cp:*)",
+ "Bash(mv:*)"
+ ],
+ "deny": [
+ "Bash(git push --force:*)",
+ "Bash(git push -f:*)",
+ "Bash(git push origin main:*)",
+ "Bash(git push origin develop:*)",
+ "Bash(git push* main)",
+ "Bash(git push* develop)",
+ "Read(./.env)",
+ "Read(~/.ssh/**)",
+ "Read(~/.claude.json)",
+ "Read(~/.config/gh/**)"
+ ]
+ },
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-bash.sh\"" }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stop-check.sh\"" }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
index 7e1ff4f6..d7121fb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,8 +14,10 @@
*.DS_Store
*.pdf
!docs/resources/*.pdf
+!docs/resources/**/*.pdf
*.png
!docs/src/assets/**/*.png
+!docs/src/islands/figures/*.png
*.jld2
.gitattributes
Manifest.toml
diff --git a/CLAUDE.md b/CLAUDE.md
index da32ed04..38e7c113 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -327,6 +327,15 @@ This workflow is reflected in the modular structure and data flow.
GPEC consists of **seven main modules** organized in `src/`:
+**Module naming convention**: `src/` module names are simple, intuitive descriptions
+of what the module *is* (`Vacuum`, `Equilibrium`, `ForceFreeStates`, `KineticForces`,
+`Islands`) — CamelCase, matching the directory. Do **not** give a feature a fancy
+standalone-sounding acronym or codename as if it were an independent code needing its
+own name recognition (e.g. no `ISLET`, `PENTRC`-style names for new modules). The
+domain the module addresses is the name. Working titles/acronyms from a paper or design
+doc are dropped when the module lands in `src/`. This keeps the codebase legible as one
+integrated tool rather than a bag of separately-branded programs.
+
#### Foundation Modules
1. **Splines** (`src/Splines/`) - Numerical interpolation library
diff --git a/Project.toml b/Project.toml
index fd2c512d..3d538155 100644
--- a/Project.toml
+++ b/Project.toml
@@ -14,9 +14,11 @@ DoubleFloats = "497a8b3b-efae-58df-a0af-a86822472b78"
FFTW = "7a1cc6ca-52ef-59f5-83cd-3a7055c09341"
FastGaussQuadrature = "442a2c76-b920-505d-bb47-c5924d526838"
FastInterpolations = "9ea80cae-fc13-4c00-8066-6eaedb12f34b"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f"
IMASdd = "c5a45a97-b3f9-491c-b9a7-aa88c3bc0067"
JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
+Krylov = "ba0b0d4f-ebba-5204-a429-3ac8c609bfb7"
LaTeXStrings = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed"
@@ -43,9 +45,11 @@ DoubleFloats = "1.6.2"
FFTW = "1.9.0"
FastGaussQuadrature = "1.1.0"
FastInterpolations = "0.4.10"
+ForwardDiff = "1.4.1"
HDF5 = "0.17.2"
IMASdd = "8"
JLD2 = "0.6.3"
+Krylov = "0.10.8"
LaTeXStrings = "1.4.0"
LinearAlgebra = "1"
OrdinaryDiffEq = "6.102.0"
diff --git a/benchmarks/islands/README.md b/benchmarks/islands/README.md
new file mode 100644
index 00000000..fe273953
--- /dev/null
+++ b/benchmarks/islands/README.md
@@ -0,0 +1,23 @@
+# Islands benchmark ladder (docs/05)
+
+Benchmark scripts for the Islands verification ladder
+(`docs/src/islands/design/05-verification.md`). Each script names its ladder
+ID, configuration, target (with source cites), and status.
+
+**Status policy (the `[VERIFY]` discipline, `src/Islands/CLAUDE.md`):** every
+physics benchmark whose target or input coefficients are `[VERIFY]`/uncleared-
+`[CHECKED]` ships **skipped** — the script states exactly which
+`docs/src/islands/QUESTIONS.md` entries gate it and exits without running.
+Un-skipping a benchmark requires the human clearances it names; silently
+filling in a coefficient to make one run is the failure mode this project
+exists to prevent. The structural A-ladder (A1–A8) runs in CI via
+`test/runtests_islands_*.jl`, not here.
+
+Figure scripts (docs/07 pipeline) live in `figures/` and read archived
+benchmark data only.
+
+| Script | Ladder ID | Status |
+|---|---|---|
+| `benchmark_B2_large_w_limits.jl` | B2 | SKIPPED — gated on Q2/Q3/Q4 |
+| `benchmark_B4_polarization_omegaE.jl` | B4 | SKIPPED — gated on Q2/Q3 |
+| `benchmark_B5_york_thresholds.jl` | B5a/B5b/B5c | SKIPPED — gated on Q2/Q3/Q4 |
diff --git a/benchmarks/islands/benchmark_B2_large_w_limits.jl b/benchmarks/islands/benchmark_B2_large_w_limits.jl
new file mode 100644
index 00000000..f9123e84
--- /dev/null
+++ b/benchmarks/islands/benchmark_B2_large_w_limits.jl
@@ -0,0 +1,14 @@
+# benchmark_B2_large_w_limits.jl — ladder B2 (docs/05 §B)
+#
+# Target: large-w analytic limits — Δ_bs + Δ_cur ∝ 1/w with coefficient against
+# WCHH96 Eq. (85) mapped to the island frame (Diss19 p. 86 frame caveat), and the
+# Δ_pol ∝ 1/w³ tail [CHECKED: Diss19 pp. 84–86; D21 Fig. 8-class curves].
+#
+# STATUS: SKIPPED — gated per docs/src/islands/QUESTIONS.md:
+# Q3 the Δ moment prefactors and electron-closure constants are uncleared
+# Q4 WCHH96 is not yet in the docs/08 reference library (cited only via
+# transcriptions) and the ψ̃ amplitude carries an open [VERIFY]
+# Q2 D7 (re-derived equation set) unratified.
+
+println("SKIPPED: B2 large-w limits — gated on QUESTIONS Q2, Q3, Q4")
+println(" (WCHH96 not in the reference library; ψ̃ [VERIFY] open; closure constants uncleared).")
diff --git a/benchmarks/islands/benchmark_B4_polarization_omegaE.jl b/benchmarks/islands/benchmark_B4_polarization_omegaE.jl
new file mode 100644
index 00000000..d16f9b08
--- /dev/null
+++ b/benchmarks/islands/benchmark_B4_polarization_omegaE.jl
@@ -0,0 +1,16 @@
+# benchmark_B4_polarization_omegaE.jl — ladder B4 (docs/05 §B)
+#
+# Target: polarization structure — (i) Wilson–Connor collisionless and Smolyakov
+# collisional scalings; (ii) Δ_pol ∝ ω_E² away from zero with the sign reversal
+# at ω_E ≈ −0.89 ω_dia,e, reversal point insensitive to w/ρ_θi; (iii)
+# torque-balance roots at discrete ω̂_E (Diss19 benchmark root ω₀ = −0.93
+# ω_dia,e) [CHECKED: D23b Fig. 8; Diss19 Fig. 4.18].
+#
+# STATUS: SKIPPED — gated per docs/src/islands/QUESTIONS.md:
+# Q3 the frame-convention signs (src/Islands/frames/) are NaN-gated until
+# cleared — the ω_E-dependence of Δ_pol is exactly the physics the frames
+# module must own before any sign-bearing benchmark runs
+# Q2 D7 unratified (L0 equation set).
+
+println("SKIPPED: B4 polarization ω_E structure — gated on QUESTIONS Q2, Q3")
+println(" (frame-convention signs NaN-gated; L0 equation set unratified).")
diff --git a/benchmarks/islands/benchmark_B5_york_thresholds.jl b/benchmarks/islands/benchmark_B5_york_thresholds.jl
new file mode 100644
index 00000000..1766b183
--- /dev/null
+++ b/benchmarks/islands/benchmark_B5_york_thresholds.jl
@@ -0,0 +1,22 @@
+# benchmark_B5_york_thresholds.jl — ladder B5a/B5b/B5c (docs/05 §B)
+#
+# Targets (transcribed, awaiting human sign-off — see docs/src/islands/design/05):
+# B5a DK-NTM threshold w_c ≃ 2.76 ρ_θi (half-width) ≡ 8.73 ρ_bi at ε = 0.1,
+# :original drift model [CHECKED: I19 Fig. 9; PRL p. 4]
+# + open [VERIFY]: run collisionality (I19 §4.2 ν_★ = 0.01 vs L23 p. 82 ν_★ = 1e-3).
+# B5b RDK-NTM improved-model threshold w_c ≈ 0.45 ρ_θi ≡ 1.46 ρ_bi half-width
+# [CHECKED: D21 abstract + Fig. 8; D23a abstract]
+# B5c kokuchou finite-ν_★ surface w_c ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54e-5
+# [CHECKED: L23 Eqs. 6.3.1–6.3.2], L23-amended equation set.
+#
+# STATUS: SKIPPED — running this requires assigning Level-0 physics
+# coefficients that are at most [CHECKED] and not human-cleared:
+# Q2 ratify D7 (re-derived L0 equation set) and D8 (the B5a/b/c triangle)
+# Q3 clear the L0 coefficient set (ω̂_D/L̂_B toggle, collision kernel,
+# electron closure, quasineutrality closure)
+# Q4 resolve the ψ̃ amplitude [VERIFY] and the B5a collisionality [VERIFY]
+# per docs/src/islands/QUESTIONS.md. Do not fill in a number to make this run.
+
+println("SKIPPED: B5a/B5b/B5c York thresholds — gated on QUESTIONS Q2, Q3, Q4")
+println(" (uncleared [CHECKED] L0 coefficients; D7/D8 unratified).")
+println(" See docs/src/islands/QUESTIONS.md and docs/src/islands/design/05-verification.md.")
diff --git a/benchmarks/islands/figures/README.md b/benchmarks/islands/figures/README.md
new file mode 100644
index 00000000..f0b2ad83
--- /dev/null
+++ b/benchmarks/islands/figures/README.md
@@ -0,0 +1,9 @@
+# Islands figure pipeline (docs/07 §2)
+
+Pinned figure scripts reading **archived benchmark data only** — the same
+script feeds CI artifacts, the state gallery, and paper panels. A figure that
+cannot be regenerated from archived data is a release-blocking bug.
+
+Empty until the first B-ladder benchmark is un-skipped (gated on
+`docs/src/islands/QUESTIONS.md` Q2–Q4); the Paper-I figure contract is
+`docs/src/islands/papers/paper-1/OUTLINE.md`.
diff --git a/benchmarks/islands/figures/make_structural_figures.jl b/benchmarks/islands/figures/make_structural_figures.jl
new file mode 100644
index 00000000..448dfbbe
--- /dev/null
+++ b/benchmarks/islands/figures/make_structural_figures.jl
@@ -0,0 +1,151 @@
+# make_structural_figures.jl — pinned figure script (design docs/07 §2)
+#
+# Regenerates the structural (A-ladder) figures embedded in
+# docs/src/islands/numerics.md from the verification machinery itself — no
+# archived data needed yet because everything here is deterministic M1/M2
+# structure (manufactured coefficients, no physics). Physics (B-ladder) figures
+# will read archived benchmark data once QUESTIONS Q2–Q4 clear.
+#
+# Usage (repo root):
+# env -u LD_LIBRARY_PATH julia --project=. benchmarks/islands/figures/make_structural_figures.jl
+#
+# Writes PNGs into docs/src/islands/figures/ (committed as docs assets so the
+# Documenter CI build does not need to run this script).
+
+ENV["GKSwstype"] = "100" # headless GR
+
+using Plots
+using LinearAlgebra
+using GeneralizedPerturbedEquilibrium
+const Isl = GeneralizedPerturbedEquilibrium.Islands
+const PS = Isl.PhaseSpace
+const Op = Isl.Operators
+const So = Isl.Solvers
+const V = Isl.Verify
+const Fi = Isl.Fields
+
+outdir = normpath(joinpath(@__DIR__, "..", "..", "..", "docs", "src", "islands", "figures"))
+mkpath(outdir)
+saved = String[]
+function save!(p, name)
+ path = joinpath(outdir, name)
+ savefig(p, path)
+ push!(saved, path)
+ return path
+end
+
+# ---------------------------------------------------------------------------
+# F1 — layer-clustered grids: the sinh maps and node placement (04 §1)
+# ---------------------------------------------------------------------------
+let
+ gx = PS.MappedFDGrid(33; halfwidth=6.0, clustering=2.0, order=4)
+ gy = PS.MappedFDGrid(25; halfwidth=4.0, clustering=2.0, center=1.0, domain=:half, order=4)
+ s = range(-1, 1; length=33)
+ p1 = plot(s, gx.nodes; lw=2, xlabel="computational coordinate s", ylabel="x",
+ label="x(s), β=2", legend=:topleft, title="radial map (packs x = 0)")
+ scatter!(p1, s, gx.nodes; ms=3, label="nodes")
+ p2 = plot(; xlabel="node index", ylabel="y", title="pitch grid (packs y_c = 1)", legend=:topleft)
+ scatter!(p2, 1:gy.n, gy.nodes; ms=4, label="y nodes, β=2")
+ hline!(p2, [1.0]; ls=:dash, lc=:red, label="y_c (trapped–passing)")
+ p = plot(p1, p2; layout=(1, 2), size=(950, 380), left_margin=12Plots.mm, bottom_margin=6Plots.mm)
+ save!(p, "grids_clustering.png")
+end
+
+# ---------------------------------------------------------------------------
+# F2 — verification ladder A1: MMS convergence, operators + assembled + solve
+# ---------------------------------------------------------------------------
+let
+ mkg(n) = PS.IslandGrid(; nx=n, nxi=8, ny=n, nE=3, halfwidth_x=6.0, clustering_x=1.0,
+ y_max=4.0, y_c=1.0, clustering_y=0.8, order=4)
+ ns = [9, 17, 33]
+ p = plot(; xscale=:log10, yscale=:log10, xlabel="radial/pitch resolution n",
+ ylabel="max error", legend=:bottomleft, title="MMS convergence (ladder A1)",
+ size=(700, 480), left_margin=12Plots.mm, bottom_margin=6Plots.mm)
+ for (term, lab) in ((:streaming, "streaming"), (:exb, "E×B bracket"),
+ (:collisions, "collisions"), (:perp, "⊥ transport"))
+ errs = [V.mms_operator_error(mkg(n), term) for n in ns]
+ plot!(p, ns, errs; marker=:circle, lw=2, label=lab)
+ end
+ errs = [V.mms_assembled_error(mkg(n)) for n in ns]
+ plot!(p, ns, errs; marker=:square, lw=3, lc=:black, label="assembled residual")
+ solve_errs = [V.solve_mms(n).err for n in (9, 17, 33)]
+ plot!(p, [9, 17, 33], solve_errs; marker=:diamond, lw=3, ls=:dash, label="converged solve (A1-solve)")
+ guide = errs[1] .* (ns[1] ./ ns) .^ 4
+ plot!(p, ns, guide; ls=:dot, lc=:gray, label="4th order")
+ save!(p, "mms_convergence.png")
+end
+
+# ---------------------------------------------------------------------------
+# F3 — the flattened-electron geometry functions and the A7 identity (01 §2.4)
+# ---------------------------------------------------------------------------
+let
+ Ωout = 1.02:0.02:5.0
+ Ωin = -0.98:0.02:0.98
+ Q = [Fi.Q_omega(Ω) for Ω in Ωout]
+ Qin = [Fi.Q_omega(Ω) for Ω in Ωin]
+ h = [Fi.h_profile(Ω; prefactor=1.0) for Ω in Ωout]
+ p1 = plot(Ωin, Qin; lw=2, label="Q(Ω), inside", xlabel="Ω", ylabel="Q",
+ title="Q(Ω) = (1/2π)∮√(Ω+cos ξ) dξ", legend=:topleft)
+ plot!(p1, Ωout, Q; lw=2, label="Q(Ω), outside")
+ vline!(p1, [1.0]; ls=:dash, lc=:red, label="separatrix")
+ a7 = [abs(Fi.flat_average_d2h_dx2(Ω, 1.0)) for Ω in (1.2, 2.0, 3.0, 5.0)]
+ p2 = plot(Ωout, h; lw=2, label="h(Ω) (unit prefactor)", xlabel="Ω", ylabel="h",
+ title="electron profile h(Ω)", legend=:topleft)
+ vline!(p2, [1.0]; ls=:dash, lc=:red, label="separatrix (h ≡ 0 inside)")
+ annotate!(p2, 1.6, 0.35, text("A7: max |⟨∂²h/∂x²⟩_Ω| = $(round(maximum(a7); sigdigits=2))", 9, :left))
+ p = plot(p1, p2; layout=(1, 2), size=(950, 380), left_margin=12Plots.mm, bottom_margin=6Plots.mm, right_margin=6Plots.mm)
+ save!(p, "hQ_profiles.png")
+end
+
+# ---------------------------------------------------------------------------
+# F4 — preconditioner quality (04 §5): GMRES iterations with/without YBlockJacobi
+# ---------------------------------------------------------------------------
+let
+ g = PS.IslandGrid(; nx=9, nxi=8, ny=9, nE=2, halfwidth_x=6.0, clustering_x=1.0,
+ y_max=4.0, y_c=1.0, clustering_y=0.8, order=4)
+ nx, nξ, ny, nE, nσ = PS.nnodes(g)
+ P = @. g.y.nodes * (4.0 - g.y.nodes)
+ K, = Op.conservative_pitch_operator(g.y, P, ones(ny))
+ cstiff = fill(30.0, nx, nξ, nE, nσ)
+ shift = fill(-1.0, nx, nξ, ny, nE, nσ)
+ stack = Op.IslandStack((Op.PitchAngleDiffusion(K, cstiff), Op.RadiationSink(shift)),
+ Op.Quasineutrality(1.3))
+ f0! = So.flat_residual(stack, g)
+ N = Op.statelength(g)
+ b = sin.((1:N) ./ 7)
+ f!(out, u) = (f0!(out, u); out .-= b; out)
+ pc = So.YBlockJacobi(g, (ix, iξ, iE, iσ) -> I(ny) + cstiff[ix, iξ, iE, iσ] .* K; phi_scale=-1.3)
+ s0 = So.newton_krylov(f!, zeros(N); rtol=1e-10, memory=300)
+ s1 = So.newton_krylov(f!, zeros(N); rtol=1e-10, memory=300, precond=pc)
+ p = bar(["unpreconditioned", "y-block Jacobi (TSVD)"], [s0.gmres_iters, s1.gmres_iters];
+ ylabel="total GMRES iterations", legend=false,
+ title="preconditioner: stiff collisional solve",
+ ylims=(0, 1.25 * s0.gmres_iters),
+ size=(600, 430), left_margin=12Plots.mm, bottom_margin=6Plots.mm, top_margin=4Plots.mm)
+ annotate!(p, [(1, s0.gmres_iters + 0.06 * s0.gmres_iters, text("$(s0.gmres_iters)", 10)),
+ (2, s1.gmres_iters + 0.06 * s0.gmres_iters, text("$(s1.gmres_iters)", 10))])
+ save!(p, "preconditioner_gmres.png")
+end
+
+# ---------------------------------------------------------------------------
+# F5 — pseudo-arclength continuation around the toy fold (03 §3)
+# ---------------------------------------------------------------------------
+let
+ ftoy!(out, u, p) = (out[1] = u[1]^2 + p; out)
+ pa = So.pseudo_arclength(ftoy!, [1.0], -1.0; ds=0.15, nsteps=30, rtol=1e-12, atol=1e-12)
+ us = [z[1] for z in pa.us]
+ p = plot(pa.ps, us; marker=:circle, lw=2, xlabel="parameter p", ylabel="u",
+ label="continuation path", legend=:topleft,
+ title="pseudo-arclength steps around the fold (u² + p = 0)",
+ size=(650, 430), left_margin=12Plots.mm, bottom_margin=6Plots.mm)
+ plot!(p, -1.2:0.01:0.0, sqrt.(-(-1.2:0.01:0.0)); ls=:dash, lc=:gray, label="analytic ±√(−p)")
+ plot!(p, -1.2:0.01:0.0, -sqrt.(-(-1.2:0.01:0.0)); ls=:dash, lc=:gray, label="")
+ if !isempty(pa.folds)
+ k = pa.folds[1] + 1
+ scatter!(p, [pa.ps[k]], [us[k]]; ms=8, mc=:red, label="fold detected")
+ end
+ save!(p, "continuation_fold.png")
+end
+
+println("Saved figures:")
+foreach(f -> println(" ", f), saved)
diff --git a/docs/make.jl b/docs/make.jl
index b62bf121..5fddf45d 100644
--- a/docs/make.jl
+++ b/docs/make.jl
@@ -37,9 +37,26 @@ makedocs(;
"Forcing Terms" => "forcing_terms.md",
"Perturbed Equilibrium" => "perturbed_equilibrium.md",
"Inner Layer" => "inner_layer.md",
+ "Islands" => "islands.md",
"Analysis" => "analysis.md",
"Utilities" => "utilities.md"
],
+ "Islands" => [
+ "Overview" => "islands/index.md",
+ "Numerics (as implemented)" => "islands/numerics.md",
+ "Paper I — figure contract" => "islands/papers/paper-1/OUTLINE.md",
+ "Design documents" => [
+ "00 — Roadmap" => "islands/design/00-roadmap.md",
+ "01 — Level-0 physics" => "islands/design/01-physics-level0.md",
+ "02 — Species and EPs" => "islands/design/02-species-and-eps.md",
+ "03 — Architecture" => "islands/design/03-architecture.md",
+ "04 — Numerics" => "islands/design/04-numerics.md",
+ "05 — Verification ladder" => "islands/design/05-verification.md",
+ "06 — Autonomy and tooling" => "islands/design/06-autonomy-and-tooling.md",
+ "07 — Documentation and papers" => "islands/design/07-documentation-and-papers.md",
+ "08 — Reference library" => "islands/design/08-reference-library.md"
+ ]
+ ],
"Citations" => "citations.md",
"Developer Notes" => "developer_notes.md",
],
diff --git a/docs/resources/Drift_Kinetic_Island_References/2018-Dudkovskaia-Island_Stability_in_Phase_Space.pdf b/docs/resources/Drift_Kinetic_Island_References/2018-Dudkovskaia-Island_Stability_in_Phase_Space.pdf
new file mode 100644
index 00000000..06148fee
Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2018-Dudkovskaia-Island_Stability_in_Phase_Space.pdf differ
diff --git a/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Drift_kinetic_response_of_ions_to_magnetic_island_perturbation_and_effects_on_NTM_threshold.pdf b/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Drift_kinetic_response_of_ions_to_magnetic_island_perturbation_and_effects_on_NTM_threshold.pdf
new file mode 100644
index 00000000..5259166b
Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Drift_kinetic_response_of_ions_to_magnetic_island_perturbation_and_effects_on_NTM_threshold.pdf differ
diff --git a/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Nonlinear_Kinetic_Ion_Response_to_Small_Scale_Magnetic_Islands_in_Tokamak_Plasmas.pdf b/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Nonlinear_Kinetic_Ion_Response_to_Small_Scale_Magnetic_Islands_in_Tokamak_Plasmas.pdf
new file mode 100644
index 00000000..094341bc
Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2018-Imada-Nonlinear_Kinetic_Ion_Response_to_Small_Scale_Magnetic_Islands_in_Tokamak_Plasmas.pdf differ
diff --git a/docs/resources/Drift_Kinetic_Island_References/2019-Dudkovskaia-Modelling_NTMs_in_tokamak_plasmas_PhD_dissertation.pdf b/docs/resources/Drift_Kinetic_Island_References/2019-Dudkovskaia-Modelling_NTMs_in_tokamak_plasmas_PhD_dissertation.pdf
new file mode 100644
index 00000000..fb50a695
Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2019-Dudkovskaia-Modelling_NTMs_in_tokamak_plasmas_PhD_dissertation.pdf differ
diff --git a/docs/resources/Drift_Kinetic_Island_References/2019-Imada-Finite_ion_orbit_width_effect_on_the_neoclassical_tearing_mode_threshold_in_a_tokamak_plasma.pdf b/docs/resources/Drift_Kinetic_Island_References/2019-Imada-Finite_ion_orbit_width_effect_on_the_neoclassical_tearing_mode_threshold_in_a_tokamak_plasma.pdf
new file mode 100644
index 00000000..83479cbb
Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2019-Imada-Finite_ion_orbit_width_effect_on_the_neoclassical_tearing_mode_threshold_in_a_tokamak_plasma.pdf differ
diff --git a/docs/resources/Drift_Kinetic_Island_References/2021-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_a_low_collisionality_tokamak_plasma_magnetic_island_threshold_physics.pdf b/docs/resources/Drift_Kinetic_Island_References/2021-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_a_low_collisionality_tokamak_plasma_magnetic_island_threshold_physics.pdf
new file mode 100644
index 00000000..467fc7be
Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2021-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_a_low_collisionality_tokamak_plasma_magnetic_island_threshold_physics.pdf differ
diff --git a/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_tokamak_plasmas_polarisation_current_and_its_effect_on_magnetic_island_threshold_physics.pdf b/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_tokamak_plasmas_polarisation_current_and_its_effect_on_magnetic_island_threshold_physics.pdf
new file mode 100644
index 00000000..46107246
Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_tokamak_plasmas_polarisation_current_and_its_effect_on_magnetic_island_threshold_physics.pdf differ
diff --git a/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_the_NTM_magnetic_islands_in_a_finite_beta_general_geometry_tokamak_plasma.pdf b/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_the_NTM_magnetic_islands_in_a_finite_beta_general_geometry_tokamak_plasma.pdf
new file mode 100644
index 00000000..49db982b
Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2023-Dudkovskaia-Drift_kinetic_theory_of_the_NTM_magnetic_islands_in_a_finite_beta_general_geometry_tokamak_plasma.pdf differ
diff --git a/docs/resources/Drift_Kinetic_Island_References/2023-Leigh-Drift_kinetic_simulations_of_Neoclassical_Tearing_Mode_instabilities_in_finite_collisionality_tokamak_plasmas.pdf b/docs/resources/Drift_Kinetic_Island_References/2023-Leigh-Drift_kinetic_simulations_of_Neoclassical_Tearing_Mode_instabilities_in_finite_collisionality_tokamak_plasmas.pdf
new file mode 100644
index 00000000..a7d34716
Binary files /dev/null and b/docs/resources/Drift_Kinetic_Island_References/2023-Leigh-Drift_kinetic_simulations_of_Neoclassical_Tearing_Mode_instabilities_in_finite_collisionality_tokamak_plasmas.pdf differ
diff --git a/docs/src/islands.md b/docs/src/islands.md
new file mode 100644
index 00000000..0a616786
--- /dev/null
+++ b/docs/src/islands.md
@@ -0,0 +1,108 @@
+# Islands Module
+
+The Islands module is a steady-state, multi-species drift-kinetic solver for the
+resonant magnetic island/layer region in tokamaks — the nonlinear analog of
+SLAYER, generalizing the Modified Rutherford Equation. It is under active
+development; the module conventions live in `src/Islands/CLAUDE.md`.
+
+This page is the **API reference**. The narrative documentation lives in the
+**Islands** section of this site: the [project overview](islands/index.md),
+the equations-and-figures chapter of what is
+[implemented and verified so far](islands/numerics.md), the
+[Paper I figure contract](islands/papers/paper-1/OUTLINE.md), and the full
+[design document set](islands/design/00-roadmap.md).
+
+## Status — M1 skeleton + M2 Level-0 solve machinery (structure, gated physics)
+
+M1 landed the numerical skeleton and M2 the Level-0 solve machinery — structure
+only, no physics numbers (module `CLAUDE.md`, the `[VERIFY]` policy):
+
+ - `Islands.PhaseSpace` — the `(x, ξ, λ→y, E, σ)` phase-space grids with
+ layer-clustered mappings (design `04 §1`): Fourier spectral `∂ξ`, high-order
+ finite-difference `∂x`/`∂y` on stretched grids, and Gauss energy quadrature.
+ Pure numerics; no physics coefficients.
+ - `Islands.Operators` — the `AbstractTerm` operator stack and residual assembly
+ (design `03 §2`) as allocation-free, AD-compatible structure, including the
+ mimetic (exactly conservative) pitch-angle diffusion and the neoclassical-
+ matching far-field boundary conditions (`01 §3` — never bare Neumann). Every
+ physics coefficient is a supplied data field, never a literal.
+ - `Islands.SpeciesLists` — first-class species arrays (`02 §1`, Decision D3):
+ backgrounds, `Bulk`/`Trace` roles, trace-criteria checks that warn and never
+ silently degrade.
+ - `Islands.Frames` — THE frequency/frame conversion module (`01 §5`): the
+ conversion *forms* with every sign/normalization a NaN-gated
+ `FrameConvention` field until human-cleared (QUESTIONS Q3).
+ - `Islands.Fields` — the Level-0 quasineutrality closure structure: `Q(Ω)`,
+ `h(Ω)` (supplied prefactor), the coefficient-free identity
+ `⟨∂²h/∂x²⟩_Ω = 0` (ladder A7), and the NaN-gated `ElectronClosure` constants.
+ - `Islands.Moments` — `J̄_∥` assembly and the `Δ_cos`/`Δ_sin` Ampère
+ projections (`01 §4`) with **required, gated** prefactors; `Ω`-average and
+ channel-split diagnostics.
+ - `Islands.Solvers` — matrix-free Newton–Krylov (ForwardDiff JVP + GMRES,
+ Eisenstat–Walker forcing), the TSVD-regularized physics-block preconditioner
+ skeleton (`04 §3, §5`), tiny-grid dense debug Jacobian, and pseudo-arclength
+ continuation with fold detection.
+ - `Islands.Verify` — MMS/JVP harness (ladder A1/A2), solve-level MMS and
+ zero-drive configurations (A5), and the `y_c`-block conditioning monitor
+ (A8), exercised by `test/runtests_islands_{grids,operators,solve}.jl`.
+
+Structural gates **A1–A5, A7 (coefficient-free part), A8** run in CI. The
+physics numbers (drift-frequency coefficients, collision kernels, closure
+constants, the Δ prefactors, York thresholds) remain `[VERIFY]`-gated: the
+B-ladder benchmarks in `benchmarks/islands/` ship skipped, each naming the
+`QUESTIONS.md` entries (Q2–Q4) whose human clearance un-gates it. The Paper-I
+figure contract is `docs/src/islands/papers/paper-1/OUTLINE.md`.
+
+## API Reference
+
+```@autodocs
+Modules = [GeneralizedPerturbedEquilibrium.Islands]
+```
+
+### Phase-space grids (`Islands.PhaseSpace`)
+
+```@autodocs
+Modules = [GeneralizedPerturbedEquilibrium.Islands.PhaseSpace]
+```
+
+### Species (`Islands.SpeciesLists`)
+
+```@autodocs
+Modules = [GeneralizedPerturbedEquilibrium.Islands.SpeciesLists]
+```
+
+### Frames and parameters (`Islands.Frames`)
+
+```@autodocs
+Modules = [GeneralizedPerturbedEquilibrium.Islands.Frames]
+```
+
+### Operator stack (`Islands.Operators`)
+
+```@autodocs
+Modules = [GeneralizedPerturbedEquilibrium.Islands.Operators]
+```
+
+### Field-equation closure structure (`Islands.Fields`)
+
+```@autodocs
+Modules = [GeneralizedPerturbedEquilibrium.Islands.Fields]
+```
+
+### Output moments (`Islands.Moments`)
+
+```@autodocs
+Modules = [GeneralizedPerturbedEquilibrium.Islands.Moments]
+```
+
+### Newton–Krylov solve (`Islands.Solvers`)
+
+```@autodocs
+Modules = [GeneralizedPerturbedEquilibrium.Islands.Solvers]
+```
+
+### Verification harness (`Islands.Verify`)
+
+```@autodocs
+Modules = [GeneralizedPerturbedEquilibrium.Islands.Verify]
+```
diff --git a/docs/src/islands/LOG.md b/docs/src/islands/LOG.md
new file mode 100644
index 00000000..2119e8e9
--- /dev/null
+++ b/docs/src/islands/LOG.md
@@ -0,0 +1,142 @@
+# Islands — session LOG
+
+Cross-session memory spine (design doc `06 §2.5`). Read this at session start
+together with `QUESTIONS.md`. Append a short entry before every session end:
+**what moved / what's blocked / next action**. Newest entries at the top.
+Reference `QUESTIONS.md` IDs (`Q`) and ladder IDs (`A1`, `B5a`, …) where
+relevant.
+
+---
+
+## 2026-07-08 — M2 L0 solve machinery: Newton–Krylov + moments + species/frames/fields (structure, gated physics)
+
+- **Contract**: `docs/src/islands/design/M2-launch-prompt.md` (interactive /goal
+ run). Branch `feature/islands-m2`, **PR #324** (stacked on
+ `feature/islands-m1`/PR #320; retargets to `feature/islands` when #320 merges).
+ Full suite green locally.
+- **Moved**: the full L0 solve *structure*, every physics coefficient a supplied
+ `[VERIFY]`-gated parameter (physics-verifier: **PASS**):
+ - `solvers/` — matrix-free Newton–Krylov (Krylov.jl GMRES on a preallocated
+ ForwardDiff JVP; Eisenstat–Walker; line search; convergence on norm AND
+ max-norm per `04 §5`), `YBlockJacobi` physics-block preconditioner with
+ TSVD-regularized pencil solves (the `04 §3` y_c treatment), dense tiny-grid
+ debug Jacobian, pseudo-arclength continuation with fold detection (toy fold
+ found at step 6 of the test problem).
+ - `species/` (D3 plumbing), `frames/` (conversion forms, NaN-gated
+ `FrameConvention`), `fields/` (Q(Ω)/h(Ω) structure + NaN-gated
+ `ElectronClosure`), `moments/` (J̄_∥, Δ projections with required gated
+ prefactors, ⟨·⟩_Ω diagnostics), operators additions (mimetic
+ `PitchAngleDiffusion`, `FarFieldConditions` — never bare Neumann,
+ `weighted_moment!`).
+ - **Structural gates green** (67 new tests in `runtests_islands_solve.jl`):
+ A5 (residual exactly 0 at g≡0), solve-MMS at design order (3.98 observed,
+ nx 17→33), A4 (conservation ≲1e-11, entropy sign exact), A3 parity, A7
+ ⟨∂²h/∂x²⟩_Ω ≈ 1e-16, A8 σ_min monitor + singular detection. Preconditioner
+ cuts a stiff collisional solve from 79.5 s/28 Newton to 0.6 s/7 (GMRES
+ 1700→200-class); all new kernels pass `--check-bounds=yes`.
+ - `benchmarks/islands/` created: B2/B4/B5 scripts **skipped**, each naming its
+ gating QUESTIONS IDs; `regression-harness` case `islands_l0_structural`
+ (solve-MMS err 5.254e-2, 6 Newton/1210 GMRES, A7 8.0e-17, σ_min 0.1139).
+ - Paper-I figure contract: `docs/src/islands/papers/paper-1/OUTLINE.md`
+ (claims C1–C3 green as CI artifacts; C4–C8 gated on Q2–Q4).
+ - **Rendered docs story** (user-flagged gap vs docs/07's M0–M1 intent): new
+ `docs/src/islands/numerics.md` — the equations + figures of everything as
+ implemented — plus the pinned figure script
+ (`benchmarks/islands/figures/make_structural_figures.jl`, five structural
+ figures committed as docs assets) and a full "Islands" site-nav section
+ (overview, numerics chapter, Paper-I contract, design docs 00–08).
+ Remaining docs/07 infra for later milestones: anchor-sync CI check,
+ STATE.md dashboard.
+- **Physics debugging note**: the first solve-MMS attempt failed to converge —
+ the generic `Collisions` (a_y ∂²y) term has no y-BCs, so its BVP
+ discretization is unstable under refinement; the *mimetic* divergence form
+ (degenerate P → 0 endpoints, zero-flux built in) is the correct structure and
+ the far-field x-BCs are what make the advective solve well-posed. Exactly the
+ design's point (`01 §3`, `04 §1`).
+- **Blocked**: the York gates (B5a/b/c, B2, B4) and Paper-I claims C4–C8 — all
+ on the human clearance queue **Q2/Q3/Q4** (unchanged).
+- **Next**: human clears Q2–Q4 → thin run fills the L0 coefficients from the
+ D7 re-derivation and un-skips the B-ladder; independent M2+ work: kinetic-
+ electron toggle (E4), io/ TOML section, trace-species linear pass.
+
+## 2026-07-08 — M1 skeleton: phase-space grids + operator stack + MMS/AD harness
+
+- **PR**: #320 (`feature/islands-m1` → `feature/islands`); full suite green.
+- **Moved**: Landed the M1 core (design `03 §1–2`, `04`, ladder `A1/A2`). Three
+ `src/Islands/` submodules, all structure-only (no `[VERIFY]` physics numbers):
+ - `phasespace/PhaseSpace.jl` — the `(x, ξ, y, E, σ)` grids with layer-clustered
+ maps: Fourier spectral `∂ξ`, Fornberg high-order FD `∂x`/`∂y` on `sinh`-stretched
+ grids (window sized per-derivative so `D1`/`D2` are both 4th-order incl.
+ boundaries), composite-Simpson quadrature weights, Gauss–Laguerre energy nodes.
+ - `operators/Operators.jl` — `AbstractTerm` + `apply!` + `residual!`; the term
+ structs of `03 §2` (`ParallelStreaming`, `MagneticDrift` with the
+ `:original/:improved` toggle, `ExBDrift` as the `(x,ξ)` Poisson bracket,
+ `Collisions`, `GradientDrive`, `PerpTransport`/`RadiationSink` L4 stubs,
+ `Quasineutrality` field residual). Every physics coefficient is a **supplied
+ data field** — no literal in `src/`. Allocation-free, AD-generic.
+ - `verify/Verify.jl` — manufactured-solution + AD-vs-FD JVP harness.
+ - Tests `test/runtests_islands_{grids,operators}.jl` (wired into `runtests.jl`):
+ A1 per-operator MMS → 4th order for `∂x/∂y` terms, machine-precision for the
+ `∂ξ` term; assembled kinetic residual → 4th order; A2 JVP-vs-FD agree to ~6e-9;
+ **allocation regression = 0 bytes** for every `apply!` and `residual!`. All
+ 53 Islands tests green. Added `ForwardDiff` to `Project.toml` (design `04 §9`).
+- **physics-verifier**: PASS — audited all six new/changed files, no
+ `[VERIFY]`-policy violation; the flagged literature numbers (8.73/1.46 ρ_bi,
+ k=−1.173, …) appear only in docstring prose, never assigned to a coefficient.
+- **Blocked**: nothing. **Q1 RESOLVED**: julia is at
+ `/mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia`; must be run with
+ `env -u LD_LIBRARY_PATH` (OMFIT contamination). Used it to run the suite here.
+- **Next**: M2 — wire moments (`Δ_cos`, `Δ_sin`), `frames/`, `species/`, and the
+ Newton–Krylov solver toward the L0 single-species solve; every physics
+ coefficient stays `[VERIFY]`-gated with a skipped benchmark until cleared.
+
+## 2026-07-08 — Harden Stop hook against OMFIT LD_LIBRARY_PATH contamination
+
+- **Moved**: Diagnosed why the Stop hook's package-load check fails on this box.
+ A loaded OMFIT module (`module load omfit/unstable`) leaks
+ `LD_LIBRARY_PATH=/mnt/codes/atom/mambaforge/envs/omfit/lib:` into the session;
+ those conda libs shadow Julia's bundled artifacts, giving `undefined symbol`
+ errors in CHOLMOD and the Plots/Cairo/GR native stack (and the ubiquitous
+ `libtinfo.so.6` bash warning). Not a code issue — CI is green, and
+ `env -u LD_LIBRARY_PATH julia … using GeneralizedPerturbedEquilibrium` loads
+ clean (exit 0). Fixed `stop-check.sh` to run the build check with
+ `env -u LD_LIBRARY_PATH` (no-op on a clean shell / CI). Repo deps were
+ instantiated here; the shared depot (`/mnt/codes/ncl2128/.julia`) is populated.
+- **Blocked**: nothing new. This is the concrete shape of **Q1** — the
+ automation shell must invoke julia with a clean `LD_LIBRARY_PATH` (unload
+ OMFIT, or unset the var) or the overnight loop's *actual* gpec runs fail the
+ same way, not just the hook.
+- **Next**: (human) launch the loop from a shell without the OMFIT module
+ (`module unload omfit`); hook hardening is defense-in-depth on top of that.
+
+## 2026-07-08 — Fix invalid deny rules in `.claude/settings.json`
+
+- **Moved**: `/doctor` flagged two skipped permission-deny rules —
+ `Bash(git push:* main)` / `Bash(git push:* develop)` — invalid because `:*`
+ (prefix match) is only allowed at the end of a pattern. Rewrote them with a
+ mid-pattern wildcard (`Bash(git push* main)` / `Bash(git push* develop)`) so
+ they load and again deny pushes to `main`/`develop` for any remote/flags.
+- **Blocked**: nothing.
+- **Next**: unchanged — pending items are the Phase A bootstrap **Next** below.
+
+## 2026-07-08 — Phase A bootstrap (supervised)
+
+- **Moved**: Created the `Islands` submodule skeleton (`src/Islands/Islands.jl`,
+ empty `module Islands`) and wired it into `src/GeneralizedPerturbedEquilibrium.jl`
+ (`include` + `import . as` + `export`, last submodule slot before `Rerun.jl`).
+ Stood up this `LOG.md` and `QUESTIONS.md`, the `.claude` unattended-run
+ guardrails, the `physics-verifier` subagent, and the M1 launch prompt.
+- **Landed CI-green** on `feature/islands` (PR #318): both `runtests` jobs pass
+ (the wiring is valid — the package loads and the full suite passes) and the
+ docs build passes. One fix was needed en route: the exported `Islands` module
+ docstring required a manual page under `checkdocs=:exports`, so
+ `docs/src/islands.md` (an `@autodocs` block) was added and wired into
+ `docs/make.jl` (repo-root CLAUDE.md docs-coverage rule).
+- **Blocked**: `julia` is not on the automation shell's PATH (no module, not in
+ `$HOME`) → changes could not be run locally; CI is the only Julia validation
+ here. See **Q1** — the overnight loop's scratch-clone environment must expose
+ `julia` or it cannot run tests / meet M1's definition-of-done.
+- **Next**: (human) resolve Q1 + one supervised `dontAsk` dry-run of the hooks,
+ then launch the overnight loop on milestone **M1** (design `00 §M1`) —
+ phase-space grids + operator-stack skeleton + MMS/AD harness (ladder A1, A2),
+ no `[VERIFY]` physics coefficients.
diff --git a/docs/src/islands/QUESTIONS.md b/docs/src/islands/QUESTIONS.md
new file mode 100644
index 00000000..ffdd052f
--- /dev/null
+++ b/docs/src/islands/QUESTIONS.md
@@ -0,0 +1,124 @@
+# Islands — blocker queue (QUESTIONS)
+
+Append-only non-blocking escalation queue (design doc `06 §2.2`). When blocked
+on anything the CLAUDE.md forbids guessing — `[VERIFY]` clearances, physics
+coefficients, signs, normalizations, convention/doc contradictions, or a tooling
+prerequisite — write an entry here **and switch to the next unblocked task**.
+Never stall waiting for a human; never resolve silently.
+
+Each entry:
+- **ID**: `Q` (monotonic). Commits/PRs reference the IDs they were blocked on
+ or unblocked by.
+- **Status**: `OPEN` / `RESOLVED (by , )`.
+- **Context**: what you were doing.
+- **Question**: the specific thing a human must decide.
+- **Options**: the alternatives considered.
+- **Recommendation**: your best guess (not acted on until cleared).
+- **Gated work**: what is blocked until this resolves.
+
+The human's recurring job is clearing this queue (and `[VERIFY]` tags), not
+supervising sessions.
+
+---
+
+## Q1 — Julia not on the automation shell PATH — RESOLVED (by Claude, 2026-07-08)
+
+- **Context**: Phase A bootstrap. Verifying the `Islands` module skeleton loads
+ (`using GeneralizedPerturbedEquilibrium`) and running the test suite requires
+ `julia`, but it was not on the non-interactive shell's PATH (no `julia` module,
+ none in `$HOME`; other users have installs under `/mnt/homes*/…/julia*`).
+- **Question**: What is the canonical `julia` invocation for automation on this
+ cluster, and will the overnight loop's scratch-clone environment expose it?
+- **Resolution**: the `ncl2128`-owned install is at
+ `/mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia` (option (b): an
+ absolute path to a user-owned binary), and it is on this session's PATH. The
+ M1 run used it to build and run `test/runtests.jl` locally. The **only caveat**
+ is the OMFIT `LD_LIBRARY_PATH` contamination already documented in LOG
+ (2026-07-08): the binary must be invoked with a clean loader path —
+ `env -u LD_LIBRARY_PATH /mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia
+ --project=. …` — or the conda libs shadow Julia's bundled artifacts. The Stop
+ hook already applies `env -u LD_LIBRARY_PATH`; the overnight loop's launch
+ script must do the same for its own gpec runs.
+- **Gated work (now unblocked)**: local verification of every Julia change; the
+ overnight loop's ability to run tests / meet its definition-of-done.
+
+## Q2 — Ratify Decisions D7 and D8 — RESOLVED (by the user, 2026-07-08)
+
+- **Resolution**: both ratified as written (option (a)); recorded in the
+ `docs/00` Decision Log. D7 additionally carries the user's clearance-mode
+ choice for Q3: **re-derivation first** — the L0 coefficient set is cleared by
+ human sign-off of in-repo derivations, not literature transcriptions.
+
+- **Context**: M2 setup. The L0 equation set and benchmark targets rest on two
+ Decision-Log proposals (`docs/00`) that are dated 2026-07-07 and still marked
+ "needs human ratification". Until ratified, M2 cannot fix the L0 physics form or
+ pin its benchmark tolerances.
+- **Question**: Ratify (or amend) **D7** — implement Level-0 physics from an
+ *independent re-derivation* cross-checked against the L23-amended equation set,
+ treating I19 Eq. (A.1) as printed as known-errata (L23 §2.6), with ω_E a scanned
+ input from day one — and **D8** — pin the benchmark grid as the three-code
+ triangle (DK-NTM / RDK-NTM / kokuchou) with B5a/b/c configs, superseding the
+ single "York thresholds" gate item.
+- **Options**: (a) ratify both as written; (b) ratify with amendments; (c) direct a
+ different L0 derivation strategy.
+- **Recommendation**: ratify both — they encode the project's core anti-guessing
+ thesis (published O(1) coefficients in this lineage are demonstrably wrong) and
+ the benchmark structure the ladder already assumes.
+- **Gated work**: pinning any L0 physics coefficient; the York gates B5a/b/c; the
+ Paper-I physics claims.
+
+## Q3 — Clear the Level-0 coefficient set ([CHECKED] → human sign-off) — OPEN (mode decided)
+
+- **Mode decision (user, 2026-07-08, via Q2/D7)**: option (b) — **re-derivation
+ first**. The next milestone (M2b) produces independent derivations of each
+ item below in `docs/src/islands/derivations/` (marked `[DERIVED]`, with a
+ cross-check table against the `[CHECKED]` transcriptions and every
+ discrepancy flagged); the human then signs off the *derivations*, which
+ clears the corresponding coefficients. Items remain individually OPEN until
+ that sign-off.
+
+- **Context**: M2 builds the L0 solve machinery with every physics coefficient a
+ parameterized `[VERIFY]` stub. Reaching the York gates needs these `[CHECKED]`
+ (AI-transcribed, cited, but not human-signed-off) items cleared. `[CHECKED]` is
+ not permission to hardcode (`docs/01` header). Each is implemented structurally;
+ none has a value in `src/`.
+- **Question**: Check each against its source PDF and sign off (record paper + Eq./p.
+ in `docs/01`), or flag a discrepancy:
+ - `ω̂_D` magnetic-drift frequency + the `:original`/`:improved` `L̂_B⁻¹` toggle
+ `[CHECKED: I19 Eq. (32) def.; D21 Eqs. 15, B1; D21 Eq. A2, p. 16]` — drives the
+ 8.73 → 1.46 ρ_bi threshold shift.
+ - Pitch-angle collision kernel `[CHECKED: I19 Eqs. (9)–(12); Diss19 Eqs. 2.25–2.30;
+ WCHH96 Eq. (62)]` + the analytic velocity average
+ `⟨ν̂_ii⟩_u = (4ε^{3/2}ν_★/3√π)(√2 − ln(1+√2))` `[CHECKED: L23 Eq. 4.1.6, p. 88]`
+ + normalization `ν_★ = ν_jj Rq/(ε^{3/2}v_th)` `[CHECKED: L23 Eq. (2.3.40)]`.
+ - Electron-closure constants `k ≃ −1.173` (Hirshman–Sigmar) and `f_p ≃ 1 − 1.46√ε`
+ `[CHECKED: I19 Eq. (22); L23 Eqs. 2.5.5–2.5.8]`.
+ - Quasineutrality closure `e_iΦ̂/T_i = [δn̄_i/n₀ + x − ĥ(Ω)]/(2 L̂_{n0})`
+ `[CHECKED: I19 Eq. (A.11); L23 Eq. (2.4.14)]` and the Picard form
+ `δΦ̂ = (δn̂_i − δn̂_e)/2` `[CHECKED: Diss19 Eq. 2.45]`.
+- **Options**: (a) clear item-by-item after PDF check; (b) require an independent
+ re-derivation first (couples to Q2/D7) before clearing.
+- **Recommendation**: clear via re-derivation (per D7) rather than transcription
+ alone — L23 §2.6 documents concrete errors in the published set.
+- **Gated work**: populating any of these coefficients; the York/large-w/polarization
+ gates (B5a/b/c, B2, B4); the A7 number-bearing identities (`k`, `f_p`, `⟨ν̂_ii⟩`).
+
+## Q4 — Resolve open [VERIFY]s and acquire two missing sources — OPEN
+
+- **Context**: Independent of the `[CHECKED]` clearances above, three genuinely
+ open `[VERIFY]` items block pinning M2's moment normalization and B5a tolerance.
+- **Question**:
+ - `ψ̃` amplitude: is it `(w_ψ²/4)(q_s′/q_s)` (Diss19/D21/L23, dimensional analysis)
+ or `(w_ψ²/4)(q_s/q_s′)` (one I19 extraction)? `[VERIFY: check I19 as printed —
+ possible typo in the paper itself]` — sets the `Δ_cos/Δ_sin` prefactor.
+ - B5a run collisionality: I19 §4.2 states `ν_★ = 0.01`; L23 p. 82 quotes DK-NTM at
+ `ν_★ = 10⁻³`. Resolve before pinning the B5a tolerance.
+ - Acquire **WCHH96** (analytic electron closure / large-w limits, B2) and **Park
+ PoP 29 (2022)** (SLAYER Q-convention map, D1) into the `docs/08` reference
+ library — both are currently cited only via transcription.
+- **Options**: (a) user resolves from the source PDFs; (b) an independent
+ re-derivation pins `ψ̃` and the collisionality is read from the reproduced run.
+- **Recommendation**: resolve `ψ̃` by re-derivation (it is a clean dimensional check);
+ read the B5a collisionality from whichever run the D8 triangle pins as canonical.
+- **Gated work**: the `Δ_cos/Δ_sin` normalization; the B5a threshold tolerance; the
+ B2 large-w and D1 SLAYER comparisons.
diff --git a/docs/src/islands/design/00-roadmap.md b/docs/src/islands/design/00-roadmap.md
new file mode 100644
index 00000000..1e4d1d54
--- /dev/null
+++ b/docs/src/islands/design/00-roadmap.md
@@ -0,0 +1,287 @@
+# 00 — Roadmap: the Level structure
+
+Each Level relaxes specific orderings of the Imada 2019 / Dudkovskaia / Leigh
+lineage (reference library: docs/08). The code never branches on "which level"
+— levels are *configurations* of the operator stack (docs/03), so any
+intermediate combination of toggles is legal. A Level is "done" when: (i) its
+verification gate below is green (docs/05), (ii) the Physics Book chapters
+covering its equations are complete and [VERIFY]-cleared, and (iii) its
+manuscript in the paper series is submission-ready, with every claim backed by
+a ladder ID and every figure regenerable from archived data (docs/07). Papers
+I–VI map to gates as defined in docs/07 §3; each level *starts* by writing the
+paper outline (the figure contract), not ends with it.
+
+---
+
+## Level 0 — DK-NTM reproduction (the benchmark configuration)
+
+**Orderings retained** (the I19/L23 set):
+- O1. Large aspect ratio ε ≪ 1, circular concentric surfaces, low β.
+- O2. Radially local: w ≪ r_s, constant background gradients across the domain.
+- O3. Prescribed island: single-harmonic, constant-ψ, fixed w (half-width
+ convention, docs/01 §1). No Ampère solve.
+- O4. Fixed equilibrium-E_r parameter ω_E (≡ −ω₀, the island propagation
+ frequency in the zero-E_r frame; docs/01 §5). The published York
+ thresholds sit at ω_E = 0; Islands treats ω_E as a scanned input from day
+ one (D23b already does), because Δ_pol ∝ ω_E² with a sign reversal near
+ −0.89 ω_dia,e makes single-ω_E polarization values misleading.
+- O5. Timescale ordering ω, ω_*, ω_D ≪ ω_bounce (orbit-averaged leading order
+ at fixed p_φ → 4D).
+- O6. Momentum-conserving pitch-angle collision model, banana regime ν_★ ≪ 1
+ (exact operator + the energy-dependence sub-toggle: docs/01 §2.3).
+- O7. Ions drift-kinetic; electrons via the WCHH96 analytic closure (flattened
+ h(Ω) profile + coupled parallel-flow relation, docs/01 §2.4), with
+ `electrons = :kinetic` (the RDK-NTM treatment) available as the E4
+ toggle.
+- O8. No perpendicular transport operator (w_d physics external).
+- O9. Maxwellian backgrounds, single bulk ion species *in the physics* — but the
+ species list is a first-class array from day one (docs/02). Multi-species
+ *plumbing* is a Level 0 requirement even though multi-species *physics*
+ is Level 1+.
+
+**Critical architectural decision made at Level 0 even though it only pays at
+Level 3:** discretize in (x, ξ), not island coordinates Ω or drift-surface
+coordinates S. Island/drift coordinates presuppose a separatrix and cannot
+represent shielded linear states; (x, ξ) representation makes shielding,
+penetration, and saturated islands points on one solution manifold. Island
+flux-surface averages are *diagnostics*. The RDK S-coordinate solve path
+exists as a cross-check mode (its full coefficient set is published: Diss19
+Eqs. D.60–D.62, D23b Eq. 19 + App. A), never as the primary representation.
+
+**Prior-art baseline to beat (new since the original plan):** kokuchou (L23)
+is a direct 4D implementation of this exact level and documents where it
+breaks: ν_★ floor 5×10⁻³ and ŵ ceiling 0.75 ρ̂_θi set by memory + separatrix
+resolution, Picard non-convergence, a singular trapped-passing matching
+matrix, and a spurious solution branch from Neumann far-field BCs (docs/04
+§§2–3, 6). Islands' architecture (matrix-free Newton–Krylov, adaptive
+layer-packed grids, neoclassical-matching BCs) is chosen point-by-point
+against that failure list. Getting *below* kokuchou's ν_★ floor while matching
+its thresholds is the headline Level-0 numerics deliverable.
+
+**Outputs:** Δ_cos(w, ω_E; p), Δ_sin(w, ω_E; p); flux-surface profiles (n, T,
+Φ, flows) across the island; J_∥(x, ξ) with species/channel partitions.
+
+**Gate:** (i) large-w analytic limits (ladder B2: Δ_bs+Δ_cur ∝ 1/w vs. WCHH96
+Eq. (85), Δ_pol ∝ 1/w³); (ii) York thresholds with exact configurations
+(B5a: w_c ≃ 2.76 ρ_θi ≡ 8.73 ρ_bi, :original drift model; B5b: w_c ≈ 0.45
+ρ_θi ≡ 1.46 ρ_bi half-width, :improved model; B5c: kokuchou's
+w_c ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵ finite-ν_★ surface); (iii)
+separatrix-layer polarization structure and the Δ_pol(ω_E) sign-reversal curve
+of D23b (B4, B6).
+
+---
+
+## Level 1 — Arbitrary collisionality + multi-species collisions
+
+**Relaxes:** O6.
+
+- Full linearized multi-species Fokker–Planck operator (momentum- and
+ energy-conserving; field-particle terms between all species pairs).
+ Bootstrap physics is unforgiving about non-conservative collision operators —
+ this is a correctness requirement, not a nicety.
+- Brute-force numerical resolution of the trapped-passing dissipation layer and
+ the separatrix layer at arbitrary ν (both widths ∝ ν^{1/2} at low ν, with the
+ E×B-dominated regime where the layer width tracks the iterating potential —
+ docs/04 §2). These are the layers RDK-NTM handles analytically and that set
+ kokuchou's operating floor; Level 1 owns beating them numerically.
+- **Tungsten physics lands here** (docs/02 §W): mixed-regime neoclassics (W in
+ Pfirsch–Schlüter/plateau while bulk ions are banana), ion–impurity friction
+ modification of the bootstrap drive, Z_eff effect on electron channel.
+ Deliverable: predicted in-island impurity density asymmetries, comparable to
+ AUG/DIII-D W-accumulation measurements.
+
+**Gate:** (i) in the no-island limit, bootstrap current and multi-species
+neoclassical flows vs. NEO/NCLASS across ν_★ (this doubles as the single most
+powerful global correctness check of the velocity-space discretization);
+(ii) Sauter coefficients recovered in the large-w limit across banana–plateau–PS;
+(iii) smooth connection of Level-0 low-ν results to collisional regimes,
+including closing the gap between kokuchou's ν_★ ≥ 5×10⁻³ window and
+RDK-NTM's ν_★ ≤ 10⁻³ window — the two prior codes never overlapped cleanly
+(ladder B7).
+
+---
+
+## Level 2 — General geometry, drop orbit averaging, energetic particles
+
+**Relaxes:** O1, O5, O2 (partially), O9 (backgrounds).
+
+- Retain poloidal angle: the kinetic problem becomes 5D (x, ξ, θ; λ, E).
+- Geometry arrives in two steps: **Miller analytic parametrization first**
+ (κ, δ, s_κ, s_δ, Shafranov shift — exactly D23a's geometry, giving direct
+ benchmark access to its shaping results), then equilibrium ingested from the
+ same numerical representations DCON/GPEC use (docs/03 §interfaces); shaped,
+ finite-β (the finite-β drift terms are the D23a Eq. 28–31 extension).
+ Analytic circular remains a regression toggle.
+- Widened, potentially nonlocal radial domain (several ρ_θα), with background
+ profile *variation* across the domain as a toggle (relaxing strict locality).
+- **Energetic particles land here** (docs/02 §EP): slowing-down F₀ (touches drive
+ terms and collision drag), alpha finite-orbit-width nonlocality (the drift-island
+ shift is no longer perturbative), and the precession resonance ω ~ ω_D,α — a
+ collisionless polarization-type contribution to Δ with no fluid analog. Highest
+ physics novelty in the program for burning plasmas (ITER/SPARC NTM thresholds
+ with self-consistent alpha kinetics).
+- Because alphas and W are trace in density, their response solves are *linear in
+ the trace species* given the bulk fields — cheap post-processing passes with an
+ additive contribution to Δ_cos/Δ_sin (SpeciesRole mechanism, docs/02 §roles).
+
+**Gate:** (i) general-geometry neoclassics vs. NEO; (ii) orbit frequencies
+(bounce, transit, precession) vs. analytic large-aspect-ratio formulas and a
+standalone orbit integrator; (iii) Level-0 results recovered when the circular
+low-β toggle set is applied; (iv) D23a shaping/finite-β targets with exact
+numbers (ladder C4: triangularity 2w_c 1.82 → 2.90 ρ_bi across δ = +0.42 →
+−0.5; the ε ≈ 0.3 crossover from w_c ∝ ε^{1/2}ρ_θi to ∝ ρ_θi; β_θ trend vs.
+EAST 91972).
+
+---
+
+## Level 3 — Self-consistent electromagnetics: the unification level
+
+**Relaxes:** O3 (and enables retiring O7).
+
+- Solve Ampère's law for the resonant helical harmonic(s) of A_∥ alongside the
+ kinetics. Island width/shape becomes an *output*; the external drive enters as
+ a boundary condition carrying Δ'(w) and/or the error-field amplitude from the
+ outer-region code.
+- Multiple ξ-harmonics; island deformation.
+- Kinetic (or reduced-fluid) electrons become necessary here: shielding currents
+ are carried by electrons; the flattened-electron closure O7 cannot shield.
+ (The RDK-NTM kinetic-electron machinery, already the E4 toggle, is the
+ starting point.)
+- **Small-amplitude limit = linear layer problem.** Verification against SLAYER's
+ Δ(Q) across its drift-MHD regimes. **Dependency note:** the SLAYER Δ(Q)
+ implementation arrives with GPEC's Tearing module work (PR #238,
+ `feature/tearing-growthrates`: `src/Tearing/InnerLayer/SLAYER/` Riccati layer
+ model + GGJ under the same interface, dispersion root-finding, and the
+ `delta_prime_raw` outer-region Δ′). That PR is sequenced to land before Islands
+ work begins (docs/06 §1); ladder D1's in-CI form then calls it directly.
+ The transition regime w ~ δ_layer (penetration bifurcation, kinetic) is the
+ flagship new-physics deliverable.
+- De-risking sub-track (strongly recommended, can start during Level 1): a
+ fluid-electron reduced configuration of Level 3 — essentially a kinetic-ion
+ analog of nonlinear cylindrical two-fluid codes (TM1-class) — to shake out the
+ (x, ξ) electromagnetic solve before full kinetics arrive.
+
+**Gate:** (i) linear limit vs. SLAYER Δ(Q) curves in shared regimes; (ii)
+constant-ψ recovery at small Δ' and w ≫ δ_layer; (iii) fluid-limit toggles vs.
+an established nonlinear cylindrical code (TM1 / XTOR-2F-class case); (iv)
+qualitative reproduction of Fitzpatrick's penetration bifurcation.
+
+---
+
+## Level 4 — Closures: rotation, transport, radiation
+
+**Relaxes:** O4, O8.
+
+- **Torque balance:** ω_E becomes an unknown closed by the Δ_sin = 0 root (the
+ sin ξ Ampère projection *is* the torque-balance condition — Diss19 Eq. 2.10)
+ plus flux-surface-averaged momentum balance against viscous/NTV restoring
+ torques (the in-repo KineticForces module is the natural NTV source),
+ appended to the Newton system — the nonlinear analog of SLAYER's
+ torque-balance closure. Multiple roots exist (Diss19 Fig. 4.18 found ±0.93,
+ ±1.28 ω_dia,e); continuation must track root branches, not assume
+ uniqueness. Quasi-static evolution: dw/dt from the MRE assembly, solved as a
+ sequence of steady states (arclength continuation in time-like parameter).
+- **Perpendicular transport operator:** model χ_⊥ (and D_⊥) as an explicit
+ operator, bringing Fitzpatrick's w_d threshold *inside* the fundamental
+ equation. Documented honestly as a closure knob (turbulence–island interaction
+ is not first-principles here).
+- **Radiative/thermo-resistive channel for W:** energy transport closure with a
+ radiation sink L_Z(T_e) n_W n_e inside the island and η(T_e) coupling — the
+ radiation-driven island / density-limit mechanism (Gates & Delgado-Aparicio
+ class). Requires Level 3 (η enters Ampère/Ohm) + Level 1 W transport.
+
+**Gate:** (i) w_d scaling vs. Fitzpatrick 1995; (ii) penetration thresholds with
+self-consistent torque balance vs. SLAYER-based thresholds in the linear limit
+and vs. empirical scalings in trend (La Haye database context, ladder B9);
+(iii) radiation-driven island growth vs. published thermo-resistive island
+models.
+
+---
+
+## Milestone sequencing (dependency graph, not a schedule)
+
+```
+M0 repo + CLAUDE.md + docs (this) ──┐
+M1 phase-space grids + operator stack skeleton + AD │ Level 0
+M2 L0 single-species solve, Δ moments, York gates │
+ → Paper I ──┘
+M3 FP collision operator + NEO cross-check ──┐ Level 1
+M4 W minority: friction/bootstrap + asymmetry result │
+ → Paper II ──┘
+M5 Miller + general geometry + 5D + orbit benchmarks ──┐ Level 2
+M6 slowing-down F0 + alpha trace response + ω_D res. │
+ → Paper III ──┘
+M7 fluid-electron (x,ξ) EM solve [start after M2] ──┐
+M8 kinetic-electron Ampère; SLAYER-limit gate │ Level 3
+M9 w ~ δ_layer transition study │
+ → Paper IV (flagship) ──┘
+M10 torque balance + χ⊥ + w_d gate ──┐
+M11 radiative W channel │ Level 4
+ → Paper V │
+M12 Δ-surface dataset + emulator release → Paper VI ──┘
+```
+
+Documentation infrastructure (Physics Book skeleton, anchor-sync CI, figure
+pipeline, STATE dashboard — docs/07) is part of M0–M1, not deferred: the first
+operator merged is the first operator anchored.
+
+Parallelism: M3–M4 and M7 are independent of each other; M5–M6 depends on M3
+(collision operator) but not M7.
+
+**Sequencing against GPEC:** the Tearing module PR (#238,
+`feature/tearing-growthrates` — SLAYER + GGJ inner layers, dispersion solver,
+Δ′ machinery) lands **before** M0. If M0 starts while #238 is still open, the
+Islands branch is cut from `feature/tearing-growthrates` rather than `develop`,
+so the SLAYER/Δ′ interfaces Islands consumes are in hand from the first commit.
+
+## Risk register
+
+| Risk | Level | Mitigation |
+|---|---|---|
+| Separatrix + trapped-passing layers unresolvable at low ν without RDK-style analytics — **confirmed by kokuchou hitting a ν_★ = 5×10⁻³ floor** | 0–1 | Mapped/adaptive grids clustered at both layers using the now-known ∝ν^{1/2} width estimates (docs/04 §2); matrix-free removes kokuchou's memory wall; RDK reduction retained as cross-check; accept and document a ν floor per resolution tier |
+| Trapped-passing matching block is intrinsically singular; plain linear algebra yields silent noise, not errors | 0+ | Explicit regularized (TSVD-style) treatment of the y_c block in the preconditioner; smallest-singular-value CI monitor (ladder A8); basis-change spike in M1 (docs/04 §3) |
+| Separatrix-layer width depends on Φ̂ and moves between nonlinear iterations (E×B-dominated regime) | 0–1 | Pack from lower-bound width estimates over the expected Φ̂ range; post-solve validation of layer resolution; re-mesh-and-continue fallback (docs/04 §2) |
+| Far-field BCs admit spurious solution branches (kokuchou's "winged" states under Neumann) | 0+ | Neoclassical-matching far-field BCs, never bare Neumann; continuation warm-starts; spurious-branch detection via far-field flow comparison against no-island neoclassics (docs/01 §3) |
+| Published equation sets in the lineage contain errors (L23 §2.6 amendment list against I19 Eq. A.1) | 0 | Independent re-derivation before implementation ([VERIFY]/[DERIVED] policy); benchmark against L23-amended physics; standing triage category in docs/05 reporting rules |
+| (x, ξ) small-amplitude limit fails to reproduce delicate linear layer structure | 3 | This is *the* physics risk. De-risk via M7 fluid track; verify against SLAYER regime-by-regime; budget the painful months here |
+| In-repo SLAYER not merged yet (on `develop` it is a placeholder; the implementation lives on PR #238 `feature/tearing-growthrates`) | 3 | Sequence #238 before M0; branch Islands from `feature/tearing-growthrates` if starting earlier; fall back to published Park 2022 curves only if that branch stalls |
+| Non-conservative collision discretization poisons bootstrap | 1 | NEO no-island cross-check as a CI-level gate; conservation tests as unit tests |
+| ω_E sensitivity of polarization makes single-point Δ misleading (Δ_pol ∝ ω_E², sign flip near −0.89 ω_dia,e; L23's anomalous electron Δ_pol at ω_E = 0) | 0–4 | Always publish Δ as surfaces over (w, ω_E), never single points, until Level 4 closes ω_E; E4/E6 toggle studies address the open electron-Δ_pol question |
+| 5D cost explosion at Level 2 | 2 | Orbit-averaged (4D) mode retained as toggle; trace-species linear passes; emulator strategy assumes expensive solves |
+| Turbulence–island interaction hiding in χ⊥ | 4 | Explicit closure-knob documentation; sensitivity scans part of every Level-4 result |
+
+## Decision log (append-only)
+
+- D1 (adopted): primary representation (x, ξ), never Ω or S. Rationale: Level-3
+ unification; island coordinates cannot represent shielded states.
+- D2 (adopted): steady-state Newton–Krylov + continuation, not initial-value
+ time-stepping and not nested Picard loops. Rationale: Δ-surface generation is
+ the product; continuation produces it as a byproduct; bifurcation tracking
+ needs it; kokuchou's documented Picard non-convergence (L23 §6.1.1) is the
+ empirical case against the alternative.
+- D3 (adopted): species list first-class at Level 0. Rationale: retrofit cost ≫
+ upfront cost; trace-role machinery needed by both W and EP tracks.
+- D4 (adopted): Julia, as a submodule of the GeneralizedPerturbedEquilibrium
+ package (`src/Islands/`, `module Islands` — no separate Project.toml).
+ Rationale: AD through the operator stack (exact Jacobians + ∂Δ/∂p
+ sensitivities), and GPEC-stack affinity (direct calls to the Δ′/SLAYER/
+ equilibrium machinery, docs/06 §1).
+- D5 (open): velocity coordinates (λ, E) vs. (v_∥, v_⊥) vs. (θ_b-aligned).
+ Default (λ, E) with σ = sgn(v_∥); revisit at Level 2 when θ is retained.
+ Note: prior art all uses y = λB_max with the y_c = 1 boundary; the singular
+ matching block (docs/04 §3) is a point against inheriting it unexamined.
+- D6 (open): kinetic electron treatment at Level 3 — full DKE vs. reduced
+ (parallel-kinetic) electron model. Decide after M7 results. The RDK-NTM
+ kinetic-electron formulation (Diss19 Eq. D.61) is the full-DKE candidate.
+- D7 (adopted; ratified by the user 2026-07-08, QUESTIONS Q2): implement Level-0
+ physics from an independent re-derivation cross-checked against the
+ L23-amended equation set, treating I19 Eq. (A.1) as printed as known-errata;
+ ω_E enters as a scanned input parameter at Level 0 (not deferred to L4).
+ Rationale: L23 §2.6 amendment list; D23b ω_E-parametric formulation.
+ Clearance mode (user, 2026-07-08): **re-derivation first** — the Q3
+ coefficient set is cleared by human sign-off of in-repo derivations
+ (`docs/src/islands/derivations/`), not of literature transcriptions.
+- D8 (adopted; ratified by the user 2026-07-08, QUESTIONS Q2): benchmark grid =
+ the three-code triangle (DK-NTM published numbers, RDK-NTM improved-model
+ numbers, kokuchou finite-ν_★ surface) with the B5a/B5b/B5c configurations
+ pinned in docs/05, superseding the single "York thresholds" gate item.
diff --git a/docs/src/islands/design/01-physics-level0.md b/docs/src/islands/design/01-physics-level0.md
new file mode 100644
index 00000000..45f1e401
--- /dev/null
+++ b/docs/src/islands/design/01-physics-level0.md
@@ -0,0 +1,336 @@
+# 01 — Level 0 physics formulation
+
+Scope: the equation set for the benchmark configuration (roadmap O1–O9).
+
+**Tag semantics (updated 2026-07-07).** The full source set now lives in-repo at
+`docs/resources/Drift_Kinetic_Island_References/` (see docs/08 for the library
+map). Expressions below were transcribed from the PDFs and checked against them
+by AI extraction; these carry **[CHECKED: source, Eq./p.]** and still require
+one human sign-off before the corresponding [VERIFY] discipline is considered
+cleared (CLAUDE.md policy). Anything still uncertain carries **[VERIFY: ...]**
+with the specific question stated.
+
+Primary sources: Imada et al. NF 59, 046016 (2019) — **I19** (complete DK-NTM
+reference; the 2018 PRL 121, 175001 and JPCS 1125, 012013 are its compact
+antecedents); Dudkovskaia PhD dissertation, York 2019 — **Diss19** (full RDK
+derivation chain, Appendices C–E); Dudkovskaia et al. PPCF 63, 054001 (2021) —
+**D21**; Dudkovskaia et al. NF 63, 016020 (2023) — **D23a** (finite-β, shaped
+geometry); NF 63, 126040 (2023) — **D23b** (separatrix layer, polarization,
+ω-dependence); Leigh PhD thesis, York Dec 2023 — **L23** (the `kokuchou` code:
+amended DK-NTM equations, finite-ν★ thresholds, numerics forensics); Wilson,
+Connor, Hastie & Hegna, PoP 3, 248 (1996) — **WCHH96** (analytic electron
+closure and large-w limits).
+
+> **Load-bearing warning.** L23 §2.6 (pp. 59–60) documents concrete errors in
+> the *published* I19 equation set (Eq. A.1): a missing ρ̂_θi factor on the
+> ∂²ĝ/∂p̂² diffusion term (making it ∝ ρ̂²_θi), a missing ν̂_ii ρ̂_θi coefficient
+> on ∂ĝ/∂p̂, missing factors on the Maxwellian-gradient drive terms, a corrected
+> momentum-conserving term Û_∥i(ĝ + p̂F̂′_Ms), and a sign fix in the Δ_loc
+> relation. **Islands must implement from an independently re-derived equation
+> set benchmarked against L23's amended form, never from I19 Eq. (A.1) as
+> printed.** This is the empirical justification for the whole [VERIFY]
+> policy: the literature's O(1) coefficients are demonstrably not to be
+> trusted without re-derivation. [CHECKED: L23 §2.6]
+
+---
+
+## 1. Geometry and coordinates
+
+Local region around the rational surface ψ_s (minor radius r_s) of an m/n mode
+in a large-aspect-ratio circular tokamak, B₀ = I(ψ)∇φ + ∇φ×∇ψ, I = RB_φ,
+B ≈ B₀(1 − ε cos θ), ε = r_s/R₀ ≪ 1 [CHECKED: I19 Eq. (3)].
+
+- Radial coordinate: x ∝ ψ − ψ_s. **Pin one normalization and write the map to
+ the others**: I19 uses x = (ψ−ψ_s)/ψ_s with ŵ = w/r_s, ρ̂_θi = ρ_θi/r_s;
+ D21/D23b normalize radial quantities to the island width w_ψ
+ (ρ̂_θj = I V_Tj/(ω_cj w_ψ)); the 2018 PRL normalizes to ψ_s. These
+ inconsistent conventions across the same lineage are a transcription hazard —
+ Islands' own normalization (§5) is r_s-based, with conversion factors in one
+ place. [CHECKED: I19 p. 6; D21 Eq. 19; PRL Eq. (4) note]
+- Helical angle: ξ = m(θ − φ/q_s) (I19 Eq. (6)); Diss19/D21 use ξ = φ − q_s θ
+ with the cos nξ harmonic — same island, different angle multiplicity. Pin
+ Islands' ξ to the I19 form and document the map. **Island rest frame:** all
+ Level-0 solves are steady in this frame.
+- Poloidal angle θ is eliminated at leading order by orbit averaging at fixed
+ p_φ (O5); it reappears at Level 2.
+
+Perturbation (prescribed at Level 0, O3), single-helicity, constant-ψ:
+
+ A_∥ = −(ψ̃/R) cos ξ, ψ̃ = (w_ψ²/4)(q_s′/q_s), q_s′ = dq/dψ|_s
+ [CHECKED: I19 Eq. (5); Diss19 p. 30; L23 Eq. (2.1.4)]
+
+where **w_ψ is the island HALF-width in ψ-space**, w = w_ψ/(RB_θ) the
+half-width in minor radius. Note: one extraction of I19 rendered the amplitude
+as (w_ψ²/4)(q_s/q_s′); dimensional analysis and Diss19/D21/L23 all give
+q_s′/q_s. [VERIFY: check I19 as printed — possible typo in the paper itself.]
+
+Island label and convention (pinned, matches every source in the lineage):
+
+ Ω(x, ξ) = 2(ψ−ψ_s)²/w_ψ² − cos ξ, Ω = −1 at O-point, Ω = +1 at separatrix
+ [CHECKED: I19 Eq. (7); Diss19 Eq. 2.7; L23 Eq. (2.1.8)]
+
+**All York threshold numbers are HALF-widths** (D23a abstract states
+"threshold magnetic island half-width"; L23 footnote p. 130 notes La Haye's
+experimental fits quote the *full* width w_marg = 2w_c). This half/full-width
+bookkeeping is pinned here and in docs/05.
+
+Even at Level 0, the *stored representation* of the field is A_∥(x, ξ) on the
+(x, ξ) grid (decision D1); Ω is computed, never fundamental.
+
+## 2. Kinetic equation
+
+Per species j, phase space (x, ξ, λ, v, σ) with pitch λ = μ/E (grid variable
+y = λB_max, trapped–passing boundary y_c = 1), v the speed, σ = sgn(v_∥). Split
+
+ f_j = (1 − e_j Φ/T_j) F_Mj(ψ_s) + g_j, [CHECKED: I19 Eq. (28) form; Diss19 Eq. 2.15]
+
+with F_M a Maxwellian carrying background gradients at r_s (strictly local,
+O2). The steady drift-kinetic equation in the island frame [CHECKED: I19 Eq. (8)]:
+
+ v_∥∇_∥f + v_E·∇f + v_b·∇f − (e_j/m_j v)(v_∥∇_∥Φ + v_b·∇Φ) ∂f/∂v = C_j(f)
+
+with v_E = B×∇Φ/B², v_b = −v_∥ b×∇(v_∥/ω_cj). Orderings: Δ = w/r ≪ 1;
+e_jΦ/T_j ~ g_j/F_M ~ Δ; B₁/B₀ ~ εΔ²; collisions O(Δ) below free streaming;
+ions retain ρ_θi ~ w (finite orbit width — the key relaxation), electrons have
+ρ_θe ≪ w. [CHECKED: I19 §1, §4; Diss19 p. 33]
+
+The radial coordinate is traded for the canonical momentum
+
+ p_φ = (ψ − ψ_s) − I v_∥/ω_cj [CHECKED: I19 Eq. (2)]
+
+and θ is annihilated by orbit averaging at fixed p_φ (passing: (1/2π)∮dθ;
+trapped: (1/2π)Σ_σ σ∫_{−θ_b}^{θ_b}dθ) [CHECKED: I19 Eq. (31); Diss19 Eq. 2.24].
+The master 4D equation for the orbit-averaged distribution Ḡ₀(p̂, ξ, y; v̂, σ)
+is **I19 Eq. (32)** (structure confirmed; coefficients subject to the L23 §2.6
+amendments — implement from re-derivation):
+
+ −m[ (p̂/L̂_q)Θ(y_c−y) + ρ̂_θi ω̂_D − (ρ̂_θi/2)⟨(1/v̂_∥)∂Φ̂/∂x⟩_θ ] ∂Ḡ₀/∂ξ|_p̂
+ + m[ (ŵ²/4L̂_q) sin ξ Θ(y_c−y) − (ρ̂_θi/2)⟨(1/v̂_∥)∂Φ̂/∂ξ⟩_θ ] ∂Ḡ₀/∂p̂
+ = ⟨(1/v̂_∥)Ĉ_ii(Ḡ₀)⟩_θ
+
+The three transport channels of the design (island-induced streaming, magnetic
+drift, E×B) are the three bracketed frequencies above; they map one-to-one onto
+the operator stack (docs/03 §2).
+
+### 2.1 Magnetic drift frequency: the original/improved toggle (now precise)
+
+Orbit-averaged precession [CHECKED: I19 Eq. (32) def.; D21 Eqs. 15, B1]:
+
+ ω̂_D = [σv̂/(1+ε)] [ (1/L̂_q)⟨√(1−yb)/b⟩_θ − (1/2)(1/L̂_B)⟨(2−yb)/(b√(1−yb))⟩_θ ]
+
+- **:original** (I19/DK-NTM): finite constant L̂_B⁻¹ = (ψ_s/B)∂B/∂ψ — retains a
+ non-vanishing ∇B term after orbit averaging.
+- **:improved** (D21/RDK-NTM): Appendix A of D21 shows
+ ∂B/∂ψ = −(B_φ/(R₀²B_θ)) cos θ + O(ε²) — the cos θ modulation makes the term
+ ε-small after orbit averaging; **L̂_B⁻¹ = 0 is the documented proxy** (D21
+ footnote 10, Fig. 8 compares proxy vs full cos θ form directly).
+ [CHECKED: D21 Eq. A2, p. 16]
+
+This single toggle is what moved the threshold half-width 8.73 ρ_bi → 1.46 ρ_bi
+(D23a abstract). It is the archetype of the toggle-impact studies (docs/05 E1).
+
+### 2.2 The drift-island structure (must emerge from the solve, not be assumed)
+
+The exact drift-surface label [CHECKED: I19 Eq. (33); D21 Eq. 21; Diss19 Eq. 2.37]:
+
+ S = (ŵ²/4L̂_q)[ 2(p̂ − ρ̂_θi ω̂_D L̂_q)²/ŵ² − cos ξ ] Θ(y_c−y)
+ − p̂ ρ̂_θi ω̂_D Θ(y−y_c) − (1/2)⟨(ρ̂_θi/v̂_∥) Φ̂⟩_θ
+
+Passing particles: constant-S surfaces are the magnetic island **radially
+shifted by x_D = ρ̂_θi ω̂_D(y, v̂; σ) L̂_q** — σ-dependent, equal and opposite
+for v_∥ ≷ 0, pitch/energy-dependent through ω̂_D. (There is no separate
+"h(λ,E)" shift function in the sources; the shift *is* ρ̂_θi ω̂_D L̂_q. The
+symbol h(Ω) is reserved for the electron profile function, §2.4.) Flattening
+of f on drift islands rather than the magnetic island sustains pressure
+gradients across small islands (w ~ ρ_θi) and weakens the bootstrap drive —
+the kinetic threshold mechanism, carried by **passing** particles (D21 §7:
+passing-particle physics, not banana-orbit physics; ρ_bi is merely the natural
+unit at ε = 0.1). Trapped particles: S ∝ p̂ (no island structure); response
+tied to the magnetic island.
+
+In DK (4D direct) mode Islands does **not** impose S-structure; it must *emerge*.
+The RDK reduction — solve the 1D collisional constraint ⟨Ĉ/𝒜⟩_ξ^S g^(0,0) = 0
+per S-contour [CHECKED: D21 Eqs. 23–24; explicit coefficient forms Diss19
+Eqs. D.60–D.62 and D23b Eq. 19 + Appendix A] — is retained as a cross-check
+mode valid for δ_j = ν_j/(εω_b) ≪ 1.
+
+### 2.3 Collision operator (Level 0)
+
+Momentum-conserving pitch-angle (Lorentz) model [CHECKED: I19 Eqs. (9)–(12);
+Diss19 Eqs. 2.25–2.30; lineage: WCHH96 Eq. (62)]:
+
+ C_jj(f) = 2ν_jj(v)[ (√(1−λB)/B) ∂_λ( λ√(1−λB) ∂_λ f ) + v_∥ ū_∥j f /v²_thj · F_Mj-normalized ]
+ ū_∥j(f) = (1/(n⟨ν_jj⟩_v)) ∫d³v ν_jj v_∥ f (momentum restoring)
+ C_ei drags on the ION flow u_∥i (species coupling)
+
+λ-derivatives at **fixed ψ**, not fixed p_φ (a classic transcription trap).
+Energy dependence: two variants exist in the lineage and become a documented
+sub-toggle — I19/L23 use the full ν_jj(v) = ν̃_jj[φ(v̂) − G(v̂)]/v̂³ (Chandrasekhar
+G; needed for neoclassical fidelity), while Diss19/D21 use the simpler
+ν(V) ∝ V⁻³. L23 additionally derives the analytic velocity average
+⟨ν̂_ii⟩_u = (4ε^{3/2}ν_★/3√π)(√2 − ln(1+√2)) and uses it in place of an
+inaccurate numerical u-integral (ν̃ ∝ u⁻² divergence at low u) — adopt this.
+[CHECKED: L23 Eq. 4.1.6, p. 88]
+
+Collisionality normalization: ν_★ = ν_jj Rq/(ε^{3/2} v_th) (banana regime
+ν_★ ≪ 1); ν̂_jj = ε^{3/2}ν_★ ν̃_jj(u). [CHECKED: L23 Eq. (2.3.40); Diss19
+footnote 26]
+
+Replaced wholesale at Level 1 by the multi-species Fokker–Planck operator.
+
+### 2.4 Electrons at Level 0 (O7) — closure now exact
+
+ρ_θe ≪ w ⇒ electron drift islands coincide with the magnetic island. The
+analytic closure is WCHH96's, as used by I19/L23 [CHECKED: I19 Eqs. (14)–(22);
+L23 §2.4]:
+
+ f_e = (1 − e_eΦ/T_e) F_Mes + h(Ω) F′_Mes − (Iv_∥/ω_ce) F′_Mes ∂h/∂ψ + h̄_e
+ h(Ω) = Θ(Ω−1) (w_ψ/2√2) ∫₁^Ω dΩ′/Q(Ω′), Q(Ω) = (1/2π)∮√(Ω+cos ξ) dξ
+
+h(Ω) is exactly flat inside the separatrix, → x far away, and satisfies
+⟨∂²h/∂x²⟩_Ω = 0 (unit-test target; L23 Eq. 4.1.1). Flux-surface-averaged
+electron flow [CHECKED: I19 Eq. (22); L23 Eqs. 2.5.5–2.5.8]:
+
+ ⟨⟨Bu_∥e⟩_θ⟩_Ω/(B₀v_the) = −[f_t/(1+f_t)](Iv_the/ω_ce)(n′/n)(1 + η_e + ½ k f_c η_e)⟨∂h/∂ψ⟩_Ω
+ + [f_c/(1+f_t)] ⟨⟨Bu_∥i⟩_θ⟩_Ω/(B₀v_thi)
+
+with k ≃ −1.173 (Hirshman–Sigmar; unit-test: L23 reproduces −1.1730) and
+f_p ≃ 1 − 1.46√ε. Note the electron current depends on the *numerically
+computed ion flow* (momentum conservation) — the closure is coupled, not
+one-way. Toggle `electrons = :flattened | :kinetic`: the `:kinetic` option is
+exactly RDK-NTM's defining feature (electrons solved with the same drift-island
+machinery as ions, Diss19 Eq. D.61 / D21 §5) and is *required* at Level 3
+(shielding); running it at Level 0 against `:flattened` is toggle study E4.
+
+## 3. Field equation (Level 0: quasineutrality only)
+
+ n_i[Φ; g_i] = n_e[Φ; closure] → Φ(x, ξ)
+
+Exact Level-0 closed form with flattened electrons [CHECKED: I19 Eq. (A.11);
+L23 Eq. (2.4.14)]:
+
+ e_iΦ̂/T_i = [ δn̄_i/n₀ + x − ĥ(Ω) ] / (2 L̂_{n0})
+
+(T_e = T_i assumed in the sources; Islands keeps τ = T_e/T_i general and flags
+departures). With kinetic electrons, the Picard form δΦ̂ = (δn̂_i − δn̂_e)/2
+[CHECKED: Diss19 Eq. 2.45]. In Islands both reduce to one quasineutrality
+residual inside the global Newton system (docs/03) — the sources' nested
+Picard loops (Φ outer, ū_∥i inner; I19 fig. A1) are precisely the fragile
+iteration structure Newton–Krylov replaces; L23 §6.1.1 reports the Picard
+convergence criterion was *never met* in production (Φ̂ array-max residuals
+> 100%/iteration at large ŵ) even as Δ stabilized — treat that as the
+cautionary tale motivating D2.
+
+Boundary conditions: g → neoclassical (no-island) solution and Φ̂ → background
+E_r potential as |x| → L_x; periodic in ξ. **Do not use bare Neumann
+∂ĝ/∂p̂ = 0**: L23 §5.3/§7.1 traces its non-physical "winged" solution branch
+(flows extending 8–10 island widths, disagreeing with neoclassical theory) to
+the Neumann condition admitting multiple numerically-valid solutions, and
+recommends matching to the analytic far-field limit — which is exactly Islands'
+neoclassical-matching BC. [CHECKED: L23 pp. 113–115, 141]
+
+Ampère is **not** solved at Level 0 (O3). The Ampère residual is evaluated as
+a diagnostic from day one; its resonant moments are the Δ outputs:
+
+## 4. Output moments and MRE assembly (normalization now exact)
+
+Parallel current J̄_∥ = θ-average of Σ_j e_j n_j u_∥j. The two projections of
+parallel Ampère through the island [CHECKED: Diss19 Eqs. 2.9–2.10; D21
+Eqs. 7–8, 32]:
+
+ (1/μ₀R) Δ′ ψ̃ = ∫_ℝ dψ ∮ dξ J̄_∥ cos ξ (growth: matching to Δ′)
+ 0 = ∫_ℝ dψ ∮ dξ J̄_∥ sin ξ (torque balance / rotation)
+
+so the kinetic drive and torque moments are
+
+ Δ_cos ≡ Δ_neo = −(μ₀R/2ψ̃) ∫ dψ ∮ dξ J̄_∥ cos ξ, stationarity: Δ′ + Δ_neo = 0
+ Δ_sin = (μ₀R/2ψ̃) ∫ dψ ∮ dξ J̄_∥ sin ξ [CHECKED: Diss19 Eq. 4.12 for Δ_neo;
+ sin-moment normalization chosen symmetric — [DERIVED] pin at implementation]
+
+with ψ̃ = (w_ψ²/4)(q_s′/q_s), and the Rutherford LHS (2τ_R/r_s²) dw/dt (w =
+half-width). Decomposition diagnostics [CHECKED: Diss19 Eqs. 4.13–4.15; D21
+Eqs. 33–34; D23b §4]:
+
+- **Bootstrap+curvature part**: the Ω-flux-surface-constant part of J̄_∥,
+ ⟨J̄_∥⟩_Ω with ⟨·⟩_Ω = ∮·(Ω+cosξ)^{−1/2}dξ / ∮(Ω+cosξ)^{−1/2}dξ.
+- **Polarization part**: Δ_pol = Δ_neo − (Δ_bs+Δ_cur) — the piece that
+ flux-surface-averages to zero. (L23 Eq. 2.5.3 flags this split as
+ approximate bookkeeping — "could comprise similar contributions from other
+ sources" — which is the design's position: partition is diagnostic, the
+ solve never separates channels.)
+- Species partition (ion vs electron) alongside: L23 finds the *electron*
+ channel dominates both Δ_bs and (unexpectedly, at ω_E = 0) the stabilizing
+ Δ_pol — an open physics question Islands can settle with the ω_E scan.
+
+**Analytic large-w limits to recover** (ladder B2): Δ_bs+Δ_cur ∝ 1/w matching
+WCHH96 Eq. (85) — with the caveat that Eq. (85) is derived in the E_r = 0
+frame while the island-frame calculation must be mapped before comparison
+(Diss19 p. 86) — generic scaling Δ_bs ~ ε^{1/2}(L_q/L_p)(β_θ/w); and
+Δ_pol ∝ 1/w³ at large w. [CHECKED: Diss19 pp. 84–86; D21 p. 2]
+
+In the linear limit, (Δ_cos + iΔ_sin) ↔ the complex layer Δ(Q) (SLAYER
+convention map still [VERIFY: Park PoP 29 (2022) — paper not yet in the
+reference library; acquire]).
+
+## 5. Nondimensionalization and frames (the input parameter vector p)
+
+Normalizations (r_s-based, following I19): x = (ψ−ψ_s)/ψ_s, ŵ = w/r_s,
+ρ̂_θj = ρ_θj/r_s, v̂ = v/v_thj, y = λB_max, b = B/B_max, L̂_q⁻¹ = (ψ_s/q)dq/dψ,
+L̂_n⁻¹ = (ψ_s/n)dn/dψ, Φ̂ = e_jΦ/T_j, ν̂ = ε^{3/2}ν_★ν̃(v̂). Conversion maps to
+the D21 (w_ψ-based) and PRL (ψ_s-based) conventions live in `src/frames/`
+alongside the frequency maps. [CHECKED: I19 p. 6; L23 Eqs. 2.3.40–2.3.46]
+
+**Frame identities (now source-confirmed, the frames-module spec):**
+
+- ω_dia,e = m T_e n₀′/(−e q_s n₀); ω_E ≡ m Φ′_eqm/q_s; ω̂_E = ω_E/ω_dia,e.
+ [CHECKED: Diss19 p. 46]
+- The combination **ω − ω_E is frame-independent**; with ω₀ the island
+ propagation frequency in the frame where E_r → 0 far from the island,
+ **ω₀ = −ω_E** (island-rest-frame calculation at equilibrium-potential
+ gradient ω_E ⇔ island rotating at −ω_E in the zero-E_r frame).
+ [CHECKED: Diss19 pp. 47–48]
+- The effective density gradient shifts with frame:
+ L_n⁻¹ = L_{n0}⁻¹(1 + Z_j ω_E/ω_dia,e). [CHECKED: Diss19 p. 46]
+- Level-0 sources' published thresholds are at ω_E = 0 (no equilibrium E_r);
+ D23b treats ω_E as an input parameter — exactly Islands' O4. Torque-balance
+ roots (Δ_sin = 0) exist at discrete ω̂_E (Diss19 benchmark: ω₀ = −0.93
+ ω_dia,e selected among ±0.93, ±1.28); Δ_pol ∝ ω_E² away from zero and
+ **reverses sign at ω_E ≈ −0.89 ω_dia,e** (D23b Fig. 8) — the modern, frame-
+ pinned statement of the polarization sign controversy. These are ladder-B4
+ targets.
+
+Level-0 input vector:
+
+ p = ( ŵ = w/ρ_θi (half-width),
+ ω̂_E = ω_E/ω_dia,e (≡ −ω₀/ω_dia,e; SLAYER Q-map [VERIFY: Park 2022]),
+ ν̂_j = ν_★j per species,
+ ε, ŝ (via L̂_q), q_s, τ = T_e/T_i,
+ η_j = L_n/L_Tj,
+ species list: {Z_j, m_j/m_i, n_j/n_e, T_j/T_i, gradients, F0 type, role} )
+
+Frequency bookkeeping owns its own unit tests; the polarization-sign disputes
+in the literature are largely frame disputes, and the identities above make
+the conversions mechanical. One module (`src/frames/`) owns them.
+
+## 6. Symmetries and conserved checks (unit-test targets)
+
+- Parity: Δ_cos even / Δ_sin odd under the appropriate (ξ, σ, ω_E) reflection
+ [derive at implementation and record as [DERIVED]; consistency targets:
+ Δ_pol(ω_E) parabolic/even to leading order away from the linear-in-ω_E
+ region near zero, D23b Fig. 8].
+- Zero-gradient, zero-Φ̃ Maxwellian: g = 0 exactly; residual = machine zero.
+- No island (ψ̃ → 0), gradients on: recover standard local neoclassics
+ (bootstrap J_∥ vs. Sauter/NEO) — the most powerful global check (docs/05 B1).
+- Electron-closure identities: ⟨∂²h/∂x²⟩_Ω = 0; k → −1.173; f_p → 1 − 1.46√ε;
+ ⟨ν̂_ii⟩_u analytic value (§2.3). [CHECKED: L23 Ch. 4]
+- Collision operator: particle conservation (L0); +momentum/energy per pair
+ (L1); discrete entropy sign ∫ g C[g]/F_M ≤ 0.
+
+## 7. Explicitly out of Level-0 scope (recorded to prevent creep)
+
+Ampère & multi-harmonic (L3); torque-balance closure of ω_E (L4 — but ω_E is
+an input *parameter scan* from day one; publishing single-ω_E Δ values is
+forbidden per the risk register); χ_⊥/w_d (L4); general geometry & 5D (L2);
+slowing-down F₀ (L2); radiation (L4); gyroaveraging beyond drift order (out of
+program scope — documented limitation vs. gyrokinetic island studies; L23
+p. 142 draws the same boundary: ŵ approaching ρ_i needs gyrokinetics).
diff --git a/docs/src/islands/design/02-species-and-eps.md b/docs/src/islands/design/02-species-and-eps.md
new file mode 100644
index 00000000..abc6b72c
--- /dev/null
+++ b/docs/src/islands/design/02-species-and-eps.md
@@ -0,0 +1,146 @@
+# 02 — Species abstraction, tungsten, and energetic particles
+
+## 1. Species as a first-class dimension (Level 0 requirement, D3)
+
+Every kinetic object in Islands is indexed by species. The solve is per-species DKEs
+coupled through (i) quasineutrality (and Ampère at L3), (ii) the collision
+operator's field-particle terms (L1+), and (iii) the output moments, which sum
+over species. Designing for N species at Level 0 costs ~nothing (the L0 test is a
+trace deuterium copy of the bulk); retrofitting costs a rewrite.
+
+### 1.1 Species definition
+
+```julia
+abstract type AbstractBackground end
+struct Maxwellian <: AbstractBackground # n, T, dlnn/dr, dlnT/dr at r_s
+struct SlowingDown <: AbstractBackground # S0, v_birth, v_crit(T_e, composition),
+ # dln(source)/dr ; isotropic at L2 entry
+struct Species{B<:AbstractBackground}
+ name::Symbol
+ Z::Float64 # charge number (allow Float for mean-charge-state W)
+ m::Float64 # mass ratio to reference ion
+ background::B
+ role::SpeciesRole
+ collisional_coupling::Bool # participate in field-particle terms? (see §1.3)
+end
+```
+
+### 1.2 Roles: the trace-species economy
+
+```
+@enum SpeciesRole Bulk Trace
+```
+
+- **Bulk**: full nonlinear participant. In quasineutrality (+ Ampère at L3);
+ its g enters the Newton state vector.
+- **Trace** (n_j Z_j ≪ n_e for charge, n_j ≪ n_e for current — check both): the
+ trace DKE is *linear in g_j* given the converged bulk fields (Φ̃, island, bulk
+ flows for friction). Solved as a post-processing pass — one linear solve, no
+ Newton coupling — with an additive contribution to Δ_cos/Δ_sin and to profiles.
+ This is the computational backbone of both the W and alpha tracks: parameter
+ scans over trace-species properties reuse one bulk solve.
+- Promotion rule: any species violating trace criteria (e.g. W at high
+ concentration where Z n_W is non-negligible, or when friction back-reaction on
+ bulk flows matters — see §2) must be run as Bulk; the code checks the criteria
+ and warns, never silently degrades.
+
+### 1.3 Collisional coupling matrix
+
+Friction is directional: a trace species always *feels* the bulk (drag,
+pitch-angle scattering off bulk); whether the bulk feels the trace
+(field-particle back-reaction) is the `collisional_coupling` flag. W at reactor-
+relevant concentrations: back-reaction ON (Z² n_W friction on bulk ions is not
+small even when charge-trace holds — this is exactly the mixed case the analytic
+MRE cannot do). Dilute alphas: back-reaction OFF is usually safe; verify with the
+flag flip (a one-line toggle — the whole point of the architecture).
+
+---
+
+## 2. Tungsten (physics gate: Level 1; radiative channel: Level 4)
+
+### 2.1 Why W breaks the analytic MRE terms
+
+Collisionality scales ~ Z²(?) with low v_th: W sits in Pfirsch–Schlüter or plateau
+while bulk D and electrons are banana — a *mixed-regime* multi-species problem.
+Analytic bootstrap terms in the MRE assume a per-species regime; the friction
+between a PS impurity and banana bulk ions modifies the bootstrap current (and
+hence Δ_bs) in ways only a full multi-species collision operator captures.
+Additional channels: Z_eff shift of electron collisionality (electron bootstrap
+and, at L3, resistive layer physics); impurity contribution to polarization is
+small (tiny ρ_θW) — a prediction to *verify*, not assume.
+
+### 2.2 Level-1 deliverables
+
+- Δ_bs(w; n_W, Z_W, ν̂) surfaces with W friction — quantified departure from
+ Sauter-based MRE terms.
+- **In-island impurity asymmetry**: n_W(Ω, ξ) structure (parallel compression +
+ friction with flattened bulk flows inside the island). Directly comparable to
+ AUG/DIII-D measurements of W behavior at islands, and the input to the L4
+ radiative channel.
+- Charge state: single mean-Z at L1 (Z̄(T_e) from coronal tables as a parameter);
+ multi-charge-state bundle only if asymmetry results prove sensitive.
+
+### 2.3 Level-4 radiative/thermo-resistive channel
+
+Energy closure with radiation sink Q_rad = n_e n_W L_W(T_e) inside the island,
+temperature-dependent resistivity η(T_e) entering the L3 Ohm/Ampère system →
+radiation-driven island growth (Gates & Delgado-Aparicio-class mechanism,
+density-limit relevance). Gate: reproduce published thermo-resistive island model
+trends (docs/05 D3). Depends on: L1 (W transport into island) + L3 (η in field
+equation) + L4 energy closure. This is deliberately the *last* W milestone.
+
+---
+
+## 3. Energetic particles (physics gate: Level 2)
+
+### 3.1 Why EPs are the reason Level 2 exists
+
+Alphas violate the orderings W leaves intact:
+
+- **ρ_θα ≳ w** (and possibly ≳ L_x): the drift-island shift is not a perturbative
+ O(ρ_θ) displacement — it is the dominant structure. Radially local, constant-
+ gradient assumptions fail; the domain must span several ρ_θα with profile
+ variation toggles.
+- **Orbit-average survives, resonance does not**: ω_bα is still fast (O5 holds for
+ alphas), but island rotation can satisfy **ω ~ ω_D,α** (precession) — a
+ collisionless, resonant polarization-type contribution to Δ with no fluid
+ analog and no regime formula. Flagship EP physics target: ITER/SPARC NTM
+ thresholds with self-consistent alpha kinetics.
+- **Non-Maxwellian F₀**: slowing-down background changes the drive terms
+ (∂F₀/∂r structure, no temperature gradient in the usual sense) and the
+ collision physics (drag on electrons + bulk ions rather than self-collisions;
+ self-collisions negligible). Mechanically modest once L2 orbits are right;
+ pointless before.
+
+### 3.2 Implementation sequence within Level 2
+
+1. Trace Maxwellian "hot ion" with artificially large ρ_θ — isolates finite-orbit
+ nonlocality from F₀ shape.
+2. SlowingDown F₀, isotropic — adds drive/drag changes.
+3. ω-scan through ω_D,α — the precession-resonance study. Requires the L2
+ orbit machinery to deliver accurate ω_D(λ, E) (benchmark vs. standalone orbit
+ integrator, docs/05 C2). Methodological antecedent: Dudkovskaia et al. JPCS
+ 1125, 012009 (2018) — *phase-space* island stability for EP-driven modes;
+ its bounce/angle-variable and separatrix-layer machinery is the same toolkit
+ (docs/08), though its physics (bump-on-tail secondary modes) is not part of
+ this study.
+4. (Optional/later) anisotropic F₀ for NBI/RF fast ions — same machinery,
+ different B; parked unless a collaborator needs it.
+
+### 3.3 Division of labor with the outer region
+
+EP pressure also modifies Δ′ (outer-region kinetic corrections — same physics
+class as GPEC/PENT stability integrals). That stays on the perturbed-equilibrium
+side and arrives through the Δ′(w) input. Islands owns only resonant/orbit-width EP
+physics at the island. State this in every EP paper to preempt double-counting
+questions, and define the split precisely: outer kinetic Δ′ evaluated with the
+island region excised at the matching radius |x| = L_x [interface spec in
+docs/03 §5].
+
+### 3.4 Alpha–W interplay (free deliverable)
+
+Both tracks live in the same species list; an L2 run with {D bulk, e bulk/model,
+W trace, α trace} costs one bulk solve + two linear passes. Alpha drag heating
+asymmetries vs. W radiative cooling inside islands is an unexplored combination —
+cheap to look at once both tracks exist, potentially interesting for burning-
+plasma island stability. Not a milestone; an opportunity.
diff --git a/docs/src/islands/design/03-architecture.md b/docs/src/islands/design/03-architecture.md
new file mode 100644
index 00000000..0efe26b4
--- /dev/null
+++ b/docs/src/islands/design/03-architecture.md
@@ -0,0 +1,158 @@
+# 03 — Architecture (Julia)
+
+Design principle: **the physics levels are configurations, not code paths.** One
+discretization, one Newton solver, one state vector; orderings are swappable
+operators and flags. If implementing a new level requires touching the solver
+loop, the architecture has failed.
+
+## 1. Package layout
+
+Islands is a GPEC submodule (`module Islands`, no separate `Project.toml` — it
+shares the GeneralizedPerturbedEquilibrium package environment). Its files are
+distributed across the repo's existing trees, not a self-contained package dir:
+
+```
+src/Islands/ # the module (module Islands)
+├── CLAUDE.md # module conventions (nested; Claude Code auto-loads)
+├── Islands.jl # module entry
+├── geometry/ # AbstractEquilibrium: AnalyticCircular (L0),
+│ # MillerAnalytic (L2 entry; the D23a geometry),
+│ # NumericalEquilibrium (L2; DCON/gEQDSK ingest)
+├── phasespace/ # grids (x, ξ[, θ]; λ, E, σ), maps, quadrature,
+│ # layer-clustered mappings (docs/04)
+├── species/ # Species, backgrounds, roles (docs/02)
+├── frames/ # THE frequency/frame conversion module (docs/01 §5)
+├── operators/ # the stack (see §2)
+├── fields/ # Φ̃ quasineutrality residual; A_∥ Ampère residual (L3)
+├── closures/ # torque balance, χ⊥ transport, radiation (L4)
+├── moments/ # Δ_cos, Δ_sin, profiles, channel decompositions
+├── solvers/ # Newton–Krylov, continuation, trace-species linear pass
+├── io/ # config (TOML), results (HDF5/JLD2), provenance
+└── verify/ # benchmark harness callable from tests AND scripts
+
+docs/src/islands/ # docs (rendered by the GPEC Documenter site)
+├── index.md # overview / landing page
+├── design/ # these design documents (normative, aspirational)
+├── (Physics Book chapters) # as-implemented equations (docs/07)
+├── derivations/ papers/ state/ notes/ LOG.md QUESTIONS.md
+
+test/runtests_islands_*.jl # unit + symmetry + conservation tests (fast),
+ # included from the repo's test/runtests.jl
+benchmarks/islands/ # the docs/05 ladder (slow; CI-gated subsets)
+└── figures/ # surface generation + paper/gallery figure scripts
+regression-harness/ # islands cases integrated with the rest (islands_*)
+```
+
+## 2. The operator stack
+
+The state is `U = (g_1, …, g_Nbulk, Φ̃ [, A_∥ at L3] [, ω, E_r at L4])`. The
+residual is assembled as a sum of operator applications:
+
+```julia
+abstract type AbstractTerm end
+# each implements: apply!(R, term, U, cache), and is either matrix-free or
+# provides a local stencil for preconditioning
+
+struct ParallelStreaming <: AbstractTerm end # includes island B̃_r ∂x
+struct MagneticDrift <: AbstractTerm # variant = :original (finite L̂_B⁻¹, I19)
+ # | :improved (L̂_B⁻¹ → 0 proxy of
+ # the cosθ ∂B/∂ψ structure, D21
+ # Eq. A2) — the 8.73→1.46 ρ_bi
+ # toggle, docs/01 §2.1
+struct ExBDrift <: AbstractTerm end
+struct Collisions{M} <: AbstractTerm # M = PitchAngle (L0; energy-dependence
+ # sub-toggle :chandrasekhar (I19) |
+ # :vcubed (D21), docs/01 §2.3) |
+ # FokkerPlanckMulti (L1)
+struct GradientDrive <: AbstractTerm end # (v_E+v_D+v_ψ̃)·∇F₀ ; dispatches
+ # on background type (Maxwellian /
+ # SlowingDown)
+struct PerpTransport <: AbstractTerm end # χ⊥, D⊥ (L4)
+struct RadiationSink <: AbstractTerm end # (L4, energy closure)
+```
+
+Configuration = list of terms per species + field-equation set + closure set,
+read from a TOML config. **Named configurations are pinned in `src/Islands/verify/`**:
+`:imada2019` (B5a; note it targets the L23-amended physics, not I19 Eq. A.1 as
+printed — docs/01 header), `:dudkovskaia2021` (B5b), `:leigh2023` (B5c),
+`:sauter_limit`, `:slayer_limit`, … — the toggle-comparison studies the
+project exists to do are then config diffs, and every published figure names
+its configuration.
+
+Rules:
+- No term may inspect which other terms are active (no hidden coupling).
+- Every term carries its own verification hook (an analytic limit or manufactured
+ solution registered in `src/Islands/verify/`).
+- Orderings that *remove* structure (e.g. orbit averaging O5) are implemented as
+ alternative phase-space configurations, not operators: `phase = :orbit_averaged
+ (4D)` vs `:full (5D)`. Terms are written against an abstract phase-space
+ interface so both share implementations where possible.
+
+## 3. Solver strategy
+
+- **Steady-state Newton–Krylov** (D2): matrix-free JVPs via AD; GMRES; physics-
+ block preconditioner (docs/04 §4).
+- **AD policy**: JVPs and small-parameter sensitivities via ForwardDiff duals
+ through the operator stack (write terms generically over `eltype`); evaluate
+ Enzyme for reverse-mode ∂Δ/∂p over the full parameter vector when surface
+ generation begins. AD-compatibility is a CI test per term (no
+ `Float64`-hardcoded buffers — reuse the per-thread preallocation patterns from
+ GPEC/QuadGK work, but typed generically).
+- **Continuation**: pseudo-arclength in any component of p (and in ψ̃₀/w).
+ Purposes: (i) Newton globalization by homotopy from analytic-limit solutions,
+ (ii) Δ-surface generation as a byproduct, (iii) bifurcation tracking — the
+ penetration bifurcation at Level 3 *is* a fold in the continuation curve, so the
+ solver must detect/step around folds from the start.
+- **Trace pass**: after bulk convergence, each Trace species is one linear solve
+ with frozen bulk fields (docs/02 §1.2); reuses the same operator stack and
+ Krylov machinery with a linear RHS.
+- **ω closure (L4)**: ω, E_r appended to U; torque-balance residual appended to R.
+ Structurally identical Newton system — this is why the closure is cheap *if*
+ the architecture holds.
+
+## 4. Data and provenance
+
+- Config: TOML in, full resolved config (all defaults expanded, git SHA, term
+ list, grid spec) stored inside every output file. A result that can't
+ regenerate itself is a bug.
+- Output: HDF5/JLD2 with (p, Δ_cos, Δ_sin, channel decompositions, convergence
+ metadata, profiles on demand). Surface datasets are append-only stores keyed by
+ p; the emulator (M12) trains from these.
+
+## 5. External interfaces
+
+> **Superseded where in-repo (docs/06 §1):** with Islands living inside the GPEC
+> repository, the Δ′ and SLAYER interfaces below become direct Julia calls
+> against GPEC's implementations, exercised in CI. The file-based forms remain
+> the spec for any standalone/external consumers.
+
+- **Δ′(w) input**: file-based interface (resistive DCON/STRIDE output → a small
+ table Δ′ vs. w at the rational surface, with the matching radius L_x recorded).
+ Keep it dumb and versioned; no live coupling until the physics settles.
+- **Equilibrium input (L2)**: gEQDSK + profiles, mapped through the same
+ representations the GPEC stack uses; `AnalyticCircular` remains permanently
+ available for regression.
+- **SLAYER (L3 verification)**: comparison harness that maps Islands' linear-limit
+ (Δ_cos + iΔ_sin) onto SLAYER's Δ(Q) convention — the frames module owns the Q
+ mapping [VERIFY: Park 2022 conventions — paper not yet in the reference
+ library]. **Status:** the in-repo SLAYER arrives with the Tearing module PR
+ (#238, `src/Tearing/InnerLayer/SLAYER/`), sequenced before Islands starts
+ (docs/06 §1); code against the Tearing-module layout, not `develop`'s old
+ `src/InnerLayer` placeholder.
+- **NEO/NCLASS (L1 verification)**: no-island neoclassical cross-check driver
+ (export local parameters → run → compare bootstrap/flows).
+
+## 6. Performance posture (sizing, not optimization)
+
+L0 4D: N_x × N_ξ × N_λ × N_E × σ ~ 400×64×128×32×2 ≈ 2×10⁸ dof upper bound
+per bulk species before layer-adapted grids reduce N_x, N_λ needs — matrix-free
+mandatory. The prior-art cost baseline makes the case concrete: kokuchou's
+dense-block shooting method needed ≈16.6 GB *per energy grid point* and
+O(100 GB) total at n_ξ = 30, n_p = 145, and memory (not physics) set its
+accessible parameter window (docs/04 §6). Single-node multithread first (Julia
+threads; the GC-contention lessons from parallel QuadGK apply: preallocate
+per-thread caches). MPI only if
+L2 5D demands it; do not architect for MPI speculatively, but keep halo-friendly
+array layouts (x outermost) so the option stays open. Every solve logs a cost
+model entry (dof, iterations, wall time) — the emulator strategy depends on
+knowing what a point costs.
diff --git a/docs/src/islands/design/04-numerics.md b/docs/src/islands/design/04-numerics.md
new file mode 100644
index 00000000..e04f20b9
--- /dev/null
+++ b/docs/src/islands/design/04-numerics.md
@@ -0,0 +1,190 @@
+# 04 — Numerics
+
+The numerics posture is now informed by three generations of prior-art
+implementation experience: DK-NTM (I19 Appendix A — shooting method, nested
+Picard loops), RDK-NTM (Diss19 Appendix E — S-space reduction, analytic
+layers), and kokuchou (L23 Chs. 3–6 — the most complete forensic record of
+where the direct 4D approach actually breaks). Citations per docs/01 header.
+
+## 1. Discretization by coordinate
+
+- **ξ (helical angle)**: Fourier pseudo-spectral, periodic. Nonlinear terms
+ (v_E·∇g, Φ̃ coupling) via dealiased transforms. At L0 the field has one
+ harmonic but g does not — keep the full spectrum from the start. Harmonic
+ content of the residual doubles as a resolution diagnostic. (kokuchou used
+ 2nd-order FD with n_ξ = 30 and flagged accuracy limits; spectral ξ is a
+ deliberate upgrade.)
+- **x (radial)**: mapped collocation or high-order FD on a stretched grid.
+ **Packing must target the drift-island separatrices, not the magnetic
+ island**: the layer follows contours shifted by ±ρ̂_θi ω̂_D L̂_q per (y, v̂, σ)
+ (docs/01 §2.2), so the packed envelope is max over the σ-shifts of the Ω=1
+ curve — L23 §3.1.6 identifies the rectilinear-mesh/shifted-round-island
+ mismatch as kokuchou's dominant accuracy limiter, and proposes a mapped
+ radial coordinate absorbing the θ-dependent orbit shift (its Eq. 7.1.1,
+ p̃ = ψ − I(v_∥(θ)/ω_ci(θ) − v_∥(0)/ω_ci(0))) — adopt as a candidate *grid
+ map* x(s; y, v̂, σ-envelope), never as a solve-coordinate change (D1 stands).
+ Far-field BCs at |x| = L_x: g → neoclassical (no-island) solution, Φ̃ →
+ background E_r potential. **Never bare Neumann ∂g/∂x = 0**: L23 traced its
+ spurious "winged" solution branch to Neumann non-uniqueness (docs/01 §3).
+ L_x is a convergence parameter reported with every result (and is the Δ′
+ excision radius, docs/03 §5).
+- **λ (pitch)**: collocation with clustering at the trapped-passing boundary
+ y_c = 1. Layer width **in pitch angle: ε_λ ≈ [(2ν̂_j/v̂) a(λ_c, v̂; σ)]^{1/2}
+ ~ √ν_★** with a(λ) = ⟨σλ(1−λB)^{1/2}R/B_φ⟩_θ — now a confirmed scaling
+ [CHECKED: Diss19 p. 58; D23b §3.1], electron layer wider by (m_i/m_e)^{1/4}.
+ Packing parameter set adaptively from input ν̂. Internal boundary
+ conditions at y_c (the DK-NTM/kokuchou matching set):
+ Σ_σ σg^p = 0, Σ_σ g^p = 2g^t, Σ_σ ∂_y g^p = 2 ∂_y g^t
+ [CHECKED: I19 Eqs. A.7–A.9; L23 Eqs. 2.3.52–2.3.54]. σ = ± sheets joined at
+ bounce points for trapped particles.
+- **E (energy)**: Gauss-type quadrature nodes on a mapped semi-infinite domain
+ weighted by F₀ — Maxwellian weights at L0; slowing-down weights (L2) change
+ the map, not the machinery. Mind the integrable divergences the sources hit:
+ ν̃(v̂) ∝ v̂⁻² at low v̂ (use the analytic ⟨ν̂⟩_v, docs/01 §2.3), (1−yb)^{−1/2}
+ at bounce points, and the y → 1/b flow integrals — analytic correction
+ terms/asymptotics, not naive quadrature [CHECKED: L23 pp. 69, 87–88, App. 8.4].
+ Collision-operator energy diffusion (L1) prefers modest-order collocation
+ over pure spectral here; decide with a convergence study on the Sauter
+ benchmark.
+- **θ (poloidal, L2)**: Fourier. The 4D orbit-averaged mode must be recoverable
+ as the θ-average of the 5D solve — a built-in cross-check of both.
+
+## 2. The two internal layers (the numerical cliff — now quantified)
+
+Both layers are collisional with **width ∝ ν^{1/2}** [CHECKED: D23b §3.1,
+footnote 11]:
+
+- **Trapped-passing dissipation layer** at y_c: ε_λ ~ [(2ν̂/v̂)a(λ_c)]^{1/2}.
+- **Drift-island separatrix layer** at S = S_c: ε_S ~ [(2ν̂/v̂)C_SS(S_c)]^{1/2}
+ in the S variable; in kokuchou's (p, ξ) variables the equivalent widths are
+ Δ_p,pass ~ √(ν̂L̂_q/ŵ)·ρ̂_θi and Δ_p,trap ~ √(ν̂L̂_q/ŵ)·√(ŵρ̂_θi)
+ [CHECKED: L23 §6.1.2].
+
+Two prior-art failure modes to design against:
+
+1. **The layer moves during the solve.** When E×B dominates the layer balance
+ (ν̂/(ŵL̂_q) < 1 — satisfied over part of the velocity grid in *every* L23
+ production run), the width becomes Δ_p,E×B ~ ν̂ρ̂_θi/ŵ and **depends on Φ̂,
+ so it shifts between nonlinear iterations** — a mesh packed for the initial
+ iterate can be unresolved at the converged one [CHECKED: L23 Eqs. 6.1.1–6.1.2].
+ Mitigation: pack from the layer-width *lower bound* over the expected Φ̂
+ range; validate the estimate against measured solution structure post-solve
+ (estimates logged); re-mesh-and-continue as a fallback.
+2. **The accessible parameter window shrinks with correct physics.** L23's
+ corrected ∂²ĝ/∂p̂² coefficient (∝ ρ̂²_θi, per the §2.6 amendments) makes
+ separatrix gradients *steeper* than in the original DK-NTM runs — kokuchou
+ could not reach DK-NTM's ν_★ = 10⁻³ operating point (floor at 5×10⁻³) nor
+ ŵ > 0.75ρ̂_θi at that floor, with memory as the binding constraint
+ [CHECKED: L23 §5.3, §6.1.2, p. 116]. Islands' matrix-free posture removes
+ kokuchou's specific memory wall (§6 below) but not the resolution demand.
+
+Posture (unchanged in spirit, now with numbers): adaptive packing driven by
+the ε_λ, ε_S, Δ_p,E×B estimates from input parameters; a documented ν̂ floor
+per resolution tier; below the floor, results flagged `layer_unresolved` and
+the RDK cross-check mode (analytic layer treatment per Diss19 §3/D23b §3.1.1 —
+Fourier-matched layer solutions) is the reference. Disagreement between DK and
+RDK modes above the floor is a release-blocking bug; below it, it's the
+measured cost of the RDK ordering — a publishable toggle-impact result.
+Grid-convergence studies are first-class benchmark artifacts.
+
+## 3. The trapped-passing boundary is *singular* — regularize deliberately
+
+The hardest-won lesson in the reference set [CHECKED: L23 §4.2, pp. 94–97]:
+the linear system coupling the two sides of y_c through the matching
+conditions is **intrinsically singular/ill-conditioned** (kokuchou measured
+rcond ≈ 10⁻¹⁶–10⁻¹⁹, det = 0 or ±Inf, at every energy grid point; plain LU
+gave machine-dependent noise in ĝ(v̂); the same latent defect was reproduced in
+DK-NTM once other bugs were fixed). kokuchou's fix: truncated SVD (cutoff
+10⁻⁷; exactly one singular value truncated) applied only at the boundary
+solve.
+
+Implications for Islands' Newton–Krylov (no y-sweep, one global residual):
+
+- The same near-null-space will reappear as **Jacobian ill-conditioning
+ localized at the y_c block**. The physics-block preconditioner must treat
+ the y_c matching rows explicitly (small dense block per (x-locality, v̂):
+ factor with SVD/complete pivoting, truncate/regularize below a documented
+ cutoff), so GMRES never has to resolve the near-singular directions itself.
+- Add a CI-level diagnostic: monitor the smallest singular value of the y_c
+ matching block and the GMRES convergence stagnation signature; a silent
+ regression here produced noise, not crashes, in the prior art — it must be
+ *tested for*, not observed.
+- Root cause is the asymptotic v_∥⁻¹ structure at the boundary; any basis
+ change that removes the 1/v_∥ divergence from the matched unknowns (e.g.
+ solving for flux-like variables across y_c) is worth a design spike in M1.
+
+## 4. Manufactured solutions
+
+Before any physics benchmark: MMS per operator and for the assembled L0 system
+(source terms chosen so a prescribed smooth g*, Φ̃* solve the equations).
+Verifies discretization order and the AD-generated JVPs simultaneously
+(JVP checked against finite differences of the residual). MMS configs live in
+`src/Islands/verify/` and run in CI at low resolution. Supplement with the sources' cheap
+analytic unit targets (docs/01 §6: h(Ω) identities, k = −1.173, ⟨ν̂⟩_v,
+f_p = 1 − 1.46√ε) — L23 Ch. 4 demonstrates these catch inherited bugs that
+integration tests miss.
+
+## 5. Newton–Krylov details
+
+- Inexact Newton (Eisenstat–Walker forcing), line search + continuation
+ globalization (docs/03 §3). Expect and handle fold points (penetration
+ bifurcation at L3): pseudo-arclength with tangent monitoring from day one.
+- The prior art's nested Picard loops (Φ outer / ū_∥i inner) are the explicit
+ anti-pattern: kokuchou's production runs *never met* their Picard convergence
+ criterion (Φ̂ array-max residual >100%/iteration at large ŵ) even as Δ_loc
+ stabilized, and the array-averaged residual hid locally-divergent regions
+ [CHECKED: L23 §3.1.5, §6.1.1]. Islands solves (g_j, Φ̃) as one Newton system;
+ convergence is measured by the global residual norm *and* its spatial max,
+ both archived.
+- **Preconditioning** (the make-or-break): physics-block preconditioner —
+ approximate inverse built from the ξ-averaged, drift-free operator
+ (streaming + collisions per species: block-tridiagonal-ish in x per (λ, E)),
+ a Schur-type block for Φ̃ (and A_∥ at L3), plus the explicit y_c matching
+ block of §3. Cheap to factor, captures the stiff parallel/collisional
+ physics; Krylov handles drifts and nonlinear coupling. Iteration counts vs.
+ p logged; preconditioner quality is a tracked metric, not folklore.
+- Krylov: GMRES (restarted) via Krylov.jl or LinearSolve.jl; matrix-free JVP
+ through the operator stack (ForwardDiff duals). No global sparse Jacobian is
+ ever formed except in tiny-grid debug mode (also useful for eigenvalue
+ diagnostics near folds and for the y_c singular-value monitor).
+
+## 6. Cost model and why matrix-free is mandatory (prior-art data point)
+
+kokuchou's y-sweep shooting method stores dense (n_ξn_p)² recursion blocks:
+at n_ξ = 30, n_p = 145 that is ≈16.6 GB *per energy grid point* and
+O(0.67 N³) ≈ 5.5×10¹⁰ flops per y-point (0.4 hr/energy-point on ARCHER2,
+O(100 GB) RAM total) — and memory, not physics, set its ν_★ floor and ŵ
+ceiling [CHECKED: L23 pp. 80–84]. Islands' matrix-free Newton–Krylov stores
+O(#dof) vectors instead; the trade is preconditioner engineering (§5). Every
+solve logs a cost-model entry (dof, iterations, wall time) — the emulator
+strategy depends on knowing what a point costs, and the L23 numbers are the
+baseline to beat.
+
+## 7. Trace-species linear pass
+
+One GMRES solve per trace species with frozen bulk fields; same preconditioner
+with the trace species' collision blocks. Sensitivities of Δ w.r.t. trace
+parameters (n_W, Z̄_W, alpha source) are nearly free here (linear problem +
+AD) — the W/EP parameter scans (docs/02) should exploit this before any bulk
+rescans.
+
+## 8. Surface generation & emulator posture (M12)
+
+- Continuation walks generate curves; a scheduler tiles (ŵ, ω̂_E) planes per
+ remaining-parameter grid point, warm-starting from nearest neighbors. (L23's
+ practical trick — warm-starting Φ̂ from a stable neighboring run to avoid the
+ spurious branch — is the same mechanism; continuation makes it systematic.)
+- Store everything (docs/03 §4); train emulator (GP or small NN — decide later)
+ on (p → Δ_cos, Δ_sin) with AD sensitivities as extra supervision.
+- Publish surfaces with the named configuration and grid tier; emulator
+ uncertainty must exceed measured grid-convergence error or the tier is bumped.
+
+## 9. Language/tooling specifics
+
+Julia ≥ LTS current at project start. Key packages: Krylov.jl / LinearSolve.jl,
+ForwardDiff.jl (+ Enzyme.jl evaluation), FFTW.jl, Interpolations.jl (equilibrium
+ingest; mind boundary conditions), HDF5.jl/JLD2.jl, TOML stdlib. Threading with
+per-thread preallocated caches (no allocation in `apply!` hot paths — enforce
+with an allocation regression test). Revise.jl workflow assumed; keep
+world-age-safe (no runtime `eval` in the stack). All hot kernels `@inbounds`-safe
+with explicit bounds-check test coverage first.
diff --git a/docs/src/islands/design/05-verification.md b/docs/src/islands/design/05-verification.md
new file mode 100644
index 00000000..789e0e9c
--- /dev/null
+++ b/docs/src/islands/design/05-verification.md
@@ -0,0 +1,110 @@
+# 05 — Verification ladder
+
+The project's credibility is this document. Every claim of the form "Islands
+generalizes X" is backed by "Islands reproduces X in its limit." Benchmarks are
+code in `benchmarks/islands/`, each with: named configuration, target (formula/number/
+dataset), tolerance, grid-convergence requirement, and status. CI runs a fast
+subset; full ladder runs before any tagged release or paper submission.
+
+Source abbreviations and the [CHECKED]/[VERIFY] tag semantics per docs/01
+header; the reference-library map is docs/08. Targets below marked [CHECKED]
+have been transcribed from the in-repo PDFs with equation/page cites and await
+one human sign-off; remaining [VERIFY] items state exactly what is missing.
+
+**Standing triage rule for York-lineage targets**: L23 §2.6 documents errors
+in the published I19 equation set (docs/01 header warning). Where Islands
+disagrees with a published DK-NTM number but agrees with the L23-amended
+physics, the triage outcome "their published equation set" is available — but
+only after the [VERIFY] resolution is logged with the specific amended term.
+
+## A. Structural (pre-physics)
+
+| ID | Target |
+|---|---|
+| A1 | MMS: per-operator and assembled-system convergence at design order |
+| A2 | JVP vs. finite-difference residual directional derivatives |
+| A3 | Symmetry/parity relations of Δ_cos, Δ_sin (docs/01 §6) |
+| A4 | Conservation: particles (L0); +momentum/energy per collision pair (L1); discrete entropy sign |
+| A5 | Zero-drive null test: g ≡ 0, residual = machine zero |
+| A6 | 4D orbit-averaged mode = θ-average of 5D mode (once L2 exists) |
+| A7 | Closure identities: ⟨∂²h/∂x²⟩_Ω = 0; k → −1.173; f_p → 1−1.46√ε; analytic ⟨ν̂_ii⟩_v [CHECKED: L23 Ch. 4 — kokuchou's unit set, which caught inherited DK-NTM bugs] |
+| A8 | Trapped-passing block conditioning monitor: smallest singular value of the y_c matching block tracked; regression = silent-noise failure mode of L23 §4.2 |
+
+## B. Level 0–1 physics
+
+| ID | Target | Source / configuration |
+|---|---|---|
+| B1 | **No-island limit**: bootstrap current & neoclassical flows vs. NEO/NCLASS across ν_★; single- and multi-species | NEO; Sauter PoP 6, 2834 (1999) |
+| B2 | Large-w limit: Δ_bs+Δ_cur ∝ 1/w with coefficient, against WCHH96 Eq. (85) mapped to the island frame (Diss19 p. 86 frame caveat); scaling ε^{1/2}(L_q/L_p)(β_θ/w). Δ_pol ∝ 1/w³ tail | [CHECKED: Diss19 pp. 84–86; D21 Fig. 8-class curves] |
+| B3 | Curvature: GGJ/D_R contribution in the appropriate fluid-ish limit; note Δ_cur = O(ε²), negligible in the York configs — pick a configuration where it isn't [VERIFY accessible configuration — may require L4 transport toggle] | Glasser–Greene–Johnson 1975; in-repo GGJ inner-layer model (src/InnerLayer/GGJ) as cross-check |
+| B4 | Polarization: (i) Wilson–Connor collisionless and Smolyakov collisional scalings; (ii) the frame-pinned sign structure: Δ_pol ∝ ω_E² away from zero, **sign reversal at ω_E ≈ −0.89 ω_dia,e**, reversal point insensitive to w/ρ_θi; (iii) torque-balance roots at discrete ω̂_E (Diss19 benchmark root ω₀ = −0.93 ω_dia,e) | WCHH96; Waelbroeck & Fitzpatrick PRL 78 (1997); [CHECKED: D23b Fig. 8; Diss19 Fig. 4.18] |
+| B5a | **DK-NTM threshold**: w_c ≃ 2.76 ρ_θi (half-width) ≡ 8.73 ρ_bi at ε = 0.1 (ρ_bi = ε^{1/2}ρ_θi; the "8.73" is a unit conversion, not printed in I19). Config: circular, ε = 0.1, L̂_q = 1, m/n = 2/1, T_e = T_i, ω_E = 0, **:original** drift model. [VERIFY: I19 run collisionality — I19 §4.2 states ν_★ = 0.01; L23 p. 82 quotes DK-NTM at ν_★ = 10⁻³ — resolve before pinning tolerance] | [CHECKED: I19 Fig. 9; PRL p. 4] |
+| B5b | **RDK-NTM improved-model threshold**: w_c ≈ 0.45 ρ_θi (0.46 fit) ≡ 1.41–1.47 ρ_bi half-width ≡ 2.85 ρ_bi full width. Config: as B5a but **:improved** drift model (L̂_B⁻¹ = 0 proxy), ν_i★ = 10⁻³–10⁻⁴, Φ′_eqm = 0, η_j = 1. The 8.73 → 1.46 pair is authoritative in the D23a abstract | [CHECKED: D21 abstract + Fig. 8; D23a abstract] |
+| B5c | **kokuchou finite-ν_★ threshold surface**: w_c[r_s] ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵ (2D OLS, R² = 0.9916), and per-ν_★ fits w_c/ρ̂_θi = {0.397, 0.427, 0.451, 0.487} at ν_★ = {5, 10, 15, 20}×10⁻³. Validity: ρ̂_θi ∈ [1,5]×10⁻³, ε = 0.1, ω_E = 0, m/n = 2/1, **L23-amended equation set**. The ν_★-dependence of the threshold is the new physics here | [CHECKED: L23 Eqs. 6.3.1–6.3.2, Figs. 6.10–6.12] |
+| B6 | Δ vs. w curves and separatrix-layer structure vs. published RDK-NTM figures: D23b Fig. 3 (layer-resolved g at both σ), Fig. 4 (g(p_φ) separatrix zoom), Fig. 6 (u_∥i contours with/without layer), Fig. 7 (δΦ contours), Fig. 9 (Δ_neo, Δ*_bs, Δ_pol vs. w against 1/w and 1/w³); layer effect on threshold: w_c 0.78 → 0.52 ρ_θi at ω_E = 0, ν_i = 10⁻³ | [CHECKED: D23b §3–4] |
+| B7 | DK mode vs. RDK cross-check mode agreement above the documented ν̂ floor; prior-art agreement window ν_★ ~ 10⁻³–10⁻⁴ (D21 Appendix C benchmarked DK-NTM against RDK-NTM there); note kokuchou could not operate below ν_★ = 5×10⁻³ — beating that floor is an Islands numerics deliverable | internal; [CHECKED: D21 App. C; L23 §5.3] |
+| B8 | W minority: multi-species neoclassical fluxes & bootstrap modification vs. NEO multi-species; PS-impurity/banana-bulk mixed regime | NEO |
+| B9 | Threshold-vs-experiment context check (not pass/fail): La Haye NSTX+DIII-D fit w_c = 0.26 ρ̂_θi ≈ 0.955 ρ_bi half-width (quoted as full width 1.91 ρ_bi in the source), vs. B5b/B5c — the residual gap (rotation, shaping, finite ε) is the Level 2–4 motivation | [CHECKED: L23 pp. 131–132, Fig. 6.12; La Haye 2012] |
+
+## C. Level 2 physics
+
+| ID | Target |
+|---|---|
+| C1 | General-geometry no-island neoclassics vs. NEO on a shaped numerical equilibrium |
+| C2 | Orbit frequencies ω_b, ω_t, ω_D(λ, E) vs. standalone guiding-center integrator and large-ε analytic formulas; includes the :original/:improved ω̂_D forms (docs/01 §2.1) as analytic checks |
+| C3 | Circular/low-β toggle set reproduces Level 0–1 results on the same equilibria |
+| C4 | Shaping/finite-β trends vs. D23a, now with exact targets: (i) triangularity — threshold FULL width 2w_c from 1.82 ρ_bi at δ = +0.42 to 2.90 ρ_bi at δ = −0.5 (ν_★ = 10⁻⁴, m/n = 2/1, Miller geometry), destabilizing trend confirmed against the DIII-D 2/1 onset database; (ii) aspect ratio — w_c ∝ ε^{1/2}ρ_θi up to ε ≈ 0.3, crossing to w_c ∝ ρ_θi beyond; (iii) β_θ — w_c grows with β_θ, benchmarked vs. EAST discharge 91972 RMP-triggered 2/1 onset | [CHECKED: D23a §6.3, Figs. 5–9] |
+| C5 | Slowing-down F₀: analytic slowing-down flux/current limits in no-island geometry [identify best analytic target — candidate: classical alpha-driven current / electron shielding results] |
+| C6 | Precession resonance: trace-EP Δ contribution vs. a reduced analytic resonant-response model in a controlled limit [derive companion analytic limit as part of the study — publishable on its own; the bounce/angle-variable + separatrix-layer machinery of Dudkovskaia JPCS 1125 012009 (2018) is the methodological antecedent] |
+
+## D. Level 3–4 physics
+
+| ID | Target |
+|---|---|
+| D1 | **Linear limit vs. SLAYER**: (Δ_cos + iΔ_sin)(Q) across drift-MHD regimes; frame/Q mapping documented. **Prerequisite**: the in-repo SLAYER Δ(Q) lands with the Tearing module PR (#238, `src/Tearing/InnerLayer/SLAYER/`), sequenced before Islands M0 (docs/00, docs/06 §1); this benchmark then calls it directly in CI. Published Park 2022 curves remain the independent cross-check. [VERIFY: Park PoP 29 (2022) conventions — paper not in the reference library; acquire] |
+| D2 | Constant-ψ recovery: prescribed-island results re-derived as the small-Δ′, w ≫ δ_layer limit of the self-consistent solve |
+| D3 | Fluid-limit toggles vs. an established nonlinear cylindrical two-fluid code (TM1-class case) for island growth and penetration |
+| D4 | Penetration bifurcation: fold structure and hysteresis qualitatively vs. Fitzpatrick 1998; thresholds with L4 torque balance vs. SLAYER-derived thresholds in the linear limit |
+| D5 | w_d: threshold island width scaling vs. Fitzpatrick PoP 2, 825 (1995) with the χ⊥ operator on [VERIFY target formula] |
+| D6 | Radiative island: growth/threshold trends vs. published thermo-resistive island model (Gates–Delgado-Aparicio class) [select specific reference case] |
+| D7 | Torque moment: linear-limit Δ_sin ↔ SLAYER torque; NTV-side consistency check against the in-repo KineticForces (PENTRC) module in the appropriate limit [scope carefully — may be a paper, not a benchmark] |
+
+## E. The toggle-impact studies (deliverable science, run on the ladder)
+
+Not pass/fail — measured differences, each a figure or paper section:
+
+- E1: drift-frequency model :original vs. :improved — reproduce the
+ 8.73 → 1.46 ρ_bi finding (B5a/B5b configs), then extend across parameter
+ space. The toggle is one term (L̂_B⁻¹ handling, docs/01 §2.1) — the cheapest
+ high-impact study in the program.
+- E2: orbit-averaged 4D vs. full 5D (the O5 ordering's cost).
+- E3: pitch-angle vs. full FP collisions across ν̂ (the O6 cost); plus the
+ energy-dependence sub-toggle ν(v) Chandrasekhar-form vs. V⁻³ (I19 vs. D21
+ operator variants, docs/01 §2.3) — quantifies a known inconsistency *within*
+ the York lineage.
+- E4: flattened vs. kinetic electrons (O7) — the DK-NTM/kokuchou closure vs.
+ the RDK-NTM kinetic treatment; mandatory before trusting anything at small w
+ at L3. Includes reproducing (or refuting) L23's unexplained stabilizing
+ electron Δ_pol at ω_E = 0 (L23 §6.2.2/§7) — a live physics question the
+ ω_E scan can settle.
+- E5: local vs. profile-variation domains for EPs (O2 cost at large ρ_θα).
+- E6: fixed-ω_E vs. torque-balance ω_E (O4) — the polarization-term
+ sensitivity study; publish Δ surfaces both ways. D23b Fig. 8 (sign reversal
+ at ω_E ≈ −0.89 ω_dia,e) is the anchor curve.
+
+## Reporting rules
+
+1. No benchmark "passes" on a single grid: convergence demonstrated, tolerance
+ stated, both archived with the result.
+2. Every figure in every paper names its configuration (docs/03 §2) and git SHA.
+3. Disagreements with published targets are triaged as {our bug, their
+ approximation, **their published-equation error** (see standing triage
+ rule), transcription error} — with [VERIFY] resolution logged in this
+ file's history before the triage concludes anything other than "our bug."
+4. Every ladder benchmark ships with a figure script per the docs/07 §2
+ pipeline; the same script feeds CI artifacts, the state gallery, and paper
+ panels. Ladder status renders automatically into docs/src/islands/state/STATE.md — this
+ file defines targets; the dashboard reports reality.
+5. Threshold numbers are always reported as **half-widths with the unit
+ stated** (ρ_θi and ρ_bi = ε^{1/2}ρ_θi both given at the run's ε), because
+ the literature mixes half/full widths and both gyroradius units (docs/01 §1).
diff --git a/docs/src/islands/design/06-autonomy-and-tooling.md b/docs/src/islands/design/06-autonomy-and-tooling.md
new file mode 100644
index 00000000..bfdc7b33
--- /dev/null
+++ b/docs/src/islands/design/06-autonomy-and-tooling.md
@@ -0,0 +1,286 @@
+# 06 — Autonomy, tooling, and GPEC-repo integration
+
+Audience: (a) the human setting up the environment once, (b) every Claude Code /
+Fable session working this project. Sections marked **[HUMAN SETUP]** are
+one-time actions; everything else is standing guidance for agent sessions.
+
+---
+
+## 1. Living inside the GPEC repo (supersedes docs/03 §5 where in-repo)
+
+Islands develops inside the OpenFUSIONToolkit GPEC repository as a **submodule of
+the GeneralizedPerturbedEquilibrium package** (`src/Islands/`, `module Islands` —
+no separate `Project.toml`; it shares the repo's environment and CI). Layout:
+code in `src/Islands/`, docs in `docs/src/islands/` (see `src/Islands/CLAUDE.md`
+for the full map):
+
+- **The interfaces become function calls.** docs/03 §5 specified file-based
+ interfaces to Δ′ and SLAYER because a standalone repo needed them. In-repo,
+ everything Islands consumes arrives with the Tearing module work (PR #238,
+ `feature/tearing-growthrates`): SLAYER Δ(Q) (Fitzpatrick Riccati layer model,
+ `src/Tearing/InnerLayer/SLAYER/`), GGJ under the same `InnerLayer` interface,
+ the dispersion/root-finding layer (`src/Tearing/Dispersion/`), and the
+ outer-region Δ′ exposed as the full 2m×2m matrix (`delta_prime_raw` /
+ `pest3_decompose` from ForceFreeStates, fed by the new `Riccati.jl` ideal
+ solver) — all as direct Julia calls, no digitization, no format drift.
+ Equilibrium representations are reused, not re-ingested. This is the single
+ biggest win of colocating. **Sequencing:** #238 lands before Islands M0; if
+ Islands starts earlier, branch from `feature/tearing-growthrates` (docs/00
+ milestone sequencing) so the interfaces are in hand from the first commit.
+ On `develop` today the old `src/InnerLayer/SLAYER/Slayer.jl` is still a
+ placeholder — do not code against `develop`'s module layout; #238 also moves
+ GGJ under `src/Tearing/`. `src/KineticForces` (PENTRC/NTV) exists on
+ `develop` and is the natural Level-4 torque-balance counterpart.
+- **Namespace discipline**: Islands imports GPEC; GPEC never imports Islands until
+ Islands is stable enough to be a documented feature. One-way dependency,
+ enforced by CI (a test that greps GPEC sources for `using .Islands`-type
+ references).
+- **CLAUDE.md layering**: Claude Code loads nested CLAUDE.md files; the repo
+ root keeps GPEC-wide conventions (including the existing merge-conflict
+ synthesis policy), and `src/Islands/CLAUDE.md` applies within the module
+ subtree. On conflict, the more specific file governs for work inside
+ `src/Islands/`; flag genuine contradictions to the human rather than
+ resolving silently.
+- **CI**: Islands tests/benchmarks run as separate CI jobs so a red Islands ladder
+ never blocks unrelated GPEC merges (and vice versa), but the SLAYER/DCON
+ cross-checks run in *both* suites once they exist — they protect the
+ interface from both sides.
+- **Branch discipline for autonomous work**: `main` protected; all agent work
+ on `feature/islands--` branches via PR (GPEC GitFlow
+ convention, repo-root CLAUDE.md); parallel milestones (e.g.
+ M3–M4 alongside M7, per docs/00) in separate git worktrees so concurrent
+ sessions never share a working tree.
+
+## 2. The operating model: autonomy without babysitting
+
+The goal is milestone-sized unattended runs with human attention concentrated
+at a few high-leverage points. Three mechanisms make this safe:
+
+### 2.1 Machine-checkable definition of done
+
+Every autonomous run is launched against a milestone (docs/00) whose exit
+criterion is a set of ladder IDs (docs/05) going green plus CI passing. "Done"
+is never a judgment call. Launch prompts follow this template:
+
+> Work milestone M per docs/00-roadmap.md. Definition of done: ladder IDs
+> green with convergence artifacts archived, full test suite passing,
+> PR opened with the docs/05 reporting requirements. If blocked, follow the
+> escalation protocol (docs/06 §2.2) and continue with the next parallelizable
+> task. Do not weaken a benchmark tolerance or re-baseline a target to reach
+> done; that is a blocker, not a fix.
+
+### 2.2 Non-blocking escalation: `docs/src/islands/QUESTIONS.md`
+
+Autonomous runs must never stall waiting for a human, and must never guess on
+the things CLAUDE.md forbids guessing (coefficients, signs, normalizations,
+[VERIFY] clearances). The resolution: an append-only `QUESTIONS.md` queue.
+When blocked, the agent writes an entry — context, the specific question,
+options considered, its recommendation, and what work is gated — then switches
+to the next unblocked task. The human's recurring job is clearing this queue
+(and [VERIFY] tags), not supervising sessions. Entries get IDs; commits/PRs
+reference the IDs they were blocked on or unblocked by.
+
+### 2.3 Guardrails in tooling, not vigilance
+
+**[HUMAN SETUP]** in the repo-root `.claude/settings.json` (the checked-in
+project settings shared across GPEC; add Islands-specific rules here):
+
+- `permissions`: allow the routine loop (edit within repo, `julia --project`
+ test/benchmark commands, git branch/commit/push, `gh pr` on this repo); deny
+ destructive git (force-push, `push` to main), package registry publishes,
+ and credential paths (extend the existing personal deny rules — keep those
+ global, add repo-specific ones here so collaborators inherit them).
+- Truly unattended runs (`--dangerously-skip-permissions` or equivalent
+ auto modes) only inside a container/devcontainer or on a cluster node with a
+ scratch clone — never on a laptop checkout with credentials in reach. The
+ existing tmux-on-cluster workflow is the natural home for long runs: one
+ tmux window per worktree per milestone.
+- **Hooks** (checked in with the project): PostToolUse on Edit/Write → run the
+ fast test subset for the touched module (seconds, not the ladder); a Stop
+ hook that blocks session completion if the working tree has uncommitted
+ changes or failing fast tests; PreToolUse on Bash → deny-pattern for
+ destructive commands as a second layer under permissions.
+- **Checkpointing** stays on (default) so exploratory refactors are cheaply
+ reversible.
+
+Consult https://code.claude.com/docs/en/ (docs map) when configuring — hook
+names, permission syntax, and flags change; verify against the installed
+version rather than this document.
+
+### 2.4 GitHub Actions layer
+
+**[HUMAN SETUP]**, building on the prior GPEC GitHub-App exploration (org
+permission constraints noted there still apply — may need org-owner action):
+
+- `anthropics/claude-code-action` for PR review on `src/Islands/**` and
+ `docs/src/islands/**` paths, with a
+ review prompt pointing at CLAUDE.md and docs/: check [VERIFY] policy
+ compliance, no regime-branches in operators, ladder IDs addressed.
+- Issue-triggered autonomous work: label `claude-task` on an issue containing
+ a milestone-template prompt → action runs headless (`claude -p`) in a
+ container, opens a PR. This is the "assign work from a phone" channel.
+- Scheduled (cron) workflows: nightly fast-ladder regression + allocation
+ regression; weekly full ladder on `main`. Failures open issues automatically
+ (which can themselves be `claude-task`-labeled — closing the loop on
+ maintenance without human dispatch).
+- Secrets: `ANTHROPIC_API_KEY` as a repo/org secret. Local-config gotcha from
+ prior experience: if Claude Code ignores a correctly set key and shows the
+ login menu, check `~/.claude.json` for a stale `customApiKeyResponses.rejected`
+ entry.
+
+### 2.5 Session-level habits (standing guidance for agents)
+
+- Start milestone work in plan mode; write the plan against docs/00 before
+ editing. Persist the session objective (goal/directive mechanisms) so long
+ sessions don't drift from the milestone definition of done.
+- Delegate to subagents (§4) for review/verification passes so the main
+ context stays on implementation; summarize subagent findings into the PR.
+- Commit granularly with ladder/QUESTIONS references; a session that ends
+ without a pushed branch and status note has failed its exit criteria.
+- Post-session, append a short entry to `docs/src/islands/LOG.md`: what moved, what's
+ blocked, next action. This file is the cross-session memory spine — read it
+ at session start along with QUESTIONS.md.
+
+## 3. Get Physics Done (GPD) — assessment and setup
+
+**What it is**: open-source (Apache-2.0) agentic physics-research command pack
+from Physical Superintelligence PBC, released March 2026; installs into Claude
+Code (also Codex/Gemini/OpenCode) via `npx -y get-physics-done`, adding a
+command ladder (`/gpd:help` → `start` → `tour` → `new-project` /
+`map-research` → `resume-work`) plus an autopilot mode for directed autonomous
+research. It targets exactly this project's genre — long-horizon problems
+needing rigorous verification, structured research memory, multi-step
+analytical work, and manuscript preparation — with a stated bias toward rigor
+over agreeability. Plasma physics is among its supported subfields.
+Repo: https://github.com/psi-oss/get-physics-done
+
+**Recommendation: install and trial it, scoped to a specific lane.** The
+division of labor:
+
+- **GPD lane — derivation and verification work**: clearing [VERIFY] tags by
+ independent re-derivation (its verification discipline is exactly the
+ [VERIFY] workflow's counterpart); deriving the companion analytic limits the
+ ladder needs (e.g. docs/05 C6 resonant-EP limit); literature mapping
+ (`map-research`) for the polarization-current sign genealogy before touching
+ B4; eventually manuscript drafting. GPD derivations feed
+ `docs/src/islands/derivations/` as `[DERIVED]` artifacts per CLAUDE.md — they *propose*
+ [VERIFY] clearances; a human still signs off.
+- **Native lane — implementation**: code, numerics, CI, benchmarks stay under
+ the Islands module's CLAUDE.md + docs. GPD is a research harness, not a
+ software-engineering harness; don't let two workflow systems fight over the
+ same task.
+
+**[HUMAN SETUP]** cautions: install project-local first (not global), read
+what it injects before granting — a command pack is prompt-layer software and
+should be audited and version-pinned like any dependency. Confirm its
+project-artifact directories (`GPD/`, `~/.gpd`) are gitignored or deliberately
+committed, and that its instructions don't contradict CLAUDE.md (if they do,
+CLAUDE.md wins inside `src/Islands/`; note conflicts in QUESTIONS.md).
+
+Its sibling GSD (general-purpose "Get Shit Done" workflow, which GPD is
+modeled on) is an optional trial for milestone execution on the native lane;
+adopt only if the plain milestone-prompt + hooks + ladder setup proves
+insufficient — more workflow machinery is not automatically better.
+
+## 4. Skills and subagents
+
+### 4.1 Public skills **[HUMAN SETUP]**
+
+Baseline installs from Anthropic's official marketplace
+(`/plugin marketplace add anthropics/claude-plugins-official`, and
+`anthropics/skills` as a second marketplace):
+
+- **skill-creator** — the important one. It scaffolds skills interactively and
+ runs eval loops (test cases in `evals/evals.json`, isolated subagent runs,
+ graded assertions). All custom skills below get built and *evaluated* with
+ it rather than hand-written.
+- **code-simplifier** — Anthropic's internal cleanup pass (behavior-preserving
+ simplification); run it at the end of implementation sessions to counter
+ agent-accumulated complexity.
+- Document skills (pdf/docx/pptx/xlsx) as needed for reports; low priority.
+
+Community directories (skills.sh, claudeskills.info, curated lists) are worth
+a periodic browse, but the ecosystem is flooded and physics-specific offerings
+are thin: expect nothing that knows Julia plasma physics. Adopt sparingly,
+pin versions, and audit anything that runs scripts. A community Julia skill,
+if a well-maintained one exists at setup time, can seed §4.2's `julia-conventions`
+skill; verify currency (Julia ecosystem skills go stale fast) and strip
+anything conflicting with project conventions.
+
+### 4.2 Custom project skills (the real leverage; build with skill-creator)
+
+These live in-repo (`.claude/skills/` at the appropriate level) so every
+session and CI agent inherits them. They exist to make *subagents and fresh
+sessions* cheap to orient — progressive disclosure of exactly the context that
+would otherwise be re-explained:
+
+1. **gpec-map** (repo root): GPEC/OFT architecture — where DCON Δ′, SLAYER,
+ equilibrium representations, and coordinate machinery live; module naming;
+ how to run each test suite; SFL coordinate conventions (Hamada/Boozer/PEST)
+ and Jacobian gotchas. Mostly distilled from existing GPEC docs + the
+ maintainer's head; this skill is the highest-value few hours of human
+ dictation in the whole setup.
+2. **julia-conventions** (repo root): project Julia idioms — Revise workflow,
+ per-thread preallocation patterns, allocation-test policy, `@inbounds`
+ policy, OrdinaryDiffEq-vs-QuadGK division of labor, Interpolations boundary
+ conditions. Encodes the lessons already learned so no session relearns them.
+3. **islands-conventions** (repo-root `.claude/skills/`): the load-bearing
+ distillation of docs/01–05 — half-width convention, frames module rule,
+ [VERIFY]/[DERIVED] workflow, operator-stack rules, escalation protocol.
+ Keeps subagents aligned without loading the full docs.
+4. **benchmark-ladder** (repo-root `.claude/skills/`): how to run
+ `src/Islands/verify/` and `benchmarks/islands/`, where reference data lives,
+ how to read convergence artifacts, what re-baselining requires. Paired with
+ the ladder from day one.
+5. **paper-figures** (later): publication figure conventions (Makie/matplotlib
+ styles, the editable-SVG lessons), once results exist.
+
+Maintain skills with the same [VERIFY]-grade discipline as docs: they are
+normative context, and a stale skill is worse than none. skill-creator's eval
+loop is the regression test.
+
+### 4.3 Project subagents
+
+Defined in-repo so they're versioned. Minimal set:
+
+- **physics-verifier** (read-only tools): audits diffs against docs/01
+ conventions and the [VERIFY] policy; adversarial by instruction ("find the
+ sign error" posture). Runs before every PR.
+- **numerics-reviewer** (read-only): convergence-artifact and
+ allocation-regression review; checks that "passing" benchmarks meet the
+ docs/05 reporting rules.
+- **literature-scout** (read + web): given a [VERIFY] tag, retrieves the source
+ (arXiv/DOI), extracts the exact equation context, and drafts the clearance
+ proposal for human sign-off. Pairs with an arXiv/paper-search MCP server if
+ one is connected **[HUMAN SETUP — optional]**; audit any third-party MCP
+ server before connecting, same rules as command packs.
+
+## 5. Human attention budget (what "not babysitting" costs instead)
+
+Steady state, the human's recurring surface is: (1) the QUESTIONS.md queue,
+(2) [VERIFY]/[DERIVED] sign-offs, (3) PR review of milestone branches (with
+the action + subagents having pre-reviewed), (4) gate sign-offs and Decision
+Log entries at level boundaries. Everything else — implementation, tests,
+regression triage, benchmark bookkeeping, nightly maintenance — is delegated.
+If any other category starts consuming attention, that's a tooling bug: fix
+the hook/skill/prompt, don't absorb the load manually.
+
+## 6. Setup checklist (condensed)
+
+**[HUMAN SETUP]**, in order:
+1. Create the `src/Islands/` module skeleton (submodule of
+ GeneralizedPerturbedEquilibrium — no separate `Project.toml`); the design
+ docs already live under `docs/src/islands/`. Commit docs before any code.
+2. Project settings: permissions allow/deny, hooks, checked into the repo-root
+ `.claude/`.
+3. Branch protection on main; worktree convention documented in LOG.md.
+4. GitHub Actions: claude-code-action PR review; `claude-task` issue workflow;
+ nightly/weekly ladder crons; API key secret.
+5. Plugin marketplaces + skill-creator + code-simplifier.
+6. Build gpec-map and julia-conventions skills (human-dictated, skill-creator
+ evaluated); islands-conventions and benchmark-ladder skills alongside M0–M1.
+7. Define the three subagents.
+8. GPD: project-local install, audit, trial on one [VERIFY] clearance and one
+ derivation task; decide lane adoption after two weeks of use.
+9. First autonomous run: M1 (operator-stack skeleton + MMS harness) with the
+ §2.1 template — deliberately low-physics-risk to shake out the tooling.
diff --git a/docs/src/islands/design/07-documentation-and-papers.md b/docs/src/islands/design/07-documentation-and-papers.md
new file mode 100644
index 00000000..725b0e35
--- /dev/null
+++ b/docs/src/islands/design/07-documentation-and-papers.md
@@ -0,0 +1,147 @@
+# 07 — Documentation and the paper series
+
+Principle: **documentation is a build artifact, not a chore.** The repository
+always contains an accurate, current statement of (a) what physics is
+implemented, as equations, (b) what state the code is in, and (c) what has been
+verified, as figures. Level gates are redefined so that each level concludes
+with a journal-grade manuscript whose equations are the implemented equations
+and whose figures are the passing verification tests. If the docs and the code
+disagree, the build is broken.
+
+---
+
+## 1. The four documentation layers
+
+### 1.1 The Physics Book — `docs/src/islands/`
+
+A chaptered, continuously-maintained derivation-and-implementation reference
+(Markdown + LaTeX math; rendered with the API docs). One chapter per physics
+component: coordinates & conventions, the DKE and each operator, collision
+operators, field equations, moments & the MRE assembly, species/backgrounds,
+closures. Rules:
+
+- **As-implemented, not as-aspired.** Every equation in the Physics Book is the
+ equation the code solves, in the code's normalization, with the code's sign
+ conventions. Aspirational/planned physics lives in docs/00–02, never here.
+- **Bidirectional anchors.** Every operator/term in `src/` carries a comment
+ citing its Physics Book anchor (`# physics: dke.md#magnetic-drift-improved`),
+ and every equation block in the Book carries the implementing symbol
+ (`Implemented by: MagneticDrift{:improved}`). A CI script checks both
+ directions: no operator without an anchor, no `Implemented by:` pointing at
+ a nonexistent symbol. Orphans fail CI.
+- **[VERIFY]/[DERIVED] tags live here** (migrating from docs/01 as
+ implementation proceeds); the tag state is part of the rendered page, so the
+ epistemic status of every equation is always visible.
+- **Change discipline:** any PR that changes physics behavior must change the
+ corresponding Physics Book section *in the same PR* (see §4).
+
+### 1.2 API documentation — Documenter.jl
+
+Standard Julia docstrings on all public API, built with Documenter.jl in CI
+(doctest-checked) and deployed with the GPEC docs. Docstrings for physics
+functions are thin — one-line purpose + the Physics Book anchor — so the
+physics prose has a single home. Worked examples via Literate.jl scripts that
+double as smoke tests.
+
+### 1.3 The State Dashboard — `docs/src/islands/state/`
+
+The always-current answer to "what works right now":
+
+- `STATE.md` — auto-generated (script in `src/Islands/verify/`, run in CI on main):
+ a table of levels × orderings showing each toggle's status
+ (implemented / partial / planned), and the full docs/05 ladder as a status
+ table (ID, target, status, tolerance achieved, grid tier, date, commit).
+ Hand-editing this file is forbidden; it regenerates from benchmark artifacts.
+- `LOG.md` and `QUESTIONS.md` (docs/06) — session memory and blocker queue.
+- The **figure gallery** (§2) index.
+
+### 1.4 The paper series — `docs/src/islands/papers/`
+
+Each level's gate deliverable (§3). In-repo LaTeX, figures generated only by
+pinned scripts from archived benchmark data.
+
+## 2. One figure pipeline for tests, gallery, and papers
+
+The same figure serves three masters — CI verification artifact, gallery page,
+paper panel — so there is exactly one implementation:
+
+- Every ladder benchmark (docs/05) ships with a figure script in
+ `benchmarks/islands/figures/` that reads *archived benchmark output* (HDF5/JLD2 with
+ embedded config + git SHA, per docs/03 §4), never re-runs physics. Output:
+ publication-grade PDF/SVG + a PNG for the gallery.
+- Standard verification-figure grammar, enforced by a shared plotting module:
+ Islands result (points/solid) vs. analytic limit (dashed) vs. published
+ reference data (symbols, digitized where unavoidable — in-repo SLAYER/DCON
+ comparisons are computed live in CI, one of the payoffs of colocating);
+ inset or companion panel showing grid-convergence; caption footer
+ auto-stamped with configuration name, commit, and ladder ID.
+- The gallery (`docs/src/islands/state/gallery/`) is rebuilt in the nightly/weekly ladder
+ runs — so "are we still meeting the limits we expect" is a page you (or a
+ collaborator, or a referee) can look at any day, not a claim in a README.
+- Papers `\includegraphics` from `benchmarks/islands/figures/` output directly. A paper
+ figure that cannot be regenerated by `make figures` from archived data is a
+ release-blocking bug. This is the reproducibility posture stated in
+ docs/05's reporting rules, made mechanical.
+
+## 3. The paper series: gates as manuscripts
+
+A level gate is now: **ladder IDs green + Physics Book chapters complete and
+[VERIFY]-cleared for the level's equations + a submission-ready manuscript.**
+The manuscript is not overhead on top of the gate; it *is* the gate review —
+writing it is how gaps get found. Provisional series (titles indicative;
+venues by convention of the field):
+
+| Paper | Gate | Content | Indicative venue |
+|---|---|---|---|
+| **I** | Level 0 | Formulation & numerics of the generalized island drift-kinetic solver; verification figures: MMS convergence, no-island neoclassics, large-w bootstrap limit, three-code threshold reproduction (DK-NTM 8.73 ρ_bi → RDK-NTM 1.46 ρ_bi drift-model story + kokuchou's finite-ν_★ w_c(ρ̂_θi, ν_★) surface, docs/05 B5a–c), Δ_pol(ω_E) sign-reversal curve; resolution of the L23 open question (stabilizing electron Δ_pol at ω_E = 0) is a candidate headline result | Phys. Plasmas (methods) |
+| **II** | Level 1 | Arbitrary-collisionality unification of bootstrap & polarization MRE terms; figures: Δ vs. ν̂ sweeping banana→PS with analytic corners marked; W minority: friction-modified Δ_bs and in-island impurity asymmetry | Nucl. Fusion |
+| **III** | Level 2 | Shaped/finite-β geometry; energetic-particle island response: slowing-down alphas, finite-orbit nonlocality, precession resonance ω ~ ω_D,α; burning-plasma NTM threshold implications | Nucl. Fusion (PRL letter if the resonance result warrants) |
+| **IV** | Level 3 | **Flagship:** unification of linear layer (SLAYER limit) and nonlinear island response; the w ~ δ_layer kinetic penetration-to-NTM transition; figures: Δ(w, Q) surface with SLAYER curves recovered on the small-w edge and MRE terms on the large-w edge | PRL/letter + long-form companion |
+| **V** | Level 4 | Self-consistent thresholds: torque-balance ω, w_d with explicit χ⊥, radiative/W thermo-resistive islands; confrontation with empirical penetration & onset databases | Nucl. Fusion |
+| **VI** | M12 | Δ-surface dataset + emulator; code/data release paper | Comput. Phys. Commun. or NF |
+
+Rules:
+
+- **Paper = milestone structure.** Each paper gets `docs/src/islands/papers/paper-N/` with
+ OUTLINE.md (claims → figure list → ladder IDs backing each claim) created at
+ level *start*, not level end. The outline is the level's figure contract:
+ agents implementing benchmarks know which figures are paper figures from day
+ one. Outline claims lacking a ladder ID are flagged — every claim is backed
+ by a test.
+- **Companion derivations** (e.g. the C6 resonant-EP analytic limit) are
+ developed in `docs/src/islands/derivations/` (GPD lane, docs/06 §3) and either fold into
+ the paper or spin out — decided at outline review.
+- **Authorship/collaboration** is a human matter; agents draft methods,
+ verification sections, and figure captions (which they can ground in the
+ archived artifacts), humans own claims, framing, and submission. No
+ submission without every [VERIFY] tag in the paper's equation set cleared.
+- Interim results that don't warrant a paper still get the same treatment at
+ milestone scale: a short technical note in `docs/src/islands/notes/` with gallery
+ figures — the habit is uniform, only the polish varies.
+
+## 4. Enforcement: docs change when code changes
+
+Mechanisms, weakest to strongest:
+
+1. **CLAUDE.md policy** (amended): physics-behavior PRs must update the
+ Physics Book section and, if outputs change, regenerate affected figures.
+ The PR description lists doc anchors touched, or states `docs-not-needed:`
+ with justification (reviewer-visible, greppable, audited).
+2. **Anchor-sync CI check** (§1.1): orphaned operators or dangling
+ `Implemented by:` references fail CI.
+3. **State regeneration**: STATE.md and the gallery rebuild on main; a PR that
+ turns a ladder ID red turns the dashboard red — visible drift, fast.
+4. **physics-verifier subagent** (docs/06 §4.3) gains an explicit doc-sync
+ audit: diff the operator changes against the Physics Book diff and flag
+ semantic mismatches (the thing CI string checks can't catch).
+5. **Doctest/Literate examples** run in CI, so API docs can't silently rot.
+
+## 5. Division of documentation labor
+
+Agents maintain: docstrings, Physics Book edits accompanying their own PRs,
+figure scripts, STATE regeneration, gallery, first drafts of methods/
+verification prose, technical notes. Humans own: [VERIFY] clearances, Physics
+Book review at gates, paper claims/framing/submission, and the outline reviews
+that start each level. The recurring human documentation surface is therefore
+the same queue-shaped work as docs/06 §5 — review and sign-off, not authorship
+from scratch.
diff --git a/docs/src/islands/design/08-reference-library.md b/docs/src/islands/design/08-reference-library.md
new file mode 100644
index 00000000..4a590239
--- /dev/null
+++ b/docs/src/islands/design/08-reference-library.md
@@ -0,0 +1,60 @@
+# 08 — Reference library
+
+The primary sources live in-repo at
+`docs/resources/Drift_Kinetic_Island_References/`. This file maps each PDF to
+its role in the project, its abbreviation used across docs/00–05, and the
+load-bearing content a reader (human or agent) should pull from it. Equation
+transcriptions from these sources into docs/01 carry [CHECKED]/[VERIFY] tags
+per the docs/01 header semantics.
+
+## The DK-NTM / RDK-NTM / kokuchou lineage (core Level-0/1 sources)
+
+| Abbrev. | File | What it is | Load-bearing content |
+|---|---|---|---|
+| **PRL18** | `2018-Imada-Nonlinear_Kinetic_Ion_Response_to_Small_Scale_Magnetic_Islands_in_Tokamak_Plasmas.pdf` | Imada et al., PRL 121, 175001 (2018). First announcement of DK-NTM | Compact statement of the drift-island result (w_c ≃ 2.7 ρ_θi); **caution: uses ψ_s-based normalizations, different from I19's r_s-based ones** — see docs/01 §1 |
+| **JPCS18** | `2018-Imada-Drift_kinetic_response_of_ions_to_magnetic_island_perturbation_and_effects_on_NTM_threshold.pdf` | Imada et al., Varenna proceedings (2018) | Condensed DK-NTM derivation; explicit electron-flow and h(Ω) formulas; renames Δ′_bs → Δ′_loc; MRE context incl. Δ_pol ∝ 1/w³ discussion |
+| **I19** | `2019-Imada-Finite_ion_orbit_width_effect_on_the_neoclassical_tearing_mode_threshold_in_a_tokamak_plasma.pdf` | Imada et al., NF 59, 046016 (2019). The complete DK-NTM reference paper | Full equation hierarchy (Eqs. 23–34); master 4D equation Eq. (32); S-function Eq. (33); collision operator Eqs. (9)–(12); electron closure §3 (Eqs. 14–22); numerics appendix (shooting method, y_c matching Eqs. A.7–A.10, Picard loops, Eq. A.11 quasineutrality); w_c ≃ 2.76 ρ_θi (Fig. 9). **Known errata: see L23 §2.6 amendment list** (docs/01 header warning) |
+| **Diss19** | `2019-Dudkovskaia-Modelling_NTMs_in_tokamak_plasmas_PhD_dissertation.pdf` | Dudkovskaia PhD dissertation, York 2019 | The full RDK derivation chain: island geometry & Ω convention (Ch. 2), S-coordinate reduction, trapped-passing layer analytics (Ch. 3, width √(ν/εω) in λ), Δ_neo normalization (Eq. 4.12) and bootstrap/polarization split (Eqs. 4.13–4.15), frame identities ω₀ = −ω_E (pp. 47–48), torque-balance roots (Fig. 4.18), **complete solver coefficient sets in Appendices C–E (Eqs. D.60–D.62)** — the RDK cross-check mode's spec |
+| **D21** | `2021-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_a_low_collisionality_tokamak_plasma_magnetic_island_threshold_physics.pdf` | Dudkovskaia et al., PPCF 63, 054001 (2021). RDK-NTM v.1 | The improved magnetic-drift model (App. A, Eq. A2: cos θ structure of ∂B/∂ψ; L̂_B⁻¹ = 0 proxy, footnote 10) → w_c ≈ 0.45 ρ_θi ≡ 1.41–1.47 ρ_bi half-width; DK-NTM benchmark in App. C (agreement window ν_★ ~ 10⁻³–10⁻⁴); threshold-mechanism statement (§7: passing-particle physics) |
+| **D23a** | `2023-Dudkovskaia-Drift_kinetic_theory_of_the_NTM_magnetic_islands_in_a_finite_beta_general_geometry_tokamak_plasma.pdf` | NF 63, 016020 (2023). RDK-NTM v.2: finite β, shaped (Miller) geometry | Authoritative "8.73 → 1.46 ρ_bi half-width" statement (abstract); finite-β drift terms (Eqs. 28–31); Miller parametrization (Eq. 33); shaping results: triangularity 2w_c = 1.82 ρ_bi (δ=+0.42) → 2.90 ρ_bi (δ=−0.5); ε ≈ 0.3 crossover of the w_c scaling; β_θ trend vs. EAST 91972 (ladder C4) |
+| **D23b** | `2023-Dudkovskaia-Drift_kinetic_theory_of_neoclassical_tearing_modes_in_tokamak_plasmas_polarisation_current_and_its_effect_on_magnetic_island_threshold_physics.pdf` | NF 63, 126040 (2023). RDK-NTM v.3: separatrix layer + polarization + ω dependence | **Table 1** = the code-family comparison (DK-NTM vs RDK v.1/v.2/v.3); both layer widths ∝ ν^{1/2} (§3.1, footnote 11); ω_E as input parameter; Δ_pol(ω_E) sign reversal at ≈ −0.89 ω_dia,e (Fig. 8); layer effect on threshold 0.78 → 0.52 ρ_θi (Fig. 9); the B6 figure set (Figs. 3, 4, 6, 7, 9, 11, 13) |
+| **L23** | `2023-Leigh-Drift_kinetic_simulations_of_Neoclassical_Tearing_Mode_instabilities_in_finite_collisionality_tokamak_plasmas.pdf` | Leigh PhD thesis, York Dec 2023. The `kokuchou` code (DK-NTM successor, finite ν_★) | **§2.6 amendment list against I19 Eq. (A.1)** (the [VERIFY] policy's empirical justification); WCHH96 electron closure spelled out (§2.4–2.5, k = −1.173, f_p = 1−1.46√ε); TSVD treatment of the singular y_c matching matrix (§4.2); separatrix-layer width scalings incl. the iteration-dependent E×B regime (§6.1.2); Picard non-convergence forensics (§6.1.1); spurious "winged" Neumann branch (§5.3, §7.1); memory/cost data for the dense shooting method (pp. 80–84); **w_c ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵** (Eq. 6.3.2, ν_★ ∈ [0.005, 0.020]); future-work list §7.1 (mapped p̃ coordinate, analytic far-field BC) |
+
+## Background / adjacent
+
+| Abbrev. | File | What it is | Role |
+|---|---|---|---|
+| **JOP18** | `2018-Dudkovskaia-Island_Stability_in_Phase_Space.pdf` | Dudkovskaia, Garbet, Lesur, Wilson — JPCS 1125, 012009 (2018) | **Not about magnetic islands.** Bump-on-tail *phase-space* island stability (Vlasov–Fokker-Planck–Poisson secondary modes). Relevant only as (a) the methodological antecedent of the RDK bounce/angle-variable and separatrix-layer machinery, (b) EP-physics background for the Level-2 precession-resonance study (ladder C6). Do not cite it as an NTM threshold source |
+
+## Referenced but not yet in the library (acquire)
+
+- Wilson, Connor, Hastie & Hegna, PoP 3, 248 (1996) — **WCHH96**. The analytic
+ electron closure (its Eq. 55/74 lineage) and the large-w limit target
+ (its Eq. 85) are load-bearing for docs/01 §2.4 and ladder B2; currently
+ cited via I19/L23/Diss19 transcriptions only.
+- Park, Phys. Plasmas 29 (2022) — SLAYER. Needed for the D1 Q-convention map.
+- La Haye et al. (2012 NSTX/DIII-D scaling; 2006) — experimental threshold fits
+ behind ladder B9.
+- Sauter et al., PoP 6, 2834 (1999); Glasser–Greene–Johnson 1975; Fitzpatrick
+ 1993/1995/1998; Cole & Fitzpatrick 2006; Rutherford 1973; Waelbroeck &
+ Fitzpatrick 1997; Smolyakov — the classical MRE-term and penetration
+ literature (ladder B1–B4, D4–D5 targets).
+
+## Known cross-source inconsistencies (pinned so nobody re-trips on them)
+
+1. **Normalization drift within the lineage**: PRL18 normalizes to ψ_s, I19/L23
+ to r_s, D21/D23b to w_ψ. Islands pins r_s-based forms with maps in
+ `src/frames/` (docs/01 §5).
+2. **Helical angle**: I19/L23 use ξ = m(θ − φ/q_s); Diss19/D21 use
+ ξ = φ − q_s θ with cos nξ. Same island, different angle multiplicity.
+3. **Collision-frequency energy dependence**: I19/L23 use the Chandrasekhar
+ form; Diss19/D21 use V⁻³ (docs/05 E3 sub-toggle).
+4. **ψ̃ amplitude**: one I19 extraction rendered ψ̃ = (w_ψ²/4)(q_s/q_s′);
+ dimensional analysis and Diss19/D21/L23 give (w_ψ²/4)(q_s′/q_s)
+ [VERIFY against I19 as printed — possible typo in the paper].
+5. **DK-NTM run collisionality**: I19 §4.2 states ν_★ = 0.01; L23 p. 82 quotes
+ DK-NTM at ν_★ = 10⁻³; D21 App. C benchmarks at ν_★ ~ 10⁻³–10⁻⁴
+ [VERIFY before pinning B5a tolerances].
+6. **Half vs. full width**: all York w_c values are half-widths; La Haye
+ experimental fits are quoted as full widths (w_marg = 2w_c). Ladder
+ reporting rule 5 exists because of this.
diff --git a/docs/src/islands/design/M1-launch-prompt.md b/docs/src/islands/design/M1-launch-prompt.md
new file mode 100644
index 00000000..1ea55cfe
--- /dev/null
+++ b/docs/src/islands/design/M1-launch-prompt.md
@@ -0,0 +1,74 @@
+# M1 launch prompt (Islands overnight autonomous run)
+
+> This file is fed verbatim to the overnight loop:
+> `claude --permission-mode dontAsk --continue -p "$(cat docs/src/islands/design/M1-launch-prompt.md)"`.
+> It is the milestone contract (design doc `06 §2.1`). Keep it stable across
+> relaunches so `--continue` resumes the same objective.
+
+You are working milestone **M1** of the Islands module (`src/Islands/`), a
+steady-state drift-kinetic island/layer solver, autonomously and unattended.
+
+## Read first (every session)
+
+`src/Islands/CLAUDE.md` (module conventions + the [VERIFY] policy),
+`docs/src/islands/LOG.md` and `docs/src/islands/QUESTIONS.md` (session memory +
+open blockers), then the design docs
+`docs/src/islands/design/{00-roadmap,03-architecture,04-numerics,05-verification}.md`.
+The repo-root `CLAUDE.md` governs GPEC-wide conventions.
+
+## Goal (set this as your `/goal` completion condition)
+
+**M1 is done when all of the following hold:**
+1. `test/runtests_islands_*.jl` exist and are included in `test/runtests.jl`, and
+ they implement ladder **A1** (MMS: per-operator + assembled-system convergence
+ at design order) and **A2** (JVP vs. finite-difference residual), plus an
+ allocation-regression test for the `apply!` hot paths — all **green**.
+2. The full suite passes: `julia --project=. test/runtests.jl`.
+3. A PR is open onto `feature/islands` with the changes.
+4. **No `[VERIFY]` coefficient has been assigned a value** without a
+ `docs/src/islands/QUESTIONS.md` entry.
+
+## Scope
+
+- **M1 core** (design `03 §1–2`, `04`): the phase-space grids `(x, ξ, λ, E, σ)`
+ with the layer-clustered mappings; the operator-stack skeleton (`AbstractTerm`,
+ `apply!`, and the term structs from `03 §2`) as **AD-compatible,
+ allocation-free stubs**; the MMS harness and per-term AD-vs-FD JVP checks
+ (`04 §3`). Build the structure and the tests, not the physics numbers.
+- **Early-M2 structure, best-effort after M1 core is green**: wire the
+ term/moment/field structure toward the L0 solve — but **every physics
+ coefficient stays a parameterized, `[VERIFY]`-tagged stub with a skipped
+ benchmark** (CLAUDE.md rule 1).
+
+## The hard rule (non-negotiable)
+
+**Never assign a value to a `[VERIFY]` physics coefficient, sign, or
+normalization.** The moment you would need a specific number/sign from the
+literature that isn't already `[CHECKED]`-cleared: (a) implement the *structure*
+with the coefficient as a named parameter, (b) add a skipped benchmark
+referencing the `[VERIFY]` tag, (c) write a `QUESTIONS.md` entry (context /
+question / options / recommendation / gated work), and (d) **switch to the next
+unblocked task**. Guessing a coefficient is the exact failure this project
+exists to prevent.
+
+## Working discipline
+
+- Before committing any physics-adjacent change, run the **`physics-verifier`**
+ subagent; if it returns BLOCK, fix or escalate — do not commit.
+- Commit granularly to `feature/islands` with messages in the repo format
+ (`ISLANDS - - `); reference `QUESTIONS.md` IDs where relevant.
+ Push only to `feature/islands` (never `develop`/`main`; the hooks enforce this).
+- Never weaken a tolerance or re-baseline a target to reach "done" — that is a
+ blocker, not a fix.
+- Append a `LOG.md` entry (what moved / blocked / next) before ending each
+ session. The `Stop` hook will keep you from ending with a dirty tree or a
+ broken build.
+- If you exhaust the milestone's unblocked work (everything remaining is gated on
+ `QUESTIONS.md`), commit, log, and let the session end — the outer loop and the
+ human will pick it up.
+
+## Definition of NOT done (do not stop early)
+
+Do not declare M1 done if any A1/A2/allocation test is failing or skipped-as-a-
+shortcut, if the suite is red, if the tree is dirty, or if any coefficient was
+guessed. Those are blockers, not completion.
diff --git a/docs/src/islands/design/M2-launch-prompt.md b/docs/src/islands/design/M2-launch-prompt.md
new file mode 100644
index 00000000..2b406ce1
--- /dev/null
+++ b/docs/src/islands/design/M2-launch-prompt.md
@@ -0,0 +1,169 @@
+# M2 milestone contract (Islands)
+
+> The milestone contract for Islands M2 (design doc `06 §2.1`). Set it as the
+> `/goal` completion condition when working M2.
+
+You are working milestone **M2** of the Islands module (`src/Islands/`), a
+steady-state drift-kinetic island/layer solver. M1 (phase-space grids +
+operator-stack skeleton + MMS/AD harness) is complete (PR #320); M2 builds the
+**Level-0 solve machinery** on top of it.
+
+## Read first (every session)
+
+`src/Islands/CLAUDE.md` (module conventions + the [VERIFY] policy),
+`docs/src/islands/LOG.md` and `docs/src/islands/QUESTIONS.md` (session memory +
+open blockers), then the design docs
+`docs/src/islands/design/{00-roadmap,01-physics-level0,02-species-and-eps,03-architecture,04-numerics,05-verification,06-autonomy-and-tooling,07-documentation-and-papers}.md`.
+The repo-root `CLAUDE.md` governs GPEC-wide conventions.
+
+## The gating reality (read this before you plan)
+
+M2's roadmap headline is "L0 single-species solve, Δ moments, **York gates** →
+Paper I." **The York gates are NOT reachable in this run** and reaching one is a
+policy violation, not completion. Every L0 physics coefficient the gates need
+(the `ω̂_D`/`L̂_B` toggle, the collision kernel, `k=−1.173`, `f_p=1−1.46√ε`,
+`⟨ν̂_ii⟩_u`, the quasineutrality closure, the York numbers `8.73`/`1.46` ρ_bi) is
+at most **`[CHECKED]`** — none is human-cleared — and Decisions **D7** (implement
+L0 from an independent re-derivation vs the L23-amended set) and **D8** (pin the
+B5a/b/c benchmark triangle) are **unratified** (`docs/00` Decision Log). `[CHECKED]`
+is *not* permission to hardcode a number (`docs/01` header; `CLAUDE.md` policy).
+
+So M2 here = **build the full L0 solve *structure* with every physics coefficient
+a `[VERIFY]`-gated parameter, close the physics-free structural gates, and escalate
+a prioritized clearance queue** for the human. A thin follow-up run fills the
+numbers and hits the York gates *after* a human clears the tags.
+
+## Goal (set this as your `/goal` completion condition)
+
+**M2 is done when all of the following hold:**
+
+1. **The L0 solve machinery exists** as AD-compatible, allocation-free structure,
+ with **every physics coefficient a supplied `[VERIFY]`-gated parameter (no
+ literal in `src/`)** — new submodules per design `03 §1`:
+ - `solvers/` — matrix-free **Newton–Krylov** (GMRES on the M1 ForwardDiff JVP;
+ inexact-Newton / Eisenstat–Walker forcing; line search; a pseudo-arclength
+ continuation scaffold that detects folds from day one; a physics-block
+ preconditioner that treats the `y_c` matching block explicitly), `04 §3, §5`.
+ - `moments/` — `Δ_cos`/`Δ_sin` assembly: species-charge-weighted velocity moment
+ of `g` → `J̄_∥(x, ξ)` → the `∮ J̄_∥ {cos, sin} ξ dξ ∫ dψ` projections (`01 §4`),
+ plus the bootstrap/polarization channel decomposition *structure*. The `μ₀R/2ψ̃`
+ prefactor and `ψ̃` stay **gated** (open `[VERIFY]`); the projection + quadrature
+ is pure numerics.
+ - `frames/` — THE `ω`/normalization conversion module (`01 §5`): the conversion
+ *forms* with every sign and normalization flagged and gated. This module owns
+ all `ω`-sign conventions (the polarization sign disputes are frame disputes —
+ do not reproduce them elsewhere). Unit-test only the mechanical, sign-free
+ identities.
+ - `fields/` — formalize the quasineutrality residual (already stubbed in
+ `operators/`); the flattened-electron closure *structure* gated.
+ - `species/` — `Species`, `AbstractBackground`, `SpeciesRole {Bulk, Trace}`
+ plumbing (`02 §1`, Decision D3) — pure data structures, fully unblocked. The L0
+ test is a single bulk ion + a trace-deuterium copy.
+ - **neoclassical-matching far-field BCs** (`01 §3`, `04 §1`): structure only; the
+ no-island neoclassical far-field solution is gated. **Never bare Neumann** (the
+ L23 "winged" spurious-branch failure).
+
+2. **Physics-free structural gates green** (`05 §A`), in `test/runtests_islands_*.jl`,
+ passing in the full suite:
+ - **A5** zero-drive null: gradients and `Φ̃` off ⇒ `g ≡ 0` exactly, residual =
+ machine zero, Newton converges trivially.
+ - **Assembled solve-MMS**: a manufactured `g*` with `GradientDrive` set so `g*` is
+ the exact Newton solution; the solver recovers `g*` at design order (extends
+ M1's residual-MMS to a full converged solve).
+ - **A8** `y_c` matching-block smallest-singular-value conditioning monitor
+ (regression = the silent-noise failure mode of L23 §4.2).
+ - **A4** (L0): particle conservation + discrete entropy sign `∫ g C[g]/F_M ≤ 0`
+ of the discretized collision operator (structural — holds for any valid
+ discretization, independent of the physical `ν` value).
+ - **A3** parity: `Δ_cos` even / `Δ_sin` odd under the appropriate `(ξ, σ, ω_E)`
+ reflection, tested on a manufactured `J̄_∥` (coefficient-free).
+ - **A7** the coefficient-free identity `⟨∂²h/∂x²⟩_Ω = 0` (defer the number-bearing
+ `k`, `f_p`, `⟨ν̂_ii⟩` identities to a post-clearance run).
+
+3. The full suite passes: `julia --project=. test/runtests.jl`.
+
+4. **`docs/src/islands/papers/paper-1/OUTLINE.md`** written as the Paper-I figure
+ contract (`07 §3`): claims → figures → ladder IDs (B5a/b/c, B2, B4). This is the
+ level-*start* deliverable, not the level-*gate*.
+
+5. **The clearance queue** (the parallel-human deliverable): a consolidated,
+ prioritized set of `QUESTIONS.md` entries covering every coefficient the York
+ gates need + D7/D8 ratification + the open `[VERIFY]`s, **each paired with a
+ skipped B-series benchmark** in `benchmarks/islands/` that references its tag — so
+ the human can clear in parallel and the follow-up run fills numbers and hits gates.
+
+6. A PR is open onto `feature/islands` (a sub-branch → `feature/islands`, as M1's
+ PR #320 did).
+
+7. **No `[VERIFY]`/uncleared-`[CHECKED]` coefficient has been assigned a value**
+ without a `docs/src/islands/QUESTIONS.md` entry.
+
+**Explicitly NOT in the M2 DoD (gated on human clearance — do not attempt):** the
+York gates B5a/b/c, B2, B4; B1 (needs an external NEO/NCLASS run); any physics
+number. A "green York gate" reached by hardcoding is the exact failure this project
+exists to prevent.
+
+## Scope
+
+- **Reuse existing GPEC machinery; do not reimplement** (repo-root CLAUDE.md
+ minimal-change discipline): `FastInterpolations.integrate` /
+ `cumulative_integrate` for the ψ/flux integrals (`src/Equilibrium/Equilibrium.jl`,
+ `src/ForceFreeStates/Resist.jl`); the `src/KineticForces/BounceAveraging.jl`
+ velocity-space λ-averaging pattern for the `(y, E, σ)` moments; the allocation-free
+ `QuadGK.quadgk!` pattern (`src/ForceFreeStates/Galerkin/GalerkinAssembly.jl`) for
+ hot quadrature; the `EquilibriumConfig` / `build_inputs_from_toml` TOML-config
+ pattern (`src/Equilibrium/EquilibriumTypes.jl`,
+ `src/GeneralizedPerturbedEquilibrium.jl`) for an `[Islands]` `gpec.toml` section in
+ a new `io/`.
+- **Add `Krylov.jl`** to `Project.toml` `[deps]` + `[compat]` (matrix-free GMRES;
+ `04 §9` names Krylov.jl / LinearSolve.jl — prefer Krylov.jl: lighter and
+ purpose-built). The JVP is the M1 ForwardDiff operator; form **no** global sparse
+ Jacobian except in a tiny-grid debug mode.
+- Create `benchmarks/islands/` (+ `figures/`) and at least one `islands_*` regression
+ case integrated with `regression-harness/` — every physics benchmark ships
+ **skipped**, referencing its `[VERIFY]`/`QUESTIONS` id.
+
+## The hard rule (non-negotiable)
+
+**Never assign a value to a `[VERIFY]` or uncleared-`[CHECKED]` physics coefficient,
+sign, or normalization.** The moment you would need a specific number/sign from the
+literature that isn't human-cleared: (a) implement the *structure* with the
+coefficient as a named parameter, (b) add a skipped benchmark referencing the tag,
+(c) write a `QUESTIONS.md` entry (context / question / options / recommendation /
+gated work), and (d) **ask the user to clear it if they are available, otherwise
+move to the next unblocked task**. Guessing a coefficient is the exact failure this
+project exists to prevent. Manufactured, order-unity test
+coefficients in the MMS/verification harness are legitimate (they test numerics, not
+physics) and carry no tag — do not confuse the two.
+
+## Working discipline
+
+- Before committing any physics-adjacent change, run the **`physics-verifier`**
+ subagent; if it returns BLOCK, fix or escalate — do not commit.
+- **Run every new kernel once under `--check-bounds=yes`** before trusting it. (M1
+ lesson: an `@inbounds` index-swap corrupted memory and passed silently until a
+ forced bounds-checked run caught it. Assume new nested-index kernels are guilty
+ until a bounds-checked run clears them.)
+- Invoke julia with a clean loader path:
+ `env -u LD_LIBRARY_PATH /mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia
+ --project=. …` (Q1 resolution: the OMFIT `LD_LIBRARY_PATH` shadows Julia's
+ artifacts otherwise).
+- Commit granularly to a milestone sub-branch with messages in the repo format
+ (`ISLANDS - - `); reference `QUESTIONS.md`/ladder IDs where relevant.
+ Push only Islands branches (never `develop`/`main`; the hooks enforce this).
+- Never weaken a tolerance or re-baseline a target to reach "done" — that is a
+ blocker, not a fix.
+- Append a `LOG.md` entry (what moved / blocked / next) before wrapping up a working
+ session, and keep the tree clean and the build green (`06 §2.5`).
+- If the remaining work is all gated on human clearance, surface the blockers to the
+ user (they can clear a tag or ratify D7/D8 live) rather than stalling.
+
+## Definition of NOT done (do not stop early)
+
+Do not declare M2 done if: any structural gate (A5, solve-MMS, A8, A4, A3, A7's
+`⟨∂²h/∂x²⟩=0`) is failing or skipped-as-a-shortcut; the L0 machinery submodules are
+absent; the suite is red; the tree is dirty; the Paper-I OUTLINE or the clearance
+queue is missing; or any coefficient was guessed. Those are blockers, not
+completion. Conversely, do **not** keep working past a green structural ladder in an
+attempt to reach the York gates — those are gated on human clearance and out of
+scope for this run.
diff --git a/docs/src/islands/design/M2b-launch-prompt.md b/docs/src/islands/design/M2b-launch-prompt.md
new file mode 100644
index 00000000..eef120d4
--- /dev/null
+++ b/docs/src/islands/design/M2b-launch-prompt.md
@@ -0,0 +1,118 @@
+# M2b milestone contract (Islands) — Level-0 physics via the derivation lane
+
+> The milestone contract for Islands M2b (design doc `06 §2.1`). Set it as the
+> `/goal` completion condition when working M2b. Prerequisites are in place:
+> M1 (PR #320) and M2 (PR #324) are merged — the full L0 solve *structure*
+> exists with every physics coefficient gated — and the user has ratified
+> **D7/D8** and chosen the **re-derivation-first** clearance mode (QUESTIONS
+> Q2 resolved, Q3 mode pinned, 2026-07-08).
+
+You are working milestone **M2b** of the Islands module (`src/Islands/`):
+filling in the Level-0 physics that M2 deliberately gated, by the route the
+project's own thesis demands — **independent re-derivation, human sign-off,
+then implementation** — ending with the B-ladder physics benchmarks running.
+
+## Read first (every session)
+
+`src/Islands/CLAUDE.md` (the [VERIFY]/[DERIVED]/[CHECKED] policy — rules 3 and
+4 are the spine of this milestone), `docs/src/islands/LOG.md` and
+`QUESTIONS.md` (Q3 items and mode, Q4), then design docs
+`docs/src/islands/design/{00-roadmap,01-physics-level0,03-architecture,04-numerics,05-verification}.md`
+and the as-implemented chapter `docs/src/islands/numerics.md`. The source PDFs
+are in the `docs/08` reference library. The repo-root `CLAUDE.md` governs
+GPEC-wide conventions.
+
+## Goal (set this as your `/goal` completion condition)
+
+**M2b is done when all of the following hold:**
+
+1. **The derivation set exists** in `docs/src/islands/derivations/`, one
+ chapter per Q3 item, each marked `[DERIVED: date]` and structured as:
+ stated starting point (the drift-kinetic equation and orderings O1–O9,
+ cited to `01 §2`), explicit assumptions, the derivation with no skipped
+ sign-bearing steps, a boxed final coefficient form, and a **cross-check
+ table** against the `[CHECKED]` transcriptions (I19/Diss19/D21/L23,
+ honoring the L23 §2.6 amendments). Items:
+ - (a) the orbit-averaged drift frequency `ω̂_D` including the
+ `:original`/`:improved` `L̂_B⁻¹` treatment;
+ - (b) the pitch-angle collision kernel, the `ν_★` normalization, and the
+ analytic `⟨ν̂_ii⟩_u` velocity average;
+ - (c) the flattened-electron closure: the `h(Ω)` prefactor, the coupled
+ flow relation, and the `k`/`f_p` constants;
+ - (d) the quasineutrality closure coefficient;
+ - (e) the `ψ̃` island amplitude — deriving it settles the Q4
+ `q_s′/q_s` vs `q_s/q_s′` question by dimensional necessity;
+ - (f) the `Δ_cos`/`Δ_sin` prefactors, including pinning the sin-moment
+ normalization (a `[DERIVED]` pin per `01 §4`).
+ **Every discrepancy against a transcription is flagged in the table and in
+ `QUESTIONS.md` — never resolved silently** (policy rule 2).
+2. **Human sign-off, item by item.** Present each derivation to the user (they
+ are working interactively; ask). A signed-off derivation clears the
+ corresponding coefficients: record the clearance in `docs/01` (source,
+ equation, date, derivation link) per policy rule 3. **You never sign off
+ your own derivation.**
+3. **Implementation fill-in for signed-off items only**: a single Level-0
+ coefficient/configuration builder in `src/Islands` (the named-configuration
+ mechanism of `03 §2` — e.g. `:dkntm_original`, `:rdkntm_improved`) that
+ populates the M2 gated parameters, each value annotated with its derivation
+ anchor. Un-gate progressively: partial sign-off ⇒ partial fill-in; anything
+ unsigned stays NaN/supplied.
+4. **The B-ladder starts running**: for cleared configurations, un-skip the
+ corresponding `benchmarks/islands/` scripts and run them with the docs/05
+ reporting rules (grid-convergence + tolerance archived with every result;
+ half-widths with both `ρ_θi` and `ρ_bi` stated). The DoD is **benchmarks
+ running with archived artifacts and honest triage** — B5 threshold
+ *agreement* is the Level-0 gate, not this milestone's precondition;
+ disagreements are triaged per docs/05 rule 3 (our bug / their approximation
+ / their published-equation error / transcription error) with the resolution
+ logged before any conclusion.
+5. Q4 source acquisition: WCHH96 and Park PoP 29 (2022) PDFs into the `docs/08`
+ library (ask the user — they may have them); escalate in `QUESTIONS.md` if
+ unavailable and proceed with what the in-repo sources support.
+6. The full suite passes; the Physics Book chapter
+ (`docs/src/islands/numerics.md` or a new physics chapter) is updated **in
+ the same PR** for every equation that becomes as-implemented (docs/07
+ policy), with figures regenerated via the pinned script where outputs
+ change.
+7. A PR is open onto `feature/islands`, and `physics-verifier` has passed on
+ every physics-adjacent commit — its job here is checking **provenance**:
+ derivations marked `[DERIVED]`, transcriptions never silently promoted,
+ no unsigned coefficient in `src/`.
+
+## The hard rules (non-negotiable, sharpened for this milestone)
+
+- **Never present a derivation as a literature transcription or vice versa**
+ (policy rule 4). The provenance tag is part of the result.
+- **Never assign an unsigned coefficient in `src/`** — sign-off happens in the
+ conversation and is recorded in `docs/01` before the value lands in code.
+- **Never tune a derived coefficient to make a benchmark pass** (policy rule
+ 2). If a benchmark disagrees, the triage path is docs/05 rule 3, in the open.
+- If a derivation stalls (a step you cannot justify), flag it in
+ `QUESTIONS.md` with the specific step and move to the next item — a partial
+ derivation set with honest gaps beats a complete one with a glossed step.
+
+## Working discipline
+
+- Derivation chapters are Documenter pages: one-sentence-per-line source, LaTeX
+ `math` blocks, cross-check tables as Markdown tables; wire new pages into the
+ Islands nav section in `docs/make.jl` and verify with a local docs build
+ (`env -u LD_LIBRARY_PATH …/julia --project=. build_docs_local.jl`).
+- Run every new kernel once under `--check-bounds=yes`; keep `apply!` paths
+ allocation-free (regression-tested); julia via
+ `env -u LD_LIBRARY_PATH /mnt/homes_global/ncl2128/software/julia-1.11.7/bin/julia --project=. …`.
+- Commit granularly to a milestone sub-branch (`ISLANDS - - …`,
+ referencing Q3 items and ladder IDs); update the `islands_l0_structural`
+ regression case if tracked numbers legitimately move (with justification —
+ never re-baseline to reach green); append a `LOG.md` entry per session.
+- The user is present: surface each completed derivation for sign-off rather
+ than batching everything to the end.
+
+## Definition of NOT done
+
+Any coefficient in `src/` without a recorded human sign-off; a derivation
+presented without its cross-check table; a discrepancy resolved silently; a
+benchmark "passing" via tuned coefficients or weakened tolerance; the suite
+red; docs not updated with the as-implemented equations; or the tree dirty.
+Conversely: B5 threshold *disagreement* after honest triage is a reportable
+result, not a failure — do not chase agreement past what the derivations
+support.
diff --git a/docs/src/islands/design/REVISION-2026-07-07.md b/docs/src/islands/design/REVISION-2026-07-07.md
new file mode 100644
index 00000000..4021ae87
--- /dev/null
+++ b/docs/src/islands/design/REVISION-2026-07-07.md
@@ -0,0 +1,119 @@
+# Plan revision — 2026-07-07
+
+The original design bundle for the Islands module (drafted in Claude chat from
+memory of the literature, under the working acronym "ISLET") was revised against
+a full read of the nine PDFs in `docs/resources/Drift_Kinetic_Island_References/`
+plus a reality check of the GPEC repo's actual module status. It was then placed
+in-repo: module conventions at `src/Islands/CLAUDE.md`, overview at
+`docs/src/islands/index.md`, and these design docs at
+`docs/src/islands/design/`. (The "ISLET" acronym was subsequently retired in
+favour of the plain module name `Islands`, per the repo module-naming
+convention.) This note records the physics/scope revision; the later in-repo
+relocation and rename are tracked in git history.
+
+## What changed and why
+
+### 1. A whole prior-art code was missing from the plan
+The Leigh thesis (York, Dec 2023) describes **kokuchou**, a direct 4D
+drift-kinetic NTM threshold code — effectively a working prototype of Islands'
+Level 0 — with:
+- a **finite-collisionality threshold surface**
+ w_c ≈ 0.440 ρ̂_θi + 0.0178 ν_★ − 7.54×10⁻⁵ (new ladder target B5c);
+- an **amendment list (§2.6) documenting errors in the published Imada 2019
+ equation set** (missing ρ̂_θi factors, missing collision coefficients, a
+ sign) — now a load-bearing warning in docs/01 and a new [VERIFY] policy
+ rule 6 + proposed decision D7;
+- forensic documentation of the numerical failure modes: intrinsically
+ **singular trapped-passing matching matrix** (TSVD required; new ladder A8
+ monitor and docs/04 §3), **separatrix-layer width that moves between
+ nonlinear iterations** in the E×B-dominated regime, **Picard
+ non-convergence** in production, **spurious "winged" branches under Neumann
+ far-field BCs**, and a **memory wall** (16.6 GB/energy-point) that set its
+ ν_★ ≥ 5×10⁻³ floor. All entered into docs/04 and the docs/00 risk register;
+ each maps onto an Islands architecture choice (matrix-free Newton–Krylov,
+ neoclassical-matching BCs, adaptive layer packing).
+
+### 2. Threshold numbers and conventions pinned exactly (former [VERIFY]s)
+All York thresholds are **half-widths**; ρ_bi = ε^{1/2}ρ_θi:
+- DK-NTM: w_c ≃ 2.76 ρ_θi ≡ 8.73 ρ_bi at ε = 0.1 ("8.73" is a unit
+ conversion, not printed in I19) — B5a.
+- RDK-NTM improved drift model: w_c ≈ 0.45 ρ_θi ≡ 1.46 ρ_bi (2.85 ρ_bi full
+ width); the 8.73→1.46 pair is authoritative in the NF 016020 abstract — B5b.
+- The improved-vs-original drift model is now concrete: the cos θ structure of
+ ∂B/∂ψ (D21 Eq. A2), proxied by L̂_B⁻¹ = 0 — a one-term toggle
+ (`MagneticDrift` variants in docs/03).
+- Exact Ω convention, ψ̃ = (w_ψ²/4)(q_s′/q_s), Δ_neo normalization
+ (Diss19 Eq. 4.12), bootstrap/polarization split, and the WCHH96 large-w
+ limits (Δ_bs ∝ 1/w, Δ_pol ∝ 1/w³) are transcribed into docs/01 with
+ equation/page cites.
+
+### 3. Frames and polarization physics grounded
+docs/01 §5 now carries the source-confirmed identities: ω − ω_E is
+frame-invariant; ω₀ = −ω_E; L_n⁻¹ shifts with frame; Δ_pol ∝ ω_E² with a
+**sign reversal at ω_E ≈ −0.89 ω_dia,e** (D23b Fig. 8) and discrete
+torque-balance roots (Diss19 Fig. 4.18). Consequence: ω_E is a scanned Level-0
+input parameter (roadmap O4 reworded; proposed decision D7), and single-ω_E
+polarization values are forbidden in publications (risk register). L23's
+unexplained stabilizing electron Δ_pol at ω_E = 0 is recorded as an open
+physics question Islands can settle (E4/E6; Paper I candidate result).
+
+### 4. Electron closure and collision operator now exact
+docs/01 §2.3–2.4: the WCHH96 flattened-electron closure with h(Ω), the coupled
+u_∥e(u_∥i) relation (k = −1.173, f_p = 1 − 1.46√ε), the momentum-conserving
+pitch-angle operator with the Chandrasekhar-form vs. V⁻³ energy-dependence
+sub-toggle (an inconsistency *within* the York lineage, now toggle study E3),
+and the analytic ⟨ν̂_ii⟩_v average. The `:kinetic` electron option is
+identified with the RDK-NTM treatment (full coefficient sets in Diss19
+Appendix D).
+
+### 5. Verification ladder rewritten with exact targets
+docs/05: B5 split into B5a/B5b/B5c (three-code triangle, proposed decision
+D8); B4/B6 get the D23b figure list and sign-reversal curve; B7 gets the
+prior-art agreement window and the "beat kokuchou's ν_★ floor" deliverable;
+B9 (new) places the La Haye experimental fits as context; C4 gets D23a's exact
+shaping numbers (triangularity 1.82→2.90 ρ_bi full width, ε ≈ 0.3 scaling
+crossover, EAST 91972); A7/A8 (new) add kokuchou's analytic unit-test set and
+the singular-block conditioning monitor. New reporting rule 5: thresholds
+always as half-widths with the unit stated. New standing triage category:
+"their published-equation error."
+
+### 6. Repo reality check (amended after review)
+On `develop`, `src/InnerLayer/SLAYER/Slayer.jl` is a placeholder — but the
+SLAYER implementation is in flight on **PR #238**
+(`feature/tearing-growthrates`): the `src/Tearing/` umbrella with SLAYER
+(Fitzpatrick Riccati Δ(Q)) and GGJ inner-layer models, the dispersion
+root-finding layer, and the outer-region Δ′ as a full 2m×2m matrix
+(`delta_prime_raw` + the new ForceFreeStates `Riccati.jl` solver).
+**Sequencing decision:** #238 lands before Islands M0; if Islands starts earlier,
+the Islands branch is cut from `feature/tearing-growthrates` rather than
+`develop`, so the SLAYER/Δ′ interfaces are in hand from the first commit.
+README, docs/00 (milestone sequencing + risk register), docs/03, docs/05 D1,
+and docs/06 §1 encode this; docs/06 also warns not to code against `develop`'s
+old `src/InnerLayer` layout since #238 moves GGJ under `src/Tearing/`.
+KineticForces (NTV, the Level-4 torque-balance counterpart) exists on
+`develop` already.
+
+### 7. Reference hygiene
+New `docs/08-reference-library.md`: per-PDF role map, abbreviations
+(PRL18/JPCS18/I19/Diss19/D21/D23a/D23b/L23/JOP18), and six pinned
+cross-source inconsistencies (normalization drift, helical-angle conventions,
+collision-frequency forms, a suspected ψ̃ typo, the DK-NTM run-collisionality
+discrepancy, half/full widths). **Dudkovskaia 2018 JOP reclassified**: it is
+about *phase-space* (bump-on-tail) islands, not magnetic islands —
+methodological antecedent and EP background only. Missing sources flagged for
+acquisition: WCHH96 (load-bearing), Park 2022 (SLAYER), La Haye fits, and the
+classical MRE-term papers.
+
+### 8. Tag semantics extended
+New `[CHECKED: source, Eq./p.]` state in the Islands module CLAUDE.md: transcribed from
+an in-repo PDF with exact cite and machine-checked, pending the human sign-off
+that rule 3 still requires. Applied throughout docs/01/04/05.
+
+## Items needing human ratification
+- Decision D7 (implement from re-derivation vs. L23-amended set; ω_E as
+ Level-0 scan parameter) and D8 (three-code benchmark triangle) in the
+ docs/00 decision log.
+- All [CHECKED] tags (one sign-off pass over docs/01 against the PDFs).
+- Open [VERIFY] items: the I19 ψ̃ possible typo; the DK-NTM run collisionality
+ (0.01 vs 10⁻³) before pinning B5a tolerances; Park 2022 Q-convention;
+ B3 curvature configuration; D5 w_d target formula.
diff --git a/docs/src/islands/figures/continuation_fold.png b/docs/src/islands/figures/continuation_fold.png
new file mode 100644
index 00000000..6431920b
Binary files /dev/null and b/docs/src/islands/figures/continuation_fold.png differ
diff --git a/docs/src/islands/figures/grids_clustering.png b/docs/src/islands/figures/grids_clustering.png
new file mode 100644
index 00000000..06c1890f
Binary files /dev/null and b/docs/src/islands/figures/grids_clustering.png differ
diff --git a/docs/src/islands/figures/hQ_profiles.png b/docs/src/islands/figures/hQ_profiles.png
new file mode 100644
index 00000000..1c13c178
Binary files /dev/null and b/docs/src/islands/figures/hQ_profiles.png differ
diff --git a/docs/src/islands/figures/mms_convergence.png b/docs/src/islands/figures/mms_convergence.png
new file mode 100644
index 00000000..5b2a4dd6
Binary files /dev/null and b/docs/src/islands/figures/mms_convergence.png differ
diff --git a/docs/src/islands/figures/preconditioner_gmres.png b/docs/src/islands/figures/preconditioner_gmres.png
new file mode 100644
index 00000000..d885a9b6
Binary files /dev/null and b/docs/src/islands/figures/preconditioner_gmres.png differ
diff --git a/docs/src/islands/index.md b/docs/src/islands/index.md
new file mode 100644
index 00000000..bb3f44c8
--- /dev/null
+++ b/docs/src/islands/index.md
@@ -0,0 +1,129 @@
+# Islands — drift-kinetic island/layer solver
+
+> GPEC submodule (`src/Islands/`, `module Islands`). The name is a plain
+> description of the domain — the resonant magnetic island/layer region — per the
+> repo module-naming convention (simple, intuitive names, not standalone-code
+> acronyms; repo-root CLAUDE.md). Earlier drafts used the working acronym
+> "ISLET"; that has been retired.
+
+## One-paragraph pitch
+
+The Modified Rutherford Equation (MRE) is assembled, by decades-long practice, from
+regime-specific analytic terms (Rutherford resistive drive, Sauter/Hegna–Callen
+bootstrap, GGJ curvature, Wilson–Connor/Smolyakov polarization, Fitzpatrick's
+transport threshold w_d), each valid only in its asymptotic corner of parameter
+space. This mirrors the pre-SLAYER state of linear error-field penetration theory,
+where Fitzpatrick's and Cole & Fitzpatrick's regime-specific thresholds coexisted
+until Park's SLAYER (Phys. Plasmas 29, 2022) solved the underlying layer equations
+numerically for arbitrary parameters and recovered every analytic limit. Islands is
+the nonlinear analog: a steady-state, multi-species drift-kinetic solver for the
+resonant island/layer region that returns the growth moment Δ_cos(w, ω; p) and
+torque moment Δ_sin(w, ω; p) for arbitrary parameters, replacing the sum of
+regime-specific MRE terms with a single calculation — and, in its small-amplitude
+limit, reducing to the linear layer response so that error-field shielding,
+penetration, seeded-NTM onset, and island saturation become faces of one solution
+manifold.
+
+## Key deliverables (priority order)
+
+1. **Unification of small (SLAYER) and large (MRE) island response.** One inner-region
+ framework spanning shielded linear response → penetration bifurcation → saturated
+ island. The transition regime w ~ δ_layer has no kinetic treatment in the
+ literature; it is the flagship result.
+2. **Toggleable ordering stack.** Every approximation in the Imada 2019 /
+ Dudkovskaia 2021–2023 / Leigh 2023 (DK-NTM / RDK-NTM / kokuchou) lineage is a
+ runtime toggle, so their theory is a *benchmark configuration* of Islands, and
+ the impact of each ordering is measurable all the way up to the unreduced
+ problem.
+3. **Multi-species physics.** High-Z minority impurities (W: mixed-collisionality
+ bootstrap/friction physics, radiative island destabilization) and energetic
+ particles (alphas: finite-orbit-width nonlocality, slowing-down backgrounds,
+ precession resonance with island rotation).
+4. **Δ(w, ω; p) surfaces + emulator.** Precomputed response surfaces over
+ nondimensional parameter space for integrated modeling, the way SLAYER is used
+ for penetration thresholds.
+
+## Relationship to the existing toolchain
+
+Islands develops **inside the OpenFUSIONToolkit GPEC repository** as a
+subdirectory Julia package (see docs/06 §1). Status of the GPEC-side assets
+(checked 2026-07-07):
+
+- **Outer region + linear layer:** everything Islands consumes arrives with the
+ Tearing module work (PR #238, `feature/tearing-growthrates`, sequenced to
+ land before Islands starts): SLAYER Δ(Q) and GGJ inner-layer models
+ (`src/Tearing/InnerLayer/`), the dispersion/root-finding layer, and the
+ outer-region Δ′ as a full 2m×2m matrix (`delta_prime_raw` from
+ ForceFreeStates with the new Riccati ideal solver). Islands never recomputes
+ global ideal-MHD physics; the SLAYER Δ(Q) is a Level-3 *verification target*
+ called directly in CI (docs/05 D1). If Islands work begins before #238 merges,
+ branch from `feature/tearing-growthrates`, not `develop`.
+- **EP corrections to Δ'** (fast-ion pressure in the outer region) stay on the
+ GPEC side (`src/KineticForces`, the PENTRC/NTV machinery — also the natural
+ NTV restoring-torque source for Level 4 torque balance); Islands handles
+ resonant/orbit-width EP physics at the island.
+
+## Document map
+
+Design docs live in `docs/src/islands/design/`; module conventions in
+`src/Islands/CLAUDE.md`. The `docs/NN` shorthand used throughout means design
+doc `NN` (`docs/src/islands/design/NN-*.md`).
+
+| File | Contents |
+|---|---|
+| `src/Islands/CLAUDE.md` | Module conventions for Claude Code: layout map, style, testing gates, [VERIFY] policy, merge policy |
+| `design/00-roadmap.md` | Level 0–4 plan, milestones, risk register, decision log |
+| `design/01-physics-level0.md` | Level 0 equation set: coordinates, DKE, quasineutrality, moments, nondimensionalization |
+| `design/02-species-and-eps.md` | Species abstraction; tungsten (Level 1) and energetic particles (Level 2) physics specs |
+| `design/03-architecture.md` | In-repo module layout, operator stack, toggles, AD strategy, coupling interfaces |
+| `design/04-numerics.md` | Discretization, boundary layers, Newton–Krylov, continuation, performance model |
+| `design/05-verification.md` | The benchmark ladder: every analytic limit and published number Islands must recover, per level |
+| `design/06-autonomy-and-tooling.md` | GPEC-repo integration, autonomous Claude Code workflows, GPD, skills, subagents, setup checklist |
+| `design/07-documentation-and-papers.md` | Living documentation system (Physics Book, State Dashboard, figure pipeline) and the paper series; level gates are manuscripts |
+| `design/08-reference-library.md` | Map of the in-repo PDF sources (`docs/resources/Drift_Kinetic_Island_References/`), per-source load-bearing content, and known cross-source inconsistencies |
+
+## Reading order for a new contributor (human or Claude)
+
+`00-roadmap` → `01-physics-level0` → `03-architecture` → `05-verification`, then
+`02` and `04` as needed. Nothing in `src/Islands/` may contradict these documents;
+when it must, the document is amended *first* (doc-first workflow, see
+`src/Islands/CLAUDE.md`).
+
+## Canonical references
+
+The York drift-kinetic lineage is in-repo as PDFs
+(`docs/resources/Drift_Kinetic_Island_References/`) — see **docs/08** for the
+per-source map, abbreviations, and known cross-source inconsistencies:
+
+- K. Imada et al., PRL 121, 175001 (2018); JPCS 1125, 012013 (2018); **Nucl.
+ Fusion 59, 046016 (2019)** — DK-NTM: 4D nonlinear drift-kinetic island
+ theory. The 2019 paper is the complete reference; **its published equation
+ set carries known errata (see Leigh 2023 §2.6)**.
+- A. V. Dudkovskaia, PhD dissertation, York (2019) — full RDK derivation chain
+ and solver coefficient sets (Appendices C–E).
+- A. V. Dudkovskaia et al., PPCF 63, 054001 (2021); Nucl. Fusion 63, 016020
+ (2023); Nucl. Fusion 63, 126040 (2023) — RDK-NTM v.1–v.3: improved drift
+ model, shaping/finite-β, separatrix layer & polarization / ω-dependence.
+- S. Leigh, PhD thesis, York (Dec 2023) — `kokuchou`: amended DK-NTM equation
+ set, finite-ν★ threshold surface w_c(ρ̂_θi, ν_★), and the most complete
+ forensic record of the numerical failure modes (singular trapped-passing
+ matching, separatrix-layer resolution, Picard non-convergence, spurious
+ Neumann branches).
+- A. V. Dudkovskaia et al., JPCS 1125, 012009 (2018) — *phase-space* island
+ stability (bump-on-tail); methodological antecedent and EP background only,
+ not an NTM threshold source.
+
+Classical MRE-term and penetration literature (not yet in the PDF library):
+
+- R. Fitzpatrick, Nucl. Fusion 33, 1049 (1993); Phys. Plasmas 5, 3325 (1998) — linear penetration thresholds, torque balance.
+- A. Cole & R. Fitzpatrick, Phys. Plasmas 13, 032503 (2006) — drift-MHD regime-specific penetration thresholds.
+- J.-K. Park, Phys. Plasmas 29 (2022) — SLAYER: parametric layer responses across linear two-fluid drift-MHD regimes.
+- P. H. Rutherford, Phys. Fluids 16, 1903 (1973) — nonlinear island evolution.
+- O. Sauter et al., Phys. Plasmas 6, 2834 (1999) — bootstrap coefficients (arbitrary ν*).
+- A. H. Glasser, J. M. Greene, J. L. Johnson, Phys. Fluids 18, 875 (1975) — curvature (GGJ).
+- H. R. Wilson, J. W. Connor, R. J. Hastie & C. C. Hegna, Phys. Plasmas 3, 248 (1996) — the analytic electron closure and large-w limits (load-bearing for Level 0; acquire the PDF); F. L. Waelbroeck & R. Fitzpatrick, PRL 78, 1703 (1997); A. I. Smolyakov — polarization current (regime-dependent).
+- R. Fitzpatrick, Phys. Plasmas 2, 825 (1995) — transport threshold w_d.
+
+Equation transcriptions from the in-repo PDFs carry [CHECKED: source, Eq./p.]
+tags (AI-verified against the PDF, one human sign-off pending); everything
+else carries [VERIFY] until the source is in hand (see CLAUDE.md).
diff --git a/docs/src/islands/numerics.md b/docs/src/islands/numerics.md
new file mode 100644
index 00000000..32f9fb74
--- /dev/null
+++ b/docs/src/islands/numerics.md
@@ -0,0 +1,302 @@
+# Islands — numerics as implemented (M1–M2)
+
+This chapter documents the Islands machinery **as it exists in the code today**
+— the discretization, operator stack, solver, and output-moment assembly landed
+by milestones M1 and M2, with the verification evidence behind each piece. It
+is the "Physics Book" companion to the aspirational [design documents](design/00-roadmap.md):
+the design docs say what Islands *will* compute; this page says what is
+*implemented and verified now*, equation by equation.
+
+!!! warning "Where the physics is (and isn't)"
+ Everything on this page is **structure and numerics**. Under the module's
+ `[VERIFY]` policy (`src/Islands/CLAUDE.md`), no physics coefficient, sign,
+ or normalization from the drift-kinetic literature has been assigned a
+ value anywhere in `src/`: every such quantity enters as a *supplied,
+ gated parameter* (many deliberately default to `NaN` so an un-cleared
+ convention poisons results rather than guessing). The human clearance
+ queue that un-gates the physics is `QUESTIONS.md` entries **Q2–Q4**; until
+ then the physics benchmarks (`benchmarks/islands/`) ship skipped by design.
+
+## 1. Phase space and discretization
+
+The Level-0 solve lives on the orbit-averaged phase space
+``(x, \xi;\, y, E, \sigma)`` — radial distance from the rational surface,
+helical angle, pitch ``y = \lambda B_{\max}``, energy, and the parallel-velocity
+sign ``\sigma = \pm 1`` (design `03 §1`). Implemented in `Islands.PhaseSpace`:
+
+**Helical angle ``\xi``** — Fourier pseudo-spectral on the periodic domain. The
+dense spectral derivative on an even number ``n`` of uniform nodes is
+
+```math
+(D_1)_{jk} \;=\; \frac{(-1)^{j-k}}{2}\,\cot\!\Big(\frac{(j-k)\,h}{2}\Big),
+\qquad j \neq k,\quad h = \tfrac{2\pi}{n},
+```
+
+exact for bandlimited data (verified to ``6\times10^{-15}`` in the tests).
+
+**Radial ``x`` and pitch ``y``** — high-order finite differences on
+layer-clustered grids. A uniform computational coordinate ``s \in [-1, 1]`` maps
+to the physical coordinate through a monotone ``\sinh`` stretching that packs
+nodes at the internal layers the drift-kinetic problem develops (`04 §1–2`):
+
+```math
+x(s) \;=\; x_c + L\,\frac{\sinh(\beta s)}{\sinh(\beta)}
+\qquad\Longrightarrow\qquad
+\frac{d}{dx} = \frac{1}{x'(s)}\frac{d}{ds},\quad
+\frac{d^2}{dx^2} = \frac{1}{x'(s)^2}\frac{d^2}{ds^2} - \frac{x''(s)}{x'(s)^3}\frac{d}{ds}.
+```
+
+The radial grid packs toward the rational surface ``x = 0``; the pitch grid
+toward the trapped–passing boundary ``y_c`` — the two layers whose widths scale
+as ``\nu^{1/2}`` and set the prior art's operating floor (`04 §2`).
+Derivative matrices use Fornberg weights on windows of ``\mathrm{order}+d``
+points for the ``d``-th derivative, so ``D_1`` **and** ``D_2`` hold the design
+order uniformly, including at boundary rows. Composite-Simpson weights on the
+same nodes (pushed through the map Jacobian) give quadrature at matching order.
+
+**Energy ``E``** — Gauss–Laguerre nodes and weights: the Level-0 Maxwellian
+weight ``\int_0^\infty f(E)\, e^{-E}\, dE = \sum_i w_i f(E_i)`` (a slowing-down
+background at Level 2 changes the map, not the machinery).
+
+
+
+*Implementing symbols:* `PhaseSpace.FourierGrid`, `PhaseSpace.MappedFDGrid`,
+`PhaseSpace.GaussGrid`, `PhaseSpace.IslandGrid`, `PhaseSpace.fd_weights`.
+
+## 2. The operator stack
+
+The unknowns are ``U = (g,\, \tilde\Phi)`` — the orbit-averaged distribution
+``g(x, \xi, y, E, \sigma)`` per species and the electrostatic potential
+``\tilde\Phi(x, \xi)`` — and the steady-state residual is assembled as a sum of
+independent operator applications (design `03 §2`; no term inspects which
+others are active, no regime branches anywhere):
+
+```math
+R_g(U) \;=\; \sum_{\text{terms } T} T[U],
+\qquad
+R_\Phi(U) \;=\; M[g] - \alpha\,\tilde\Phi ,
+```
+
+with the Level-0 term structures (each coefficient below is **supplied data**,
+its physics value gated):
+
+| Term | Structure | Gated coefficient |
+|---|---|---|
+| `ParallelStreaming` | ``a_\xi\, \partial_\xi g + a_x\, \partial_x g`` | island-induced streaming frequencies |
+| `MagneticDrift` | ``c_D\, \partial_\xi g`` (with the `:original`/`:improved` ``\hat L_B^{-1}`` toggle) | the precession frequency ``\hat\omega_D(y, E; \sigma)`` |
+| `ExBDrift` | ``c_E \left( \partial_\xi\tilde\Phi\, \partial_x g - \partial_x\tilde\Phi\, \partial_\xi g \right)`` — the ``(x,\xi)`` Poisson bracket, the one state-nonlinear Level-0 term | the ``E\times B`` coupling |
+| `PitchAngleDiffusion` | ``c\,(K g)`` along ``y`` (mimetic form, §3) | ``\hat\nu(E)`` and the pitch diffusivity profile |
+| `GradientDrive` | additive source | the ``(\mathbf v_E + \mathbf v_D + \mathbf v_{\tilde\psi})\cdot\nabla F_0`` drive |
+| `Quasineutrality` | ``M[g] - \alpha\tilde\Phi`` | the closure coefficient ``\alpha`` |
+
+Every `apply!` kernel is allocation-free (a CI regression test holds this at
+**0 bytes**) and generic over the element type, so ForwardDiff dual numbers
+flow through the entire stack — that is what makes the solver's Jacobian exact
+(§5).
+
+*Implementing symbols:* `Operators.AbstractTerm`, `Operators.apply!`,
+`Operators.residual!`, `Operators.IslandStack`.
+
+## 3. The conservative collision structure
+
+Bootstrap physics is unforgiving about non-conservative collision operators
+(design `00`, risk register), so the pitch-angle operator is implemented in
+**mimetic divergence form**. With gradient matrix ``G`` (the ``y``-grid ``D_1``),
+quadrature weights ``w_q``, a supplied non-negative diffusivity profile ``P(y)``
+and measure ``w(y)``:
+
+```math
+K \;=\; -\,W_q^{-1}\, G^{\mathsf T}\, \mathrm{diag}(P \circ w_q)\, G,
+\qquad W_q = \mathrm{diag}(w \circ w_q),
+```
+
+which enjoys the two Level-0 conservation properties **exactly in floating
+point**, not just asymptotically:
+
+```math
+\mathbf 1^{\mathsf T} W_q K g
+ = -\,(G\mathbf 1)^{\mathsf T}\,\mathrm{diag}(P \circ w_q)\,(G g) = 0
+\quad\text{(particles; } G\mathbf 1 = 0\text{)},
+```
+```math
+g^{\mathsf T} W_q K g
+ = -\,(G g)^{\mathsf T}\,\mathrm{diag}(P \circ w_q)\,(G g) \;\le\; 0
+\quad\text{(entropy sign)}.
+```
+
+Verified to ``10^{-14}`` (ladder **A4**). A physically-profiled ``P`` vanishes
+at the pitch-domain endpoints, so zero-flux boundary behavior is built into the
+operator — no artificial ``y`` boundary conditions. (This mattered in practice:
+the generic ``a_y \partial_y^2`` form *without* boundary conditions is an
+unstable BVP discretization under refinement; the mimetic degenerate form is
+the correct structure.)
+
+*Implementing symbols:* `Operators.conservative_pitch_operator`,
+`Operators.PitchAngleDiffusion`.
+
+## 4. Boundary conditions
+
+Far-field rows at ``|x| = L_x`` are replaced by matching conditions
+``g - g_\infty`` and ``\tilde\Phi - \tilde\Phi_\infty`` (Dirichlet-type against
+supplied far-field states). **Never bare Neumann** ``\partial_x g = 0``: the
+prior art traced a spurious "winged" solution branch directly to Neumann
+non-uniqueness (`01 §3`). The physical far field — the no-island neoclassical
+solution — is gated physics, so the code takes it as supplied data; the tests
+use manufactured far fields. These conditions are also what make the
+first-order-in-``x`` advective solve well-posed.
+
+*Implementing symbols:* `Operators.FarFieldConditions`, `Operators.apply_farfield!`.
+
+## 5. The Newton–Krylov solve
+
+Decision D2: steady-state Newton–Krylov, never time-stepping and never the
+sources' nested Picard loops (which, per the prior-art forensics, *never met*
+their own convergence criterion in production). The pieces (design `04 §5`):
+
+**Exact matrix-free Jacobian.** The directional derivative comes from one dual-
+number sweep of the residual — no finite-difference Jacobian, no global sparse
+matrix:
+
+```math
+J(u)\,v \;=\; \left.\frac{d}{d\varepsilon}\right|_{\varepsilon=0} F(u + \varepsilon v)
+\quad\text{via ForwardDiff duals through the stack.}
+```
+
+**Inexact Newton with Eisenstat–Walker forcing.** Each step solves
+``J\,\delta u = -F`` by GMRES only to the tolerance the outer iteration needs,
+
+```math
+\eta_k = \gamma \left( \frac{\lVert F_k \rVert}{\lVert F_{k-1} \rVert} \right)^{2},
+```
+
+with a backtracking line search on ``\lVert F \rVert``. Convergence is declared
+on the norm **and** the pointwise maximum of the residual — the array-averaged
+residual famously hid locally divergent regions in the prior art.
+
+**Physics-block preconditioning with explicit regularization.** The stiff
+pitch-direction blocks are factored per pencil by SVD and truncated below
+``\epsilon\,\sigma_{\max}`` — the deliberate treatment of the intrinsically
+near-singular trapped–passing matching block (`04 §3`; the prior art measured
+``\mathrm{rcond} \sim 10^{-16}`` there and got machine-dependent *noise, not
+crashes*, under plain LU). On a collision-dominated test solve the block-Jacobi
+preconditioner cuts the work by an order of magnitude:
+
+
+
+A companion diagnostic tracks the smallest singular value of the ``y_c``-block
+of the (tiny-grid, debug) dense Jacobian — ladder **A8** — so a silent
+conditioning regression is *tested for*, not observed.
+
+*Implementing symbols:* `Solvers.newton_krylov`, `Solvers.JVPOperator`,
+`Solvers.YBlockJacobi`, `Solvers.dense_jacobian`, `Verify.yc_block_sigma_min`.
+
+## 6. Continuation and fold detection
+
+Δ-surface generation, Newton globalization, and (at Level 3) the penetration
+bifurcation all ride on pseudo-arclength continuation, so fold handling is in
+from day one. The corrector solves the extended system
+
+```math
+G(z) = \begin{pmatrix} F(u, p) \\ t \cdot (z - z_{\text{pred}}) \end{pmatrix} = 0,
+\qquad z = (u, p),
+```
+
+with a secant tangent ``t`` and fold detection via sign reversal of the
+tangent's parameter component ``t_p``:
+
+
+
+*Implementing symbol:* `Solvers.pseudo_arclength`.
+
+## 7. Island geometry, the electron-closure functions, and the Δ moments
+
+The island flux-surface label is the pinned module convention (half-width
+``w``):
+
+```math
+\Omega(x, \xi) = \frac{2x^2}{w^2} - \cos\xi,
+\qquad \Omega = -1 \text{ at the O-point},\quad \Omega = +1 \text{ at the separatrix},
+```
+
+with the flux-surface average
+``\langle f \rangle_\Omega = \oint f\,(\Omega + \cos\xi)^{-1/2} d\xi \,/\,
+\oint (\Omega + \cos\xi)^{-1/2} d\xi`` — a *diagnostic* only, never a solve
+coordinate (Decision D1). The flattened-electron closure geometry is
+implemented as structure with a supplied amplitude:
+
+```math
+Q(\Omega) = \frac{1}{2\pi} \oint \sqrt{\Omega + \cos\xi}\; d\xi,
+\qquad
+h(\Omega) = \Theta(\Omega - 1)\; C \int_1^{\Omega} \frac{d\Omega'}{Q(\Omega')},
+```
+
+so ``h`` is exactly flat inside the separatrix. Because ``h'(\Omega) = C/Q``,
+the chain rule gives the **coefficient-free consistency identity** (ladder
+**A7** — the unit target that historically caught inherited bugs in this
+lineage):
+
+```math
+\Big\langle \frac{\partial^2 h}{\partial x^2} \Big\rangle_{\!\Omega}
+ = \frac{4}{w^2}\left[ h''(\Omega)\, \frac{Q}{Q'} \; +\; h'(\Omega) \right]
+ \;\equiv\; 0 ,
+```
+
+verified to ``10^{-16}`` for arbitrary amplitude ``C``:
+
+
+
+The output moments are the two Ampère projections of the species-summed
+parallel current ``\bar J_\parallel = \sum_j Z_j \int W_j\, g_j`` (`01 §4`):
+
+```math
+\Delta_{\cos} = C_{\cos} \int dx \oint d\xi\; \bar J_\parallel \cos\xi,
+\qquad
+\Delta_{\sin} = C_{\sin} \int dx \oint d\xi\; \bar J_\parallel \sin\xi,
+```
+
+where the ``\xi``-projection is spectrally exact on the periodic grid and the
+prefactors ``C_{\cos}, C_{\sin}`` (physically ``\mp\mu_0 R / 2\tilde\psi``) are
+**required, gated arguments** — ``\tilde\psi`` carries an open `[VERIFY]` and
+the sin normalization is `[DERIVED]`-unpinned (QUESTIONS Q4). The parity
+structure ``\Delta_{\cos}`` even / ``\Delta_{\sin}`` odd under ``\xi``-reflection
+is verified exactly (ladder **A3**).
+
+*Implementing symbols:* `Moments.parallel_current!`, `Moments.delta_moments`,
+`Moments.omega_average`, `Fields.Q_omega`, `Fields.h_profile`,
+`Fields.flat_average_d2h_dx2`.
+
+## 8. Verification evidence (the A-ladder, all green in CI)
+
+The manufactured-solution ladder verifies discretization order and the AD
+plumbing simultaneously — per operator, for the assembled residual, and through
+a full converged Newton solve forced by the analytic source:
+
+
+
+| Gate | Statement | Result |
+|---|---|---|
+| A1 | per-operator + assembled MMS at design order | 4th order (``\xi`` spectral to ``10^{-15}``) |
+| A1-solve | converged Newton solve recovers the manufactured state | observed order **3.98** |
+| A2 | AD Jacobian–vector product vs. central finite differences | agree to ``\sim 10^{-9}`` |
+| A3 | ``\Delta_{\cos}`` even / ``\Delta_{\sin}`` odd parity | exact |
+| A4 | particle conservation + entropy sign of the collision operator | exact (``10^{-14}`` / definite) |
+| A5 | zero-drive null: ``g \equiv 0 \Rightarrow R = 0`` | **exactly** machine zero |
+| A7 | ``\langle \partial^2 h / \partial x^2 \rangle_\Omega = 0`` | ``10^{-16}`` |
+| A8 | ``y_c``-block ``\sigma_{\min}`` monitor + singular detection | active |
+| — | allocation regression on every hot kernel | 0 bytes |
+
+Everything above regenerates from one pinned script
+(`benchmarks/islands/figures/make_structural_figures.jl`) and runs in the test
+suite (`test/runtests_islands_{grids,operators,solve}.jl`); the
+`islands_l0_structural` regression case tracks the headline numbers across
+commits.
+
+## 9. What comes next
+
+The physics story — the drift-kinetic coefficients, the York threshold triangle
+(DK-NTM ``8.73\,\rho_{bi}`` → RDK-NTM ``1.46\,\rho_{bi}``), the
+``\Delta_{\text{pol}}(\omega_E)`` sign-reversal curve — is written as the
+[Paper I figure contract](papers/paper-1/OUTLINE.md) and un-gates claim by
+claim as the human clearance queue (`QUESTIONS.md` Q2–Q4) is worked through.
+The [design documents](design/00-roadmap.md) hold the full eight-milestone
+program.
diff --git a/docs/src/islands/papers/paper-1/OUTLINE.md b/docs/src/islands/papers/paper-1/OUTLINE.md
new file mode 100644
index 00000000..bfc3704d
--- /dev/null
+++ b/docs/src/islands/papers/paper-1/OUTLINE.md
@@ -0,0 +1,44 @@
+# Paper I — OUTLINE (the Level-0 figure contract)
+
+> Created at level *start* per design doc `07 §3`: claims → figures → ladder
+> IDs. This outline is the figure contract — agents implementing benchmarks
+> know which figures are paper figures from day one. Every claim must be backed
+> by a ladder ID; claims lacking one are flagged. Status reflects the
+> `[VERIFY]` gating of `docs/src/islands/QUESTIONS.md` (Q2–Q4) — **no
+> submission until every tag in the paper's equation set is cleared**.
+
+**Working title:** Formulation and verification of a generalized drift-kinetic
+solver for magnetic-island stability (Islands, Level 0).
+
+**Indicative venue:** Phys. Plasmas (methods paper). **Gate:** Level 0
+(design `00`), i.e. ladder A + B1/B2/B4/B5a–c green with convergence artifacts.
+
+## Claims and figures
+
+| # | Claim | Figure(s) | Ladder ID(s) | Status |
+|---|---|---|---|---|
+| C1 | The `(x, ξ; y, E, σ)` discretization converges at design order: spectral in `ξ`, 4th-order FD on layer-clustered `x`/`y` grids, per operator and for the assembled solve | F1: MMS convergence panels (per-operator + assembled residual + assembled solve error vs. resolution) | A1, A2 | **green** (M1/M2; CI artifacts) |
+| C2 | The steady-state Newton–Krylov solve is exact-Jacobian (AD), globally convergent from zero states, and its conditioning is monitored at the trapped–passing boundary (no silent-noise regime, contra L23 §4.2) | F2: Newton/GMRES convergence histories with and without the physics-block preconditioner; `σ_min(y_c)` track | A5, A8 (+ solver gates) | **green** (M2) |
+| C3 | The discretized collision operator conserves particles exactly and has definite entropy sign — bootstrap-relevant structure holds discretely, not just asymptotically | F3: conservation/entropy residuals vs. resolution and profile | A4 | **green** (L0 parts, M2) |
+| C4 | In the no-island limit the solver reproduces standard local neoclassics (bootstrap current vs. Sauter/NEO) — the strongest global check of the velocity-space discretization | F4: `J_bs` vs. `ν_★` against NEO/Sauter | B1 | **gated** — needs cleared L0 coefficient set (Q2, Q3) + external NEO runs |
+| C5 | At large `w` the solver recovers the analytic MRE limits: `Δ_bs + Δ_cur ∝ 1/w` (WCHH96 Eq. 85, frame-mapped) and `Δ_pol ∝ 1/w³` | F5: `Δ` channels vs. `w` with analytic asymptotes | B2 | **gated** — Q2, Q3, Q4 (WCHH96 acquisition; `ψ̃` [VERIFY]) |
+| C6 | The three-code threshold triangle is reproduced in its exact configurations: DK-NTM `w_c ≃ 2.76 ρ_θi ≡ 8.73 ρ_bi` (`:original` drift model), RDK-NTM `w_c ≈ 0.45 ρ_θi ≡ 1.46 ρ_bi` (`:improved`), kokuchou's finite-`ν_★` surface `w_c(ρ̂_θi, ν_★)` — and the 8.73 → 1.46 shift is a single drift-model toggle (E1) | F6: threshold `w_c` vs. configuration (the triangle); F7: the E1 toggle scan | B5a, B5b, B5c (E1) | **gated** — Q2 (D7/D8), Q3, Q4 (B5a collisionality) |
+| C7 | The polarization contribution is frame-pinned: `Δ_pol ∝ ω_E²` away from zero with sign reversal at `ω_E ≈ −0.89 ω_dia,e`, insensitive to `w/ρ_θi`; single-`ω_E` `Δ` values are misleading (surfaces over `(w, ω_E)` are the deliverable) | F8: `Δ_pol(ω_E)` reversal curve vs. D23b Fig. 8; F9: `Δ(w, ω_E)` surface | B4 | **gated** — Q2, Q3 (frame-convention signs) |
+| C8 (candidate headline) | Resolution of L23's open question: the stabilizing *electron* `Δ_pol` at `ω_E = 0` — reproduced or refuted by the `ω_E` scan with kinetic vs. flattened electrons (E4) | F10: electron-channel `Δ_pol(ω_E)` decomposition | B4 + E4 | **gated** — Q2, Q3; kinetic-electron toggle is M2+/E4 work |
+
+## Verification-artifact rules (docs/05 reporting)
+
+- Every figure names its configuration (docs/03 §2) and git SHA; no benchmark
+ "passes" on a single grid — convergence + tolerance archived with the result.
+- Threshold numbers are reported as **half-widths** with both `ρ_θi` and
+ `ρ_bi = ε^{1/2} ρ_θi` stated at the run's `ε` (docs/05 rule 5).
+- Disagreements with published targets are triaged per the standing rule
+ (docs/05: our bug / their approximation / their published-equation error /
+ transcription error) with `[VERIFY]` resolution logged first.
+
+## Dependencies for un-gating (the human clearance queue)
+
+`QUESTIONS.md` **Q2** (ratify D7/D8), **Q3** (clear the L0 `[CHECKED]`
+coefficient set), **Q4** (resolve the `ψ̃` and B5a-collisionality `[VERIFY]`s;
+acquire WCHH96 + Park 2022). C1–C3 are already green as CI artifacts; C4–C8
+un-gate in that order of effort once the queue clears.
diff --git a/regression-harness/cases/islands_l0_structural.toml b/regression-harness/cases/islands_l0_structural.toml
new file mode 100644
index 00000000..091b4949
--- /dev/null
+++ b/regression-harness/cases/islands_l0_structural.toml
@@ -0,0 +1,60 @@
+# Regression case: Islands L0 solve machinery, structural (pre-physics) quantities.
+# A "computed" case (kind = "computed", no example_dir) tracking the M2 structural
+# gates: the solve-level MMS error and solver iteration counts (Newton-Krylov on the
+# manufactured advective stack at nx = 17), the coefficient-free flattened-electron
+# closure identity |_Omega| at Omega = 2, and the y_c-block smallest singular
+# value (the L23 SS4.2 silent-noise tripwire). The physics B-ladder stays skipped until
+# [VERIFY] clearance (docs/src/islands/QUESTIONS.md Q2-Q4); these are the numbers that
+# silently move if the discretization, solver, or quadratures drift.
+[case]
+name = "islands_l0_structural"
+description = "Islands L0 structural: solve-MMS error, solver iterations, A7 identity, y_c sigma_min"
+kind = "computed"
+
+[quantities.solve_mms_err]
+h5path = "islands/solve_mms_err"
+type = "real_scalar"
+extract = "value"
+label = "solve-MMS max error (nx=17)"
+noise_threshold = 1e-8
+order = 10
+
+[quantities.newton_iters]
+h5path = "islands/newton_iters"
+type = "real_scalar"
+extract = "value"
+label = "Newton iterations"
+noise_threshold = 0.0
+order = 11
+
+[quantities.gmres_iters]
+h5path = "islands/gmres_iters"
+type = "real_scalar"
+extract = "value"
+label = "GMRES iterations (total)"
+noise_threshold = 0.0
+order = 12
+
+[quantities.a7_identity]
+h5path = "islands/a7_identity"
+type = "real_scalar"
+extract = "value"
+label = "|_Omega| (A7)"
+noise_threshold = 1e-12
+order = 13
+
+[quantities.yc_sigma_min]
+h5path = "islands/yc_sigma_min"
+type = "real_scalar"
+extract = "value"
+label = "y_c block sigma_min (A8)"
+noise_threshold = 1e-10
+order = 14
+
+[quantities.runtime]
+h5path = ""
+type = "runtime"
+extract = "value"
+label = "Runtime (s)"
+noise_threshold = 0.0
+order = 90
diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl
index 8705cb4d..d6aa9b21 100644
--- a/regression-harness/src/runner.jl
+++ b/regression-harness/src/runner.jl
@@ -2,7 +2,9 @@
Runner: orchestrates checking out commits, running GPEC, and extracting results.
"""
-"""Read the GPEC-only runtime from the timing file written by the subprocess."""
+"""
+Read the GPEC-only runtime from the timing file written by the subprocess.
+"""
function _read_timing_file(path::String)::Float64
if isfile(path)
return parse(Float64, strip(read(path, String)))
@@ -29,7 +31,7 @@ function _materialize_rundir(example_path::String, overrides::Dict{String,Any})
for (dotted, val) in overrides
ks = split(dotted, ".")
d = cfg
- for k in ks[1:(end - 1)]
+ for k in ks[1:(end-1)]
d = get!(d, k, Dict{String,Any}())
end
d[ks[end]] = val
@@ -109,28 +111,60 @@ open(ARGS[2], "w") do f
end
"""
+# Islands L0 structural regression: the solve-level MMS (Newton-Krylov recovers
+# the manufactured state), the coefficient-free A7 closure identity, and the A8
+# y_c-block conditioning monitor. Tracks the structural (pre-physics) numbers
+# that would silently move if the discretization, solver, or quadratures drift;
+# the physics B-ladder stays skipped until [VERIFY] clearance (QUESTIONS Q2-Q4).
+const COMPUTED_ISLANDS_SCRIPT_TEMPLATE = """
+using Pkg
+%INSTANTIATE%
+using GeneralizedPerturbedEquilibrium
+using HDF5
+const Isl = GeneralizedPerturbedEquilibrium.Islands
+t_start = time()
+r = Isl.Verify.solve_mms(17)
+a7 = Isl.Fields.flat_average_d2h_dx2(2.0, 1.0)
+grid = Isl.PhaseSpace.IslandGrid(nx=7, nxi=8, ny=7, nE=2, halfwidth_x=6.0, clustering_x=1.0,
+ y_max=4.0, y_c=1.0, clustering_y=0.8, order=4)
+setup = Isl.Verify.zero_drive_setup(grid)
+J = Isl.Solvers.dense_jacobian(setup.f, zeros(setup.N))
+mon = Isl.Verify.yc_block_sigma_min(J, grid)
+elapsed = time() - t_start
+h5open(ARGS[1], "w") do fid
+ fid["islands/solve_mms_err"] = r.err
+ fid["islands/newton_iters"] = Float64(r.iterations)
+ fid["islands/gmres_iters"] = Float64(r.gmres_iters)
+ fid["islands/a7_identity"] = abs(a7)
+ fid["islands/yc_sigma_min"] = mon.sigma_min
+end
+open(ARGS[2], "w") do f
+ println(f, elapsed)
+end
+"""
+
"""
Run GPEC for a single commit/ref and case. Dispatches to run_local for
the working tree or run_at_commit for a git ref.
"""
function run_commit(db::SQLite.DB, commit_hash::String, ref_name::String,
- case_spec::CaseSpec, repo_root::String;
- force::Bool=false, verbose::Bool=false,
- no_instantiate::Bool=false)
+ case_spec::CaseSpec, repo_root::String;
+ force::Bool=false, verbose::Bool=false,
+ no_instantiate::Bool=false)
if case_spec.kind == "computed"
if commit_hash == LOCAL_REF
return run_computed_local(db, case_spec, repo_root;
- verbose=verbose, no_instantiate=no_instantiate)
+ verbose=verbose, no_instantiate=no_instantiate)
end
return run_computed_at_commit(db, commit_hash, ref_name, case_spec, repo_root;
- force=force, verbose=verbose, no_instantiate=no_instantiate)
+ force=force, verbose=verbose, no_instantiate=no_instantiate)
end
if commit_hash == LOCAL_REF
return run_local(db, case_spec, repo_root;
- force=force, verbose=verbose, no_instantiate=no_instantiate)
+ force=force, verbose=verbose, no_instantiate=no_instantiate)
end
return run_at_commit(db, commit_hash, ref_name, case_spec, repo_root;
- force=force, verbose=verbose, no_instantiate=no_instantiate)
+ force=force, verbose=verbose, no_instantiate=no_instantiate)
end
"""
@@ -141,6 +175,8 @@ function _computed_script_template(case_spec::CaseSpec)
return COMPUTED_GGJ_SCRIPT_TEMPLATE
elseif case_spec.name == "efit_fixedbdy_separatrix"
return COMPUTED_SEPARATRIX_SCRIPT_TEMPLATE
+ elseif case_spec.name == "islands_l0_structural"
+ return COMPUTED_ISLANDS_SCRIPT_TEMPLATE
end
error("No computed-script template registered for case '$(case_spec.name)'")
end
@@ -153,11 +189,11 @@ import GeneralizedPerturbedEquilibrium), reads the resulting tempfile h5 with
so callers can handle store_failed_run uniformly.
"""
function _execute_computed(case_spec::CaseSpec, project_root::String;
- verbose::Bool, no_instantiate::Bool,
- stderr_buf::IO)
+ verbose::Bool, no_instantiate::Bool,
+ stderr_buf::IO)
instantiate_line = no_instantiate ? "" : "Pkg.instantiate()"
script_content = replace(_computed_script_template(case_spec),
- "%INSTANTIATE%" => instantiate_line)
+ "%INSTANTIATE%" => instantiate_line)
tmpscript = tempname() * ".jl"
h5path = tempname() * ".h5"
timingfile = tempname() * ".timing"
@@ -166,8 +202,8 @@ function _execute_computed(case_spec::CaseSpec, project_root::String;
if verbose
run(pipeline(`julia --project=$project_root $tmpscript $h5path $timingfile`))
else
- run(pipeline(`julia --project=$project_root $tmpscript $h5path $timingfile`,
- stdout=devnull, stderr=stderr_buf))
+ run(pipeline(`julia --project=$project_root $tmpscript $h5path $timingfile`;
+ stdout=devnull, stderr=stderr_buf))
end
runtime_s = _read_timing_file(timingfile)
if !isfile(h5path)
@@ -186,18 +222,18 @@ end
Run a kind="computed" case against the working tree.
"""
function run_computed_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String;
- verbose::Bool=false, no_instantiate::Bool=false)
+ verbose::Bool=false, no_instantiate::Bool=false)
delete_cached(db, LOCAL_REF, case_spec.name)
date = Dates.format(Dates.now(), "yyyy-mm-ddTHH:MM:SS")
@info "Running: $(case_spec.name) @ local (working tree, computed)"
stderr_buf = IOBuffer()
try
extracted, runtime_s = _execute_computed(case_spec, repo_root;
- verbose=verbose,
- no_instantiate=no_instantiate,
- stderr_buf=stderr_buf)
+ verbose=verbose,
+ no_instantiate=no_instantiate,
+ stderr_buf=stderr_buf)
store_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name,
- runtime_s, extracted)
+ runtime_s, extracted)
@info " Completed in $(round(runtime_s, digits=3))s — $(length(extracted)) quantities extracted"
catch e
err_msg = if e isa ProcessFailedException
@@ -212,7 +248,7 @@ function run_computed_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::Strin
err_msg_short = length(err_msg) > 2000 ? "..." * last(err_msg, 2000) : err_msg
@warn "Run failed (local computed): $(first(err_msg_short, 200))"
store_failed_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name,
- err_msg_short)
+ err_msg_short)
end
end
@@ -220,9 +256,9 @@ end
Run a kind="computed" case at a specific git commit via worktree.
"""
function run_computed_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String,
- case_spec::CaseSpec, repo_root::String;
- force::Bool=false, verbose::Bool=false,
- no_instantiate::Bool=false)
+ case_spec::CaseSpec, repo_root::String;
+ force::Bool=false, verbose::Bool=false,
+ no_instantiate::Bool=false)
if !force && is_cached(db, commit_hash, case_spec.name)
info = get_run_info(db, commit_hash, case_spec.name)
if info !== nothing
@@ -243,11 +279,11 @@ function run_computed_at_commit(db::SQLite.DB, commit_hash::String, ref_name::St
try
worktree_path = create_worktree(commit_hash, repo_root)
extracted, runtime_s = _execute_computed(case_spec, worktree_path;
- verbose=verbose,
- no_instantiate=no_instantiate,
- stderr_buf=stderr_buf)
+ verbose=verbose,
+ no_instantiate=no_instantiate,
+ stderr_buf=stderr_buf)
store_run(db, commit_hash, commit_info.short, commit_info.date,
- commit_info.msg, case_spec.name, runtime_s, extracted)
+ commit_info.msg, case_spec.name, runtime_s, extracted)
@info " Completed in $(round(runtime_s, digits=3))s — $(length(extracted)) quantities extracted"
catch e
err_msg = if e isa ProcessFailedException
@@ -262,7 +298,7 @@ function run_computed_at_commit(db::SQLite.DB, commit_hash::String, ref_name::St
err_msg_short = length(err_msg) > 2000 ? "..." * last(err_msg, 2000) : err_msg
@warn "Run failed (computed) for $(commit_info.short): $(first(err_msg_short, 200))"
store_failed_run(db, commit_hash, commit_info.short, commit_info.date,
- commit_info.msg, case_spec.name, err_msg_short)
+ commit_info.msg, case_spec.name, err_msg_short)
finally
if worktree_path !== nothing
remove_worktree(worktree_path, repo_root)
@@ -275,8 +311,8 @@ Run GPEC in the current working tree (uncommitted changes included).
Always re-runs (local results are never cached since the working tree is mutable).
"""
function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String;
- force::Bool=false, verbose::Bool=false,
- no_instantiate::Bool=false)
+ force::Bool=false, verbose::Bool=false,
+ no_instantiate::Bool=false)
# Always delete previous local results and re-run
delete_cached(db, LOCAL_REF, case_spec.name)
@@ -287,7 +323,7 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String;
if !isdir(example_path)
@warn "Example directory not found: $(case_spec.example_dir)"
store_failed_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name,
- "Example directory not found: $(case_spec.example_dir)")
+ "Example directory not found: $(case_spec.example_dir)")
return
end
@@ -309,8 +345,8 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String;
if verbose
run(pipeline(`julia --project=$repo_root $tmpscript $rundir $timingfile`))
else
- run(pipeline(`julia --project=$repo_root $tmpscript $rundir $timingfile`,
- stdout=devnull, stderr=stderr_buf))
+ run(pipeline(`julia --project=$repo_root $tmpscript $rundir $timingfile`;
+ stdout=devnull, stderr=stderr_buf))
end
runtime_s = _read_timing_file(timingfile)
@@ -318,13 +354,13 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String;
if !isfile(h5path)
@warn "gpec.h5 not produced"
store_failed_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name,
- "gpec.h5 not produced after successful run")
+ "gpec.h5 not produced after successful run")
return
end
extracted = extract_quantities(h5path, case_spec.quantities, runtime_s)
store_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name,
- runtime_s, extracted)
+ runtime_s, extracted)
@info " Completed in $(round(runtime_s, digits=1))s — $(length(extracted)) quantities extracted"
@@ -341,7 +377,7 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String;
err_msg_short = length(err_msg) > 2000 ? "..." * last(err_msg, 2000) : err_msg
@warn "Run failed (local): $(first(err_msg_short, 200))"
store_failed_run(db, LOCAL_REF, "local", date, "working tree", case_spec.name,
- err_msg_short)
+ err_msg_short)
finally
if tmpscript !== nothing
rm(tmpscript; force=true)
@@ -360,9 +396,9 @@ Run GPEC for a specific git commit via worktree. Stores results in the database.
Skips if already cached (unless force=true).
"""
function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String,
- case_spec::CaseSpec, repo_root::String;
- force::Bool=false, verbose::Bool=false,
- no_instantiate::Bool=false)
+ case_spec::CaseSpec, repo_root::String;
+ force::Bool=false, verbose::Bool=false,
+ no_instantiate::Bool=false)
# Check cache
if !force && is_cached(db, commit_hash, case_spec.name)
info = get_run_info(db, commit_hash, case_spec.name)
@@ -398,8 +434,8 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String,
if !isdir(example_path)
@warn "Example directory not found at commit $(commit_info.short): $(case_spec.example_dir)"
store_failed_run(db, commit_hash, commit_info.short, commit_info.date,
- commit_info.msg, case_spec.name,
- "Example directory not found: $(case_spec.example_dir)")
+ commit_info.msg, case_spec.name,
+ "Example directory not found: $(case_spec.example_dir)")
return
end
@@ -418,8 +454,8 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String,
if verbose
run(pipeline(`julia --project=$project_root $tmpscript $rundir $timingfile`))
else
- run(pipeline(`julia --project=$project_root $tmpscript $rundir $timingfile`,
- stdout=devnull, stderr=stderr_buf))
+ run(pipeline(`julia --project=$project_root $tmpscript $rundir $timingfile`;
+ stdout=devnull, stderr=stderr_buf))
end
runtime_s = _read_timing_file(timingfile)
@@ -428,8 +464,8 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String,
if !isfile(h5path)
@warn "gpec.h5 not produced at $(commit_info.short)"
store_failed_run(db, commit_hash, commit_info.short, commit_info.date,
- commit_info.msg, case_spec.name,
- "gpec.h5 not produced after successful run")
+ commit_info.msg, case_spec.name,
+ "gpec.h5 not produced after successful run")
return
end
@@ -438,7 +474,7 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String,
# Store in database
store_run(db, commit_hash, commit_info.short, commit_info.date,
- commit_info.msg, case_spec.name, runtime_s, extracted)
+ commit_info.msg, case_spec.name, runtime_s, extracted)
@info " Completed in $(round(runtime_s, digits=1))s — $(length(extracted)) quantities extracted"
@@ -456,7 +492,7 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String,
err_msg_short = length(err_msg) > 2000 ? "..." * last(err_msg, 2000) : err_msg
@warn "Run failed for $(commit_info.short): $(first(err_msg_short, 200))"
store_failed_run(db, commit_hash, commit_info.short, commit_info.date,
- commit_info.msg, case_spec.name, err_msg_short)
+ commit_info.msg, case_spec.name, err_msg_short)
finally
# Clean up
if tmpscript !== nothing
diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl
index e7ecef8a..26750e78 100755
--- a/src/GeneralizedPerturbedEquilibrium.jl
+++ b/src/GeneralizedPerturbedEquilibrium.jl
@@ -48,6 +48,10 @@ include("Analysis/Analysis.jl")
import .Analysis as Analysis
export Analysis
+include("Islands/Islands.jl")
+import .Islands as Islands
+export Islands
+
include("Rerun.jl")
# Import ForceFreeStates types and functions needed for main
diff --git a/src/Islands/CLAUDE.md b/src/Islands/CLAUDE.md
new file mode 100644
index 00000000..61ce2cc7
--- /dev/null
+++ b/src/Islands/CLAUDE.md
@@ -0,0 +1,156 @@
+# CLAUDE.md — Islands module conventions
+
+Islands is a steady-state, multi-species drift-kinetic solver for the resonant
+island/layer region in tokamaks, generalizing the Modified Rutherford Equation
+the way SLAYER generalized linear layer theory. It is a GPEC submodule
+(`src/Islands/`, `module Islands`), not a standalone package.
+
+**Where things live** (in-repo layout, reconciled from the original standalone
+design bundle):
+
+- Module source: `src/Islands/` (this file governs work here).
+- Design docs (normative, aspirational): `docs/src/islands/design/00-roadmap.md`
+ … `08-reference-library.md`. Throughout the docs and code, the shorthand
+ `docs/NN §X` (e.g. `docs/03 §2`) means design doc `NN` — read it as
+ `docs/src/islands/design/NN-*.md`.
+- Physics Book (as-implemented equations, rendered by Documenter):
+ `docs/src/islands/` chapters. Derivations: `docs/src/islands/derivations/`.
+ Papers: `docs/src/islands/papers/`. State dashboard + gallery:
+ `docs/src/islands/state/`. Notes: `docs/src/islands/notes/`.
+- Session memory / blocker queue: `docs/src/islands/LOG.md`,
+ `docs/src/islands/QUESTIONS.md`.
+- Tests: `test/runtests_islands_.jl`, included from `test/runtests.jl`.
+- Benchmarks + figures: `benchmarks/islands/`, `benchmarks/islands/figures/`.
+- Regression cases: integrated with the rest under `regression-harness/`.
+
+Read `docs/src/islands/index.md`, then `docs/src/islands/design/00-roadmap.md`.
+The design docs are **normative**: code must not contradict them; when physics
+or design must change, amend the doc in the same PR (doc-first workflow) and
+append to the Decision Log in `docs/src/islands/design/00-roadmap.md`.
+
+## The [VERIFY] policy (most important rule)
+
+Equations and numeric targets transcribed from literature carry `[VERIFY: source]`
+tags in docs and in code comments. Rules:
+
+1. Never implement physics against a [VERIFY]-tagged expression as if it were
+ confirmed. Implement the *structure*, parameterize the uncertain coefficient,
+ and add a failing/skipped benchmark referencing the tag.
+2. Never silently "fix" a coefficient to make a benchmark pass. Flag the
+ discrepancy for human review with the source citation.
+3. Only a human clears a [VERIFY] tag, after checking the source paper. Record
+ the clearance (paper, equation number) in the doc.
+4. If you (Claude) derive an expression yourself, mark it `[DERIVED: date]` with
+ the derivation in `docs/src/islands/derivations/` — never present a
+ derivation as a literature transcription or vice versa.
+5. `[CHECKED: source, Eq./p.]` is the intermediate state: the expression was
+ transcribed from a PDF in the in-repo reference library (docs/08) with an
+ exact equation/page cite and machine-checked against it, but has not yet
+ received the human sign-off of rule 3. [CHECKED] expressions may guide
+ design but are implemented under the same rule-1 discipline as [VERIFY].
+6. **The policy is not paranoia — it is calibrated to this literature.** Leigh
+ 2023 §2.6 documents concrete coefficient/sign errors in the *published*
+ Imada 2019 equation set (docs/01 header). Published equations in this
+ lineage are re-derived before implementation, full stop.
+
+## Physics conventions (pinned; changing any of these is a Decision Log entry)
+
+- Island width `w` = **half**-width; Ω = 2x²/w² − cos ξ; O-point Ω = −1,
+ separatrix Ω = +1. (Matches every source in the York lineage — [CHECKED:
+ I19 Eq. 7; Diss19 Eq. 2.7; L23 Eq. 2.1.8]. Thresholds are always reported
+ as half-widths with the gyroradius unit stated; docs/05 reporting rule 5.)
+- Primary field representation: A_∥(x, ξ) on the grid. Island coordinates Ω are
+ diagnostics only (Decision D1). Any PR introducing Ω as a solve coordinate
+ outside the RDK cross-check mode is rejected.
+- All frame/frequency conversions live in `src/Islands/frames/`. No other part
+ of the module may contain an ω sign convention. The polarization-current sign
+ disputes in the literature are largely frame disputes; we will not reproduce
+ them internally.
+- Normalizations per `docs/01-physics-level0.md §5`. SI only at I/O boundaries.
+- Species lists are first-class everywhere; no function may assume a single ion
+ species (Decision D3). Trace species go through the linear post-pass
+ (`docs/02 §1.2`), with trace-criteria checks that warn, never silently degrade.
+
+## Code conventions
+
+- Julia. Style: 4-space indent, explicit imports, no `using X` wildcard in
+ `src/Islands/`. Public API documented with docstrings; physics functions cite
+ the doc section they implement (`# docs/01 §2.1`).
+- Operator stack rules (`docs/03 §2`): terms are independent (no term inspects
+ other terms), generic over `eltype` (AD compatibility is tested), and
+ allocation-free in `apply!` hot paths (allocation regression test in CI).
+- No regime-specific branches in physics code. If you find yourself writing
+ `if collisional ... else banana ...` inside an operator, stop: that is the
+ disease this project treats. Regime logic is allowed only in
+ `src/Islands/verify/` (choosing analytic comparison targets) and in
+ preconditioners (approximations there are legitimate).
+- Threading: per-thread preallocated caches; no shared mutable state in kernels.
+
+## Testing gates
+
+- `test/runtests_islands_*.jl` (included from `test/runtests.jl`): fast unit +
+ symmetry + conservation + MMS-at-low-resolution + AD checks. Must pass on
+ every commit.
+- `benchmarks/islands/`: the docs/05 ladder. CI runs the fast subset; the full
+ ladder runs before any tag/paper. A PR that changes physics must state which
+ ladder IDs it affects and show them green (or explicitly re-baselined with
+ justification).
+- New operators/terms ship with: MMS test, at least one analytic-limit hook in
+ `src/Islands/verify/`, and an AD-compatibility test.
+
+## Merge conflict policy
+
+Synthesize both branch sides rather than choosing one; preserve current-branch
+naming conventions; flag ambiguous cases for human review rather than guessing.
+Conflicts touching the design docs or physics conventions always go to human
+review.
+
+## Documentation policy (docs/07)
+
+Documentation is a build artifact; stale docs are broken builds.
+
+- Any PR changing physics behavior updates the corresponding Physics Book
+ section (`docs/src/islands/`) **in the same PR**, and regenerates affected
+ verification figures if outputs change. Otherwise the PR description carries
+ `docs-not-needed:` with justification.
+- Bidirectional anchors are mandatory: every operator cites its Physics Book
+ section (`# physics: #`); every Physics Book equation block
+ names its implementing symbol. The anchor-sync CI check must stay green.
+- The Physics Book documents equations **as implemented** (code normalization,
+ code sign conventions). Aspirational physics belongs in the design docs
+ (`docs/src/islands/design/`, docs 00–02).
+- Never hand-edit `docs/src/islands/state/STATE.md` or gallery pages — they
+ regenerate from benchmark artifacts.
+- Paper figures are generated only by pinned scripts in
+ `benchmarks/islands/figures/` reading archived benchmark data; a figure that
+ can't be regenerated by `make figures` is a release-blocking bug.
+- Each level begins with the paper OUTLINE.md (claims → figures → ladder IDs);
+ implementing agents treat it as the figure contract.
+
+## Autonomous-session protocol (docs/06)
+
+- Definition of done for any milestone run = its docs/05 ladder IDs green with
+ convergence artifacts + CI passing + PR opened. Never weaken a tolerance or
+ re-baseline a target to reach done — that is a blocker.
+- When blocked on anything CLAUDE.md forbids guessing ([VERIFY] clearances,
+ coefficients, signs, conventions, doc contradictions): append an entry to
+ `docs/src/islands/QUESTIONS.md` (context, question, options, recommendation,
+ gated work) and continue with the next unblocked task. Never stall waiting for
+ a human; never resolve silently.
+- Read `docs/src/islands/LOG.md` and `docs/src/islands/QUESTIONS.md` at session
+ start; append a LOG.md entry (what moved / blocked / next) before session end.
+ End every session with the branch pushed.
+- This file governs work inside `src/Islands/` (code) and `docs/src/islands/`
+ (docs); the repo-root CLAUDE.md governs GPEC-wide conventions. On conflict the
+ repo-root file wins; flag genuine contradictions via QUESTIONS.md.
+- GPD (`/gpd:*` commands, if installed) is for derivation/verification/
+ literature tasks feeding `docs/src/islands/derivations/` and [VERIFY]
+ proposals — not for implementation work, which follows this file and
+ docs/03–05.
+
+## What to do when uncertain
+
+Prefer: (a) parameterize and flag, (b) add a skipped test documenting the
+uncertainty, (c) ask — in that order. Do not guess coefficients, sign
+conventions, or normalizations; in this project those are the entire failure
+mode of the field we're trying to fix.
diff --git a/src/Islands/Islands.jl b/src/Islands/Islands.jl
new file mode 100644
index 00000000..9fec2f9e
--- /dev/null
+++ b/src/Islands/Islands.jl
@@ -0,0 +1,44 @@
+"""
+ Islands
+
+Steady-state, multi-species drift-kinetic solver for the resonant island/layer
+region in tokamaks. Generalizes the Modified Rutherford Equation the way SLAYER
+generalized linear layer theory: it returns the growth moment `Δ_cos(w, ω; p)`
+and torque moment `Δ_sin(w, ω; p)` for arbitrary parameters, and in its
+small-amplitude limit reduces to the linear layer response.
+
+Design docs live in `docs/src/islands/` (module conventions in
+`src/Islands/CLAUDE.md`, design docs in `docs/src/islands/design/`). Physics
+equations transcribed from the York drift-kinetic lineage carry `[VERIFY]` /
+`[CHECKED]` tags until human-cleared — see the module CLAUDE.md.
+
+Status: skeleton. The operator stack, phase-space grids, field/moment assembly,
+solvers, and verification harness (design doc `03-architecture.md §1`) land as
+milestone M1 proceeds.
+"""
+module Islands
+
+# Submodule layout follows docs/src/islands/design/03-architecture.md §1.
+# M1 landed the discretization + operator stack + MMS/AD harness; M2 lands the
+# L0 solve machinery (species, frames, solve, fields, moments) as gated
+# structure. Remaining design dirs (geometry/, closures/, io/) arrive with the
+# milestones that need them (L2/L4/M2-io).
+include("phasespace/PhaseSpace.jl") # grids (x, ξ, λ→y, E, σ), layer-clustered maps
+include("species/Species.jl") # Species, backgrounds, roles (D3)
+include("frames/Frames.jl") # THE frequency/frame conversion module (gated forms)
+include("operators/Operators.jl") # the AbstractTerm stack + residual assembly
+include("fields/Fields.jl") # quasineutrality closure structure, h(Ω)/Q(Ω)
+include("moments/Moments.jl") # J̄_∥, Δ_cos/Δ_sin projections, ⟨·⟩_Ω diagnostics
+include("solvers/Solvers.jl") # Newton–Krylov, preconditioner, continuation
+include("verify/Verify.jl") # MMS + AD-vs-FD JVP harness, y_c monitor
+
+import .PhaseSpace
+import .SpeciesLists
+import .Frames
+import .Operators
+import .Fields
+import .Moments
+import .Solvers
+import .Verify
+
+end # module Islands
diff --git a/src/Islands/fields/Fields.jl b/src/Islands/fields/Fields.jl
new file mode 100644
index 00000000..a050d988
--- /dev/null
+++ b/src/Islands/fields/Fields.jl
@@ -0,0 +1,143 @@
+"""
+ Fields
+
+The Level-0 field-equation layer (design `01 §3`, `03 §1`): quasineutrality is
+the only field equation at Level 0 (Ampère arrives at Level 3). The residual
+term itself lives in `Operators.Quasineutrality`; this module provides the
+closure *structure* around it:
+
+ - the island-geometry functions `Q(Ω)`, `h(Ω)` of the flattened-electron
+ (WCHH96-class) closure — implemented as **structure with a supplied
+ prefactor** (the physics prefactor `w_ψ/2√2` is `[CHECKED]`-uncleared,
+ QUESTIONS Q3);
+ - the coefficient-free consistency identity `⟨∂²h/∂x²⟩_Ω = 0` (ladder A7 —
+ kokuchou's unit set, which caught inherited DK-NTM bugs);
+ - [`ElectronClosure`](@ref) — the gated constant set of the closure, NaN-
+ poisoned until human-cleared.
+"""
+module Fields
+
+import QuadGK
+
+export Q_omega, dQ_domega, h_profile, dh_domega, d2h_domega2
+export flat_average_d2h_dx2, ElectronClosure, is_cleared
+
+"""
+ Q_omega(Ω; rtol=1e-10)
+
+`Q(Ω) = (1/2π) ∮ √(Ω + cos ξ) dξ` over the region where the radicand is
+positive (`01 §2.4` structure). For `Ω > 1` the integral covers the full
+period; for `|Ω| < 1` it runs between the turning points `cos ξ_b = −Ω`.
+"""
+function Q_omega(Ω; rtol::Real=1e-10)
+ if Ω > 1
+ val, _ = QuadGK.quadgk(ξ -> sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol)
+ return val / (2π)
+ elseif -1 < Ω <= 1
+ ξb = acos(-Ω)
+ val, _ = QuadGK.quadgk(ξ -> sqrt(max(Ω + cos(ξ), 0.0)), -ξb, ξb; rtol=rtol)
+ return val / (2π)
+ else
+ throw(DomainError(Ω, "Q_omega needs Ω > −1"))
+ end
+end
+
+"""
+ dQ_domega(Ω; rtol=1e-10)
+
+`dQ/dΩ = (1/4π) ∮ (Ω + cos ξ)^{−1/2} dξ` (differentiating `Q_omega` under the
+integral; the inside-separatrix endpoint singularity is integrable).
+"""
+function dQ_domega(Ω; rtol::Real=1e-10)
+ if Ω > 1
+ val, _ = QuadGK.quadgk(ξ -> 1.0 / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol)
+ return val / (4π)
+ elseif -1 < Ω < 1
+ ξb = acos(-Ω)
+ val, _ = QuadGK.quadgk(ξ -> 1.0 / sqrt(Ω + cos(ξ)), -ξb, ξb; rtol=rtol)
+ return val / (4π)
+ else
+ throw(DomainError(Ω, "dQ_domega needs Ω ∈ (−1, 1) ∪ (1, ∞)"))
+ end
+end
+
+"""
+ h_profile(Ω; prefactor, rtol=1e-10)
+
+The flattened-electron profile function *structure* `h(Ω) = Θ(Ω − 1) · prefactor · ∫₁^Ω dΩ′/Q(Ω′)` (`01 §2.4`): exactly flat inside the separatrix,
+`→ x` far outside. The physics prefactor (`w_ψ/2√2` in the sources) is
+`[CHECKED]`-uncleared (QUESTIONS Q3) and therefore **supplied**; the A7
+identity below is prefactor-independent.
+"""
+function h_profile(Ω; prefactor, rtol::Real=1e-10)
+ Ω <= 1 && return zero(float(Ω))
+ val, _ = QuadGK.quadgk(Ωp -> 1.0 / Q_omega(Ωp; rtol=rtol), 1.0, Ω; rtol=rtol)
+ return prefactor * val
+end
+
+"""
+ dh_domega(Ω; prefactor, rtol=1e-10)
+
+`h′(Ω) = prefactor / Q(Ω)` for `Ω > 1`, zero inside.
+"""
+dh_domega(Ω; prefactor, rtol::Real=1e-10) = Ω > 1 ? prefactor / Q_omega(Ω; rtol=rtol) : zero(float(Ω))
+
+"""
+ d2h_domega2(Ω; prefactor, rtol=1e-10)
+
+`h″(Ω) = −prefactor · Q′(Ω)/Q(Ω)²` for `Ω > 1`, zero inside.
+"""
+function d2h_domega2(Ω; prefactor, rtol::Real=1e-10)
+ Ω <= 1 && return zero(float(Ω))
+ Q = Q_omega(Ω; rtol=rtol)
+ return -prefactor * dQ_domega(Ω; rtol=rtol) / Q^2
+end
+
+"""
+ flat_average_d2h_dx2(Ω, w; prefactor=1.0, rtol=1e-10)
+
+The ladder-A7 identity integrand: `⟨∂²h/∂x²⟩_Ω` assembled from the chain rule
+`∂²h/∂x² = h″(Ω)(∂Ω/∂x)² + h′(Ω) ∂²Ω/∂x²` with `∂Ω/∂x = 4x/w²` on the surface
+(`(∂Ω/∂x)² = 8(Ω + cos ξ)/w²`), averaged with the `(Ω + cos ξ)^{−1/2}` weight.
+Analytically **exactly zero** for any prefactor — a coefficient-free
+consistency check of the `Q`, `h` quadratures and the `⟨·⟩_Ω` machinery
+(L23 Eq. 4.1.1-class unit target). Valid for `Ω > 1`.
+"""
+function flat_average_d2h_dx2(Ω, w; prefactor::Real=1.0, rtol::Real=1e-10)
+ Ω > 1 || throw(DomainError(Ω, "the A7 identity applies outside the separatrix (Ω > 1)"))
+ hp = dh_domega(Ω; prefactor=prefactor, rtol=rtol)
+ hpp = d2h_domega2(Ω; prefactor=prefactor, rtol=rtol)
+ num, _ = QuadGK.quadgk(ξ -> (hpp * 8 * (Ω + cos(ξ)) / w^2 + hp * 4 / w^2) / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol)
+ den, _ = QuadGK.quadgk(ξ -> 1.0 / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol)
+ return num / den
+end
+
+"""
+ ElectronClosure(; k_HS=NaN, f_p=NaN, h_prefactor=NaN, C_phi=NaN)
+
+The gated constant set of the Level-0 flattened-electron closure (`01 §2.4`,
+`§3`), all `[CHECKED]`-uncleared (QUESTIONS Q3) and **NaN-defaulted** so an
+un-cleared closure poisons results instead of committing to a guess:
+
+## Fields
+
+ - `k_HS` — the Hirshman–Sigmar flow coefficient (sources: `≃ −1.173`).
+ - `f_p` — the passing fraction form (sources: `≃ 1 − 1.46√ε`).
+ - `h_prefactor` — the `h(Ω)` amplitude (sources: `w_ψ/2√2`).
+ - `C_phi` — the quasineutrality closure coefficient (sources: `1/2L̂_{n0}`).
+"""
+Base.@kwdef struct ElectronClosure
+ k_HS::Float64 = NaN
+ f_p::Float64 = NaN
+ h_prefactor::Float64 = NaN
+ C_phi::Float64 = NaN
+end
+
+"""
+ is_cleared(ec::ElectronClosure)
+
+`true` only when every gated closure constant has been assigned (no NaN).
+"""
+is_cleared(ec::ElectronClosure) = !(isnan(ec.k_HS) || isnan(ec.f_p) || isnan(ec.h_prefactor) || isnan(ec.C_phi))
+
+end # module Fields
diff --git a/src/Islands/frames/Frames.jl b/src/Islands/frames/Frames.jl
new file mode 100644
index 00000000..079167d9
--- /dev/null
+++ b/src/Islands/frames/Frames.jl
@@ -0,0 +1,119 @@
+"""
+ Frames
+
+THE frequency/frame conversion module (design `01 §5`, module CLAUDE.md): no
+other part of Islands may contain an ω sign convention. The polarization-current
+sign disputes in the literature are largely frame disputes; this module owns the
+conversions so they cannot be reproduced inconsistently elsewhere.
+
+**Milestone-M2 status: forms only, signs gated.** The frame identities of
+`01 §5` are `[CHECKED: Diss19 pp. 46–48]` but not human-cleared (QUESTIONS Q3),
+so every sign/normalization here is a `FrameConvention` field with a **NaN
+default**: using an un-cleared convention poisons every downstream number
+instead of silently committing to a guess. Only the mechanical, sign-free
+bookkeeping (`frame_shift`, round-trips, parameter validation) is active.
+"""
+module Frames
+
+export Level0Parameters, FrameConvention
+export frame_shift, omega_dia_form, effective_dlnn_form, is_cleared
+
+"""
+ Level0Parameters(; w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau, nu_star)
+
+The Level-0 input parameter vector `p` (`01 §5`). Species-resolved quantities
+(gradients, `η_j`, backgrounds, roles) live on the species list; this struct
+carries the scalars.
+
+## Fields
+
+ - `w_hat` — island **half**-width `w/ρ_θi` (half-width convention pinned
+ in the module CLAUDE.md; thresholds are always reported as half-widths).
+ - `omega_E_hat` — `ω_E/ω_dia,e` (`≡ −ω₀/ω_dia,e`; a scanned input from day one,
+ ordering O4).
+ - `epsilon` — inverse aspect ratio `r_s/R₀`.
+ - `inv_Lq_hat` — `L̂_q⁻¹ = (ψ_s/q) dq/dψ` at `r_s`.
+ - `q_s` — safety factor at the rational surface.
+ - `tau` — `T_e/T_i`.
+ - `nu_star` — per-species banana collisionality `ν_★j`, keyed by species name.
+"""
+struct Level0Parameters
+ w_hat::Float64
+ omega_E_hat::Float64
+ epsilon::Float64
+ inv_Lq_hat::Float64
+ q_s::Float64
+ tau::Float64
+ nu_star::Dict{Symbol,Float64}
+ function Level0Parameters(w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau, nu_star)
+ w_hat > 0 || throw(ArgumentError("w_hat must be positive (half-width)"))
+ epsilon > 0 || throw(ArgumentError("epsilon must be positive"))
+ q_s > 0 || throw(ArgumentError("q_s must be positive"))
+ tau > 0 || throw(ArgumentError("tau must be positive"))
+ all(v -> v >= 0, values(nu_star)) || throw(ArgumentError("nu_star entries must be nonnegative"))
+ return new(w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau, Dict{Symbol,Float64}(nu_star))
+ end
+end
+
+function Level0Parameters(; w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau=1.0, nu_star=Dict{Symbol,Float64}())
+ return Level0Parameters(w_hat, omega_E_hat, epsilon, inv_Lq_hat, q_s, tau, nu_star)
+end
+
+"""
+ FrameConvention(; C_dia=NaN, sign_omega0=NaN, C_gradient_shift=NaN)
+
+The gated sign/normalization set of the frame identities (`01 §5`,
+`[CHECKED: Diss19 pp. 46–48]`, awaiting human clearance — QUESTIONS **Q3**).
+All fields default to **NaN** so an un-cleared convention poisons results
+rather than silently committing to a guess; `is_cleared` tests for this.
+
+## Fields
+
+ - `C_dia` — prefactor (incl. sign) of the electron diamagnetic
+ frequency form `ω_dia,e = C_dia · m T̂_e L̂_n⁻¹ / q_s`.
+ - `sign_omega0` — the island-propagation relation `ω₀ = sign_omega0 · ω_E`.
+ - `C_gradient_shift` — prefactor of the frame shift of the effective density
+ gradient, `L_n⁻¹ = L_{n0}⁻¹ (1 + C_gradient_shift · Z_j ω_E/ω_dia,e)`.
+"""
+Base.@kwdef struct FrameConvention
+ C_dia::Float64 = NaN
+ sign_omega0::Float64 = NaN
+ C_gradient_shift::Float64 = NaN
+end
+
+"""
+ is_cleared(conv::FrameConvention)
+
+`true` only when every gated field has been assigned (no NaN). Downstream code
+must check this before producing physics numbers.
+"""
+is_cleared(conv::FrameConvention) = !(isnan(conv.C_dia) || isnan(conv.sign_omega0) || isnan(conv.C_gradient_shift))
+
+"""
+ frame_shift(omega, omega_E)
+
+The frame-invariant combination `ω − ω_E` (`01 §5`): mechanical bookkeeping,
+no sign convention (the invariance is what *defines* the shift).
+"""
+frame_shift(omega, omega_E) = omega - omega_E
+
+"""
+ omega_dia_form(m_mode, T_hat, inv_Ln_hat, q_s, conv)
+
+The electron diamagnetic frequency *form* `C_dia · m T̂ L̂_n⁻¹ / q_s`
+(structure of `01 §5`; value gated on `conv.C_dia`, QUESTIONS Q3). Returns NaN
+until the convention is cleared.
+"""
+omega_dia_form(m_mode, T_hat, inv_Ln_hat, q_s, conv::FrameConvention) = conv.C_dia * m_mode * T_hat * inv_Ln_hat / q_s
+
+"""
+ effective_dlnn_form(inv_Ln0_hat, Z, omega_E_hat, conv)
+
+The frame shift of the effective density gradient *form*
+`L̂_n⁻¹ = L̂_{n0}⁻¹ (1 + C_gradient_shift · Z ω̂_E)` (structure of `01 §5`; value
+gated on `conv.C_gradient_shift`, QUESTIONS Q3). `omega_E_hat` is already
+normalized to `ω_dia,e`. Returns NaN until the convention is cleared.
+"""
+effective_dlnn_form(inv_Ln0_hat, Z, omega_E_hat, conv::FrameConvention) = inv_Ln0_hat * (1 + conv.C_gradient_shift * Z * omega_E_hat)
+
+end # module Frames
diff --git a/src/Islands/moments/Moments.jl b/src/Islands/moments/Moments.jl
new file mode 100644
index 00000000..a9c4d71d
--- /dev/null
+++ b/src/Islands/moments/Moments.jl
@@ -0,0 +1,133 @@
+"""
+ Moments
+
+Output-moment assembly (design `01 §4`, `03 §1`): the parallel current
+`J̄_∥(x, ξ)` from species-summed velocity moments, its `cos ξ`/`sin ξ` Ampère
+projections `Δ_cos`/`Δ_sin`, and the island flux-surface-average diagnostics
+(`Ω` label, `⟨·⟩_Ω`, bootstrap/polarization channel split).
+
+**Gating:** the projection and quadrature machinery here is pure numerics. The
+physics enters through (i) the per-species velocity-space weights `W_j` (the
+`v̂_∥`-structure — `[VERIFY]`-gated, QUESTIONS Q3), and (ii) the `Δ` moment
+prefactors (`±μ₀R/2ψ̃` with `ψ̃` under an open `[VERIFY]`, and the sin-moment
+normalization still `[DERIVED]`-unpinned — QUESTIONS Q4). Both are **required
+caller-supplied arguments** with no defaults; nothing here assigns them.
+
+The island label convention is the module-CLAUDE.md pin: `Ω = 2x²/w² − cos ξ`,
+O-point `Ω = −1`, separatrix `Ω = +1`, `w` = **half**-width.
+"""
+module Moments
+
+using LinearAlgebra
+import QuadGK
+import ..PhaseSpace: IslandGrid, nnodes
+import ..Operators: weighted_moment!
+import ..SpeciesLists: Species
+
+export parallel_current!, delta_moments, omega_label, omega_average, channel_split
+
+"""
+ parallel_current!(Jpar, gs, species, weights, grid)
+
+Assemble `J̄_∥(x, ξ) = Σ_j Z_j ∫ W_j g_j` into `Jpar[ix, iξ]` (`01 §4`): one
+`weighted_moment!` per species, charge-scaled and accumulated. `gs`, `species`
+and `weights` are aligned vectors; each `W_j` is the supplied `(ny, nE, nσ)`
+parallel-flow velocity weight (gated physics, QUESTIONS Q3).
+"""
+function parallel_current!(Jpar, gs::AbstractVector, species::AbstractVector{<:Species}, weights::AbstractVector, grid::IslandGrid)
+ length(gs) == length(species) == length(weights) ||
+ throw(ArgumentError("gs, species, weights must be aligned (got $(length(gs)), $(length(species)), $(length(weights)))"))
+ fill!(Jpar, zero(eltype(Jpar)))
+ for (g, sp, W) in zip(gs, species, weights)
+ weighted_moment!(Jpar, g, W, grid; scale=sp.Z, accumulate=true)
+ end
+ return Jpar
+end
+
+"""
+ delta_moments(Jpar, grid; prefactor_cos, prefactor_sin)
+
+The two Ampère projections of `J̄_∥` through the island (`01 §4`):
+
+ Δ_cos = prefactor_cos · ∫dx ∮dξ J̄_∥ cos ξ
+ Δ_sin = prefactor_sin · ∫dx ∮dξ J̄_∥ sin ξ
+
+`ξ`-integration is the uniform-grid trapezoid (spectrally exact on the periodic
+grid); `x`-integration uses the grid's Simpson weights. The prefactors
+(`∓μ₀R/2ψ̃`, `01 §4`) are **gated** — `ψ̃` carries an open `[VERIFY]` and the
+sin normalization is `[DERIVED]`-unpinned (QUESTIONS Q4) — so both are required
+arguments. Returns `(Δcos, Δsin)`.
+"""
+function delta_moments(Jpar, grid::IslandGrid; prefactor_cos, prefactor_sin)
+ nx, nξ = size(Jpar)
+ (nx, nξ) == (grid.x.n, grid.ξ.n) || throw(ArgumentError("Jpar must be (nx, nξ) = $((grid.x.n, grid.ξ.n))"))
+ wξ = grid.ξ.L / grid.ξ.n
+ pc = zero(eltype(Jpar))
+ ps = zero(eltype(Jpar))
+ @inbounds for iξ in 1:nξ
+ c = cos(grid.ξ.nodes[iξ])
+ s = sin(grid.ξ.nodes[iξ])
+ for ix in 1:nx
+ w = grid.x.wq[ix] * wξ * Jpar[ix, iξ]
+ pc += w * c
+ ps += w * s
+ end
+ end
+ return (Δcos=prefactor_cos * pc, Δsin=prefactor_sin * ps)
+end
+
+"""
+ omega_label(x, ξ, w)
+
+The island flux-surface label `Ω = 2x²/w² − cos ξ` (pinned convention, module
+CLAUDE.md; O-point `Ω = −1`, separatrix `Ω = +1`, `w` = half-width). A
+diagnostic label only — never a solve coordinate (Decision D1).
+"""
+omega_label(x, ξ, w) = 2 * x^2 / w^2 - cos(ξ)
+
+# x on the Ω surface at helical angle ξ (branch = ±1 selects the x-sign).
+_x_on_surface(Ω, ξ, w, branch) = branch * (w / sqrt(2)) * sqrt(max(Ω + cos(ξ), 0.0))
+
+"""
+ omega_average(f, Ω, w; branch=+1, rtol=1e-10)
+
+Island flux-surface average `⟨f⟩_Ω = ∮ f (Ω + cos ξ)^{−1/2} dξ / ∮ (Ω + cos ξ)^{−1/2} dξ`
+(`01 §4` decomposition weight) of a callable `f(x, ξ)`:
+
+ - `Ω > 1` (outside the separatrix): the `±x` branches are distinct surfaces;
+ `branch` selects one, integrating over the full `ξ ∈ [0, 2π)`.
+ - `−1 < Ω < 1` (inside): the surface closes through both branches between the
+ turning points `cos ξ_b = −Ω`; the endpoint weight singularity is integrable
+ and handled by the adaptive quadrature.
+"""
+function omega_average(f, Ω, w; branch::Int=1, rtol::Real=1e-10)
+ if Ω > 1
+ num, _ = QuadGK.quadgk(ξ -> f(_x_on_surface(Ω, ξ, w, branch), ξ) / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol)
+ den, _ = QuadGK.quadgk(ξ -> 1.0 / sqrt(Ω + cos(ξ)), 0.0, 2π; rtol=rtol)
+ return num / den
+ elseif -1 < Ω < 1
+ ξb = acos(-Ω)
+ integrand(ξ) = (f(_x_on_surface(Ω, ξ, w, +1), ξ) + f(_x_on_surface(Ω, ξ, w, -1), ξ)) / sqrt(Ω + cos(ξ))
+ num, _ = QuadGK.quadgk(integrand, -ξb, ξb; rtol=rtol)
+ den, _ = QuadGK.quadgk(ξ -> 2.0 / sqrt(Ω + cos(ξ)), -ξb, ξb; rtol=rtol)
+ return num / den
+ else
+ throw(DomainError(Ω, "omega_average needs Ω ∈ (−1, 1) ∪ (1, ∞) (O-point/separatrix excluded)"))
+ end
+end
+
+"""
+ channel_split(Jfun, Ω, w; branch=+1)
+
+The `01 §4` bootstrap/polarization bookkeeping at one `Ω` surface: returns
+`(bs, pol)` where `bs = ⟨J⟩_Ω` (the flux-surface-constant "bootstrap+curvature"
+part) and `pol(x, ξ) = Jfun(x, ξ) − bs` (the piece that `Ω`-averages to zero).
+Diagnostic only — the solve never separates channels; the split is approximate
+bookkeeping (L23 Eq. 2.5.3 caveat).
+"""
+function channel_split(Jfun, Ω, w; branch::Int=1)
+ bs = omega_average(Jfun, Ω, w; branch=branch)
+ return (bs=bs, pol=(x, ξ) -> Jfun(x, ξ) - bs)
+end
+
+end # module Moments
diff --git a/src/Islands/operators/Operators.jl b/src/Islands/operators/Operators.jl
new file mode 100644
index 00000000..3a7afba5
--- /dev/null
+++ b/src/Islands/operators/Operators.jl
@@ -0,0 +1,643 @@
+"""
+ Operators
+
+The Islands operator stack (`03 §2`): the state vector, the residual assembly,
+and the `AbstractTerm` family whose sum is the drift-kinetic residual.
+
+**Milestone-M1 status: allocation-free, AD-compatible *structural* stubs.** Each
+term implements its discretized differential/algebraic *structure* — which
+derivatives act, in which coordinate, with which sign pattern — but takes every
+physics coefficient as *supplied data* (`term.a_xi`, `term.c_D`, …). No literal
+physics coefficient, sign, or normalization is written here: those carry
+`[VERIFY]`/`[CHECKED]` tags (module `CLAUDE.md`) and are populated only after
+human clearance. The verification harness (`Verify`) exercises the discretization
+with arbitrary manufactured test coefficients, which is legitimate — it tests
+the numerics, not the physics.
+
+Operator-stack rules enforced here (`03 §2`, `CLAUDE.md`):
+
+ - terms are independent — no term inspects which others are active;
+ - `apply!` is generic over `eltype(U)` (ForwardDiff duals flow through) and
+ allocation-free on the hot path (regression-tested);
+ - orderings that *remove* structure are phase-space configurations, not terms.
+
+Term ↔ physics map (`03 §2`, `01 §2`): `ParallelStreaming` (island-induced
+streaming, `∂ξ` + `∂x`), `MagneticDrift` (precession, `∂ξ`, `:original`/`:improved`
+toggle), `ExBDrift` (the `E×B` Poisson bracket in `(x, ξ)`), `Collisions`
+(pitch-angle diffusion in `y`), `GradientDrive` (`(v·∇F₀)` source),
+`PerpTransport`/`RadiationSink` (Level-4 stubs), and `Quasineutrality` (the
+Level-0 field residual, `01 §3`).
+"""
+module Operators
+
+using LinearAlgebra
+import ..PhaseSpace: IslandGrid, nnodes
+
+export IslandState, IslandCache, IslandStack, AbstractTerm
+export ParallelStreaming, MagneticDrift, ExBDrift, Collisions, GradientDrive,
+ PerpTransport, RadiationSink, Quasineutrality
+export PitchAngleDiffusion, conservative_pitch_operator
+export FarFieldConditions, apply_farfield!
+export apply!, residual!, velocity_moment!, weighted_moment!, statelength,
+ flatten!, unflatten!, g_flat_index, Φ_flat_index
+
+# ---------------------------------------------------------------------------
+# State, cache
+# ---------------------------------------------------------------------------
+"""
+ IslandState(g, Φ)
+
+The Level-0 unknowns (`03 §2`): the orbit-averaged distribution
+`g[ix, iξ, iy, iE, iσ]` per grid point and the electrostatic potential
+`Φ[ix, iξ]`. Parametric in the element type so ForwardDiff duals flow through.
+
+## Fields
+
+ - `g` — 5D array over `(x, ξ, y, E, σ)`.
+ - `Φ` — 2D array over `(x, ξ)`.
+"""
+struct IslandState{T,A5<:AbstractArray{T,5},A2<:AbstractArray{T,2}}
+ g::A5
+ Φ::A2
+end
+
+function IslandState{T}(grid::IslandGrid) where {T}
+ nx, nξ, ny, nE, nσ = nnodes(grid)
+ return IslandState(zeros(T, nx, nξ, ny, nE, nσ), zeros(T, nx, nξ))
+end
+IslandState(grid::IslandGrid) = IslandState{Float64}(grid)
+
+Base.eltype(::IslandState{T}) where {T} = T
+
+function Base.similar(U::IslandState{T}) where {T}
+ return IslandState(similar(U.g), similar(U.Φ))
+end
+
+function fill_state!(U::IslandState, v)
+ fill!(U.g, v)
+ fill!(U.Φ, v)
+ return U
+end
+
+"""
+ IslandCache{T}(grid)
+
+Per-solve scratch buffers, typed to the state element type `T` so the hot path
+allocates nothing (and so AD duals get dual-typed scratch). Currently holds the
+two potential-gradient fields consumed by the `E×B` bracket.
+"""
+struct IslandCache{T}
+ dΦdx::Matrix{T}
+ dΦdξ::Matrix{T}
+end
+
+function IslandCache{T}(grid::IslandGrid) where {T}
+ nx, nξ, = nnodes(grid)
+ return IslandCache{T}(zeros(T, nx, nξ), zeros(T, nx, nξ))
+end
+IslandCache(grid::IslandGrid) = IslandCache{Float64}(grid)
+
+# ---------------------------------------------------------------------------
+# Term family
+# ---------------------------------------------------------------------------
+"""
+ AbstractTerm
+
+Supertype of every operator-stack term (`03 §2`). Each concrete term implements
+`apply!(R, term, U, grid, cache)` to accumulate its contribution to the residual.
+Terms are independent (no term inspects which others are active), generic over
+`eltype(U)`, and allocation-free on the hot path.
+"""
+abstract type AbstractTerm end
+
+"""
+ ParallelStreaming(a_xi, a_x)
+
+Island-induced parallel streaming (`01 §2`, the `∂ξ`/`∂x` channel): adds
+`a_xi ∂g/∂ξ + a_x ∂g/∂x` to the residual. `a_xi`, `a_x` are supplied coefficient
+arrays shaped like `g` (physics values `[VERIFY]`-gated; never literals here).
+"""
+struct ParallelStreaming{A} <: AbstractTerm
+ a_xi::A
+ a_x::A
+end
+
+"""
+ MagneticDrift(c_D; variant=:original)
+
+Orbit-averaged magnetic (precession) drift (`01 §2.1`): adds `c_D ∂g/∂ξ`. The
+`variant` selects the `:original` (finite `L̂_B⁻¹`, I19) vs `:improved`
+(`L̂_B⁻¹ → 0` proxy, D21) drift-frequency structure — the 8.73→1.46 ρ_bi toggle
+(`docs/05 E1`). Structure only: `c_D` (the `ω̂_D` coefficient over `(y, E, σ)`)
+is supplied data.
+"""
+struct MagneticDrift{A} <: AbstractTerm
+ c_D::A
+ variant::Symbol
+end
+MagneticDrift(c_D; variant::Symbol=:original) = MagneticDrift(c_D, variant)
+
+"""
+ ExBDrift(c_E)
+
+`E×B` advection as the Poisson bracket of `Φ` and `g` in `(x, ξ)`
+(`01 §2`): adds `c_E (∂Φ/∂ξ · ∂g/∂x − ∂Φ/∂x · ∂g/∂ξ)`. This is the one Level-0
+kinetic term nonlinear in the state (couples `g` and `Φ`); `c_E` is a supplied
+scalar coupling.
+"""
+struct ExBDrift{S} <: AbstractTerm
+ c_E::S
+end
+
+"""
+ Collisions(a_y, b_y; model=:pitch_angle)
+
+Pitch-angle (Lorentz) collision operator (`01 §2.3`) as a second-order operator
+in `y`: adds `a_y ∂²g/∂y² + b_y ∂g/∂y`. `model = :pitch_angle` is the Level-0
+form; `:fokker_planck` (Level 1) reuses the same slot. `a_y`, `b_y` are supplied
+coefficient arrays (the `ν̂`-weighted diffusion structure, `[VERIFY]`-gated).
+"""
+struct Collisions{A} <: AbstractTerm
+ a_y::A
+ b_y::A
+ model::Symbol
+end
+Collisions(a_y, b_y; model::Symbol=:pitch_angle) = Collisions(a_y, b_y, model)
+
+"""
+ PitchAngleDiffusion(K, c)
+
+Pitch-angle collision operator in **discretely conservative (mimetic) form**
+(`01 §2.3` structure; ladder A4): adds `c ⋅ (K g)` along `y`, where `K` is the
+divergence-form matrix built by [`conservative_pitch_operator`](@ref) and `c` is
+a supplied coefficient array over `(x, ξ, E, σ)` — **`y`-independent by
+construction**, so the exact discrete particle conservation and entropy-sign
+properties of `K` are preserved (the physical `ν̂(v̂)` energy dependence lives in
+`c`'s `E`-dependence and is `[VERIFY]`-gated). The momentum-restoring
+field-particle piece of the Level-0 operator is gated physics (QUESTIONS Q3)
+and is not part of this structure.
+"""
+struct PitchAngleDiffusion{M<:AbstractMatrix,A} <: AbstractTerm
+ K::M
+ c::A
+end
+
+"""
+ conservative_pitch_operator(ygrid, P, wmeas)
+
+Build the mimetic divergence-form pitch operator on `ygrid`
+(`C[g] = (1/w) d/dy(P w dg/dy)` discretized as `K = −Wq⁻¹ Gᵀ diag(P .* wq) G`
+with `G = ygrid.D1` and `Wq = wmeas .* ygrid.wq`), returning `(K, Wq)`.
+
+Because `G` differentiates constants exactly and the quadrature weights are
+positive, `K` satisfies **exactly** (to machine precision, ladder A4):
+
+ - particle conservation: `Wqᵀ (K g) = 0` for any `g` (zero-flux built in);
+ - entropy sign: `gᵀ diag(Wq) K g = −(Gg)ᵀ diag(P .* wq)(Gg) ≤ 0` for `P ≥ 0`.
+
+`P` (the pitch-space diffusivity profile, physically the `λ√(1−λB)`-structure)
+and `wmeas` (the velocity-space measure) are **supplied** profiles — their
+Level-0 physics forms are `[VERIFY]`-gated (QUESTIONS Q3); any positive test
+profiles exercise the conservation structure.
+"""
+function conservative_pitch_operator(ygrid, P::AbstractVector, wmeas::AbstractVector)
+ length(P) == ygrid.n || throw(ArgumentError("P must have length $(ygrid.n)"))
+ length(wmeas) == ygrid.n || throw(ArgumentError("wmeas must have length $(ygrid.n)"))
+ all(>=(0), P) || throw(ArgumentError("diffusivity profile P must be nonnegative"))
+ all(>(0), wmeas) || throw(ArgumentError("measure weights wmeas must be positive"))
+ G = ygrid.D1
+ Wq = wmeas .* ygrid.wq
+ K = -Diagonal(1.0 ./ Wq) * (G' * Diagonal(P .* ygrid.wq) * G)
+ return Matrix(K), Wq
+end
+
+"""
+ GradientDrive(drive)
+
+The `(v_E + v_D + v_ψ̃)·∇F₀` gradient drive (`03 §2`, `01 §2`): a state-independent
+source, adds `drive` to the residual. `drive` is a supplied array shaped like
+`g`; at Level 0 it is built from the background Maxwellian gradients (`[VERIFY]`).
+"""
+struct GradientDrive{A} <: AbstractTerm
+ drive::A
+end
+
+"""
+ PerpTransport(χ)
+
+Perpendicular transport (Level-4 closure stub, `03 §2`): adds `χ ∂²g/∂x²`.
+Present as structure only; the closure value `χ` is a supplied knob.
+"""
+struct PerpTransport{S} <: AbstractTerm
+ χ::S
+end
+
+"""
+ RadiationSink(κ)
+
+Radiative energy sink (Level-4 closure stub, `03 §2`): adds `-κ g`. Structure
+only; `κ` is a supplied coefficient array.
+"""
+struct RadiationSink{A} <: AbstractTerm
+ κ::A
+end
+
+"""
+ Quasineutrality(α)
+
+The Level-0 field residual (`01 §3`): `R_Φ = M[g] − α Φ`, where `M[g]` is the
+velocity moment `∫dy ∫dE Σ_σ g` (Gauss in `E`, Simpson in `y`) and `α` encodes
+the flattened-electron closure scaling (structure of `e_iΦ̂/T_i/(2 L̂_{n0})`,
+supplied). This closes `Φ(x, ξ)` inside the global Newton system rather than the
+sources' fragile nested Picard loop (`01 §3`, `03 §3`).
+"""
+struct Quasineutrality{S} <: AbstractTerm
+ α::S
+end
+
+# ---------------------------------------------------------------------------
+# Discrete kernels (allocation-free, generic over eltype)
+#
+# Directional-derivative kernels accumulate `coef · ∂g/∂dir` into `Rg`. The
+# dense matrices `D` are Float64; `D·g` with `g::Dual` promotes correctly, so
+# the whole stack is AD-transparent.
+# ---------------------------------------------------------------------------
+# adds coef .* (∂g/∂ξ) along dim 2
+@inline function _add_dxi!(Rg, coef, g, D)
+ nx, nξ, ny, nE, nσ = size(g)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, ix in 1:nx
+ for a in 1:nξ
+ acc = zero(eltype(Rg))
+ for b in 1:nξ
+ acc += D[a, b] * g[ix, b, iy, iE, iσ]
+ end
+ Rg[ix, a, iy, iE, iσ] += coef[ix, a, iy, iE, iσ] * acc
+ end
+ end
+ return Rg
+end
+
+# adds coef .* (∂g/∂x) along dim 1
+@inline function _add_dx!(Rg, coef, g, D)
+ nx, nξ, ny, nE, nσ = size(g)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ
+ for a in 1:nx
+ acc = zero(eltype(Rg))
+ for b in 1:nx
+ acc += D[a, b] * g[b, iξ, iy, iE, iσ]
+ end
+ Rg[a, iξ, iy, iE, iσ] += coef[a, iξ, iy, iE, iσ] * acc
+ end
+ end
+ return Rg
+end
+
+# adds coef .* (Dⁿ g along dim 3, y). D is D1 or D2.
+@inline function _add_dy!(Rg, coef, g, D)
+ nx, nξ, ny, nE, nσ = size(g)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx
+ for a in 1:ny
+ acc = zero(eltype(Rg))
+ for b in 1:ny
+ acc += D[a, b] * g[ix, iξ, b, iE, iσ]
+ end
+ Rg[ix, iξ, a, iE, iσ] += coef[ix, iξ, a, iE, iσ] * acc
+ end
+ end
+ return Rg
+end
+
+# scalar-coefficient ∂²/∂x² (PerpTransport): adds χ .* D2x g along dim 1
+@inline function _add_dx2_scalar!(Rg, χ, g, D)
+ nx, nξ, ny, nE, nσ = size(g)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ
+ for a in 1:nx
+ acc = zero(eltype(Rg))
+ for b in 1:nx
+ acc += D[a, b] * g[b, iξ, iy, iE, iσ]
+ end
+ Rg[a, iξ, iy, iE, iσ] += χ * acc
+ end
+ end
+ return Rg
+end
+
+# ∂Φ/∂x and ∂Φ/∂ξ into cache buffers (allocation-free)
+@inline function _potential_gradients!(cache::IslandCache, Φ, Dx, Dξ)
+ nx, nξ = size(Φ)
+ dΦdx, dΦdξ = cache.dΦdx, cache.dΦdξ
+ @inbounds for iξ in 1:nξ, ix in 1:nx
+ ax = zero(eltype(Φ))
+ for b in 1:nx
+ ax += Dx[ix, b] * Φ[b, iξ]
+ end
+ dΦdx[ix, iξ] = ax
+ end
+ @inbounds for ix in 1:nx, iξ in 1:nξ
+ aξ = zero(eltype(Φ))
+ for b in 1:nξ
+ aξ += Dξ[iξ, b] * Φ[ix, b]
+ end
+ dΦdξ[ix, iξ] = aξ
+ end
+ return cache
+end
+
+# ---------------------------------------------------------------------------
+# apply!(R, term, U, grid, cache) — accumulate the term's contribution.
+# ---------------------------------------------------------------------------
+"""
+ apply!(R, term, U, grid, cache)
+
+Accumulate `term`'s contribution to the residual `R` at state `U` on `grid`,
+using `cache` for scratch. Each `AbstractTerm` defines a method; the assembly in
+[`residual!`](@ref) walks the stack. Allocation-free and generic over `eltype(U)`
+so ForwardDiff duals flow through (verification ladder A2).
+"""
+function apply!(R::IslandState, t::ParallelStreaming, U::IslandState, grid::IslandGrid, ::IslandCache)
+ _add_dxi!(R.g, t.a_xi, U.g, grid.ξ.D1)
+ _add_dx!(R.g, t.a_x, U.g, grid.x.D1)
+ return R
+end
+
+function apply!(R::IslandState, t::MagneticDrift, U::IslandState, grid::IslandGrid, ::IslandCache)
+ _add_dxi!(R.g, t.c_D, U.g, grid.ξ.D1)
+ return R
+end
+
+function apply!(R::IslandState, t::Collisions, U::IslandState, grid::IslandGrid, ::IslandCache)
+ _add_dy!(R.g, t.a_y, U.g, grid.y.D2)
+ _add_dy!(R.g, t.b_y, U.g, grid.y.D1)
+ return R
+end
+
+function apply!(R::IslandState, t::PitchAngleDiffusion, U::IslandState, grid::IslandGrid, ::IslandCache)
+ K = t.K
+ c = t.c
+ g = U.g
+ nx, nξ, ny, nE, nσ = size(g)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx
+ cv = c[ix, iξ, iE, iσ]
+ for a in 1:ny
+ acc = zero(eltype(R.g))
+ for b in 1:ny
+ acc += K[a, b] * g[ix, iξ, b, iE, iσ]
+ end
+ R.g[ix, iξ, a, iE, iσ] += cv * acc
+ end
+ end
+ return R
+end
+
+function apply!(R::IslandState, t::GradientDrive, U::IslandState, ::IslandGrid, ::IslandCache)
+ @inbounds @. R.g += t.drive
+ return R
+end
+
+function apply!(R::IslandState, t::PerpTransport, U::IslandState, grid::IslandGrid, ::IslandCache)
+ _add_dx2_scalar!(R.g, t.χ, U.g, grid.x.D2)
+ return R
+end
+
+function apply!(R::IslandState, t::RadiationSink, U::IslandState, ::IslandGrid, ::IslandCache)
+ @inbounds @. R.g += -t.κ * U.g
+ return R
+end
+
+function apply!(R::IslandState, t::ExBDrift, U::IslandState, grid::IslandGrid, cache::IslandCache)
+ _potential_gradients!(cache, U.Φ, grid.x.D1, grid.ξ.D1)
+ dΦdx, dΦdξ = cache.dΦdx, cache.dΦdξ
+ g = U.g
+ Dx, Dξ = grid.x.D1, grid.ξ.D1
+ cE = t.c_E
+ nx, nξ, ny, nE, nσ = size(g)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ, ix in 1:nx
+ dgdx = zero(eltype(R.g))
+ for b in 1:nx
+ dgdx += Dx[ix, b] * g[b, iξ, iy, iE, iσ]
+ end
+ dgdξ = zero(eltype(R.g))
+ for b in 1:nξ
+ dgdξ += Dξ[iξ, b] * g[ix, b, iy, iE, iσ]
+ end
+ R.g[ix, iξ, iy, iE, iσ] += cE * (dΦdξ[ix, iξ] * dgdx - dΦdx[ix, iξ] * dgdξ)
+ end
+ return R
+end
+
+function apply!(R::IslandState, t::Quasineutrality, U::IslandState, grid::IslandGrid, ::IslandCache)
+ velocity_moment!(R.Φ, U.g, grid; accumulate=true)
+ @inbounds @. R.Φ += -t.α * U.Φ
+ return R
+end
+
+# ---------------------------------------------------------------------------
+# Velocity moment M[g](x,ξ) = ∫dy ∫dE Σ_σ g (Simpson in y, Gauss in E).
+# ---------------------------------------------------------------------------
+"""
+ velocity_moment!(M, g, grid; accumulate=false)
+
+Accumulate the phase-space velocity moment `∫dy ∫dE Σ_σ g` into `M[ix, iξ]`
+using the Simpson `y`-weights and Gauss `E`-weights of `grid` (`03 §2`). Set
+`accumulate=true` to add into `M` (residual assembly); otherwise `M` is zeroed
+first.
+"""
+function velocity_moment!(M, g, grid::IslandGrid; accumulate::Bool=false)
+ accumulate || fill!(M, zero(eltype(M)))
+ wy = grid.y.wq
+ wE = grid.E.weights
+ nx, nξ, ny, nE, nσ = size(g)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny
+ w = wy[iy] * wE[iE]
+ for iξ in 1:nξ, ix in 1:nx
+ M[ix, iξ] += w * g[ix, iξ, iy, iE, iσ]
+ end
+ end
+ return M
+end
+
+"""
+ weighted_moment!(M, g, W, grid; scale=1, accumulate=false)
+
+Accumulate the weighted velocity moment `scale · ∫dy ∫dE Σ_σ W(y, E, σ) g` into
+`M[ix, iξ]` (`03 §2`, moments). `W` is a supplied `(ny, nE, nσ)` velocity-space
+weight — e.g. the `v̂_∥`-structure of the parallel-flow moment, whose Level-0
+physics form is `[VERIFY]`-gated (QUESTIONS Q3). `scale` carries a per-species
+factor (e.g. the charge `Z_j`). `velocity_moment!` is the `W ≡ 1` special case.
+"""
+function weighted_moment!(M, g, W, grid::IslandGrid; scale=1, accumulate::Bool=false)
+ accumulate || fill!(M, zero(eltype(M)))
+ wy = grid.y.wq
+ wE = grid.E.weights
+ nx, nξ, ny, nE, nσ = size(g)
+ size(W) == (ny, nE, nσ) || throw(ArgumentError("weight W must have size (ny, nE, nσ) = $((ny, nE, nσ))"))
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny
+ w = scale * wy[iy] * wE[iE] * W[iy, iE, iσ]
+ for iξ in 1:nξ, ix in 1:nx
+ M[ix, iξ] += w * g[ix, iξ, iy, iE, iσ]
+ end
+ end
+ return M
+end
+
+# ---------------------------------------------------------------------------
+# Far-field boundary conditions (01 §3, 04 §1): match g and Φ to supplied
+# far-field states at |x| = L_x. NEVER bare Neumann — L23 traced its spurious
+# "winged" solution branch to Neumann non-uniqueness; the physical far field is
+# the neoclassical (no-island) solution, which is [VERIFY]-gated physics and
+# therefore *supplied* here, not computed.
+# ---------------------------------------------------------------------------
+"""
+ FarFieldConditions(g_left, g_right, Φ_left, Φ_right)
+
+Dirichlet-type far-field matching data at the two radial boundaries (`01 §3`):
+`g → g_far` and `Φ̂ → Φ̂_far` as `|x| → L_x`. The residual rows at `ix = 1, nx`
+are replaced by `(U − far)` so the Newton solve pins the far field. The
+neoclassical no-island `g_far` is gated physics (QUESTIONS Q3) — callers supply
+it (tests use manufactured far fields).
+
+## Fields
+
+ - `g_left`, `g_right` — `(nξ, ny, nE, nσ)` far-field distributions at `ix = 1`, `ix = nx`.
+ - `Φ_left`, `Φ_right` — length-`nξ` far-field potentials at `ix = 1`, `ix = nx`.
+"""
+struct FarFieldConditions{T,A4<:AbstractArray{T,4},V<:AbstractVector{T}}
+ g_left::A4
+ g_right::A4
+ Φ_left::V
+ Φ_right::V
+end
+
+"""
+ apply_farfield!(R, U, bc, grid)
+
+Replace the residual rows at the radial boundaries with the far-field matching
+conditions `U − far` (`01 §3`). Called after the operator stack in
+[`residual!`](@ref); allocation-free and AD-transparent.
+"""
+function apply_farfield!(R::IslandState, U::IslandState, bc::FarFieldConditions, grid::IslandGrid)
+ nx, nξ, ny, nE, nσ = size(U.g)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ
+ R.g[1, iξ, iy, iE, iσ] = U.g[1, iξ, iy, iE, iσ] - bc.g_left[iξ, iy, iE, iσ]
+ R.g[nx, iξ, iy, iE, iσ] = U.g[nx, iξ, iy, iE, iσ] - bc.g_right[iξ, iy, iE, iσ]
+ end
+ @inbounds for iξ in 1:nξ
+ R.Φ[1, iξ] = U.Φ[1, iξ] - bc.Φ_left[iξ]
+ R.Φ[nx, iξ] = U.Φ[nx, iξ] - bc.Φ_right[iξ]
+ end
+ return R
+end
+
+# ---------------------------------------------------------------------------
+# Stack + residual assembly
+# ---------------------------------------------------------------------------
+"""
+ IslandStack(kinetic, field)
+
+A named configuration (`03 §2`): the tuple of kinetic terms acting on `R.g` and
+the `field` term (`Quasineutrality`) closing `R.Φ`. Stored as a tuple for type
+stability so `residual!` stays allocation-free.
+
+## Fields
+
+ - `kinetic` — tuple of `AbstractTerm`s contributing to the kinetic residual.
+ - `field` — the field-equation term.
+"""
+struct IslandStack{K<:Tuple,F<:AbstractTerm}
+ kinetic::K
+ field::F
+end
+
+"""
+ residual!(R, U, stack, grid, cache)
+
+Assemble the full residual `R = (R_g, R_Φ)` in place: zero `R`, apply every
+kinetic term, then the field term. Allocation-free and AD-transparent.
+"""
+function residual!(R::IslandState, U::IslandState, stack::IslandStack, grid::IslandGrid, cache::IslandCache)
+ fill_state!(R, zero(eltype(R)))
+ _apply_kinetic!(R, stack.kinetic, U, grid, cache)
+ apply!(R, stack.field, U, grid, cache)
+ return R
+end
+
+"""
+ residual!(R, U, stack, grid, cache, bc)
+
+Residual assembly with far-field boundary conditions: assemble the stack, then
+replace the radial-boundary rows with the `FarFieldConditions` matching
+residual (`01 §3`).
+"""
+function residual!(R::IslandState, U::IslandState, stack::IslandStack, grid::IslandGrid, cache::IslandCache, bc::FarFieldConditions)
+ residual!(R, U, stack, grid, cache)
+ apply_farfield!(R, U, bc, grid)
+ return R
+end
+
+# recursive tuple walk keeps the loop type-stable (no dynamic dispatch, no alloc)
+@inline _apply_kinetic!(R, ::Tuple{}, U, grid, cache) = R
+@inline function _apply_kinetic!(R, terms::Tuple, U, grid, cache)
+ apply!(R, terms[1], U, grid, cache)
+ return _apply_kinetic!(R, Base.tail(terms), U, grid, cache)
+end
+
+# ---------------------------------------------------------------------------
+# Flatten / unflatten for the Newton–Krylov / JVP interface
+# ---------------------------------------------------------------------------
+"""
+ statelength(grid)
+
+Number of scalar unknowns in the flattened state (`g` plus `Φ`).
+"""
+function statelength(grid::IslandGrid)
+ nx, nξ, ny, nE, nσ = nnodes(grid)
+ return nx * nξ * ny * nE * nσ + nx * nξ
+end
+
+"""
+ flatten!(v, U)
+
+Copy state `U` into the flat vector `v` (`g` block then `Φ` block).
+"""
+function flatten!(v, U::IslandState)
+ ng = length(U.g)
+ copyto!(view(v, 1:ng), vec(U.g))
+ copyto!(view(v, (ng+1):(ng+length(U.Φ))), vec(U.Φ))
+ return v
+end
+
+"""
+ unflatten!(U, v)
+
+Copy the flat vector `v` back into state `U`.
+"""
+function unflatten!(U::IslandState, v)
+ ng = length(U.g)
+ copyto!(vec(U.g), view(v, 1:ng))
+ copyto!(vec(U.Φ), view(v, (ng+1):(ng+length(U.Φ))))
+ return U
+end
+
+"""
+ g_flat_index(grid, ix, iξ, iy, iE, iσ)
+
+Flat-state-vector index of `g[ix, iξ, iy, iE, iσ]` under the `flatten!` layout
+(`g` block column-major, then `Φ`). Used by the `y_c`-block conditioning
+monitor (ladder A8) to address pitch-pencil sub-blocks of the dense Jacobian.
+"""
+function g_flat_index(grid::IslandGrid, ix::Int, iξ::Int, iy::Int, iE::Int, iσ::Int)
+ nx, nξ, ny, nE, = nnodes(grid)
+ return ix + nx * ((iξ - 1) + nξ * ((iy - 1) + ny * ((iE - 1) + nE * (iσ - 1))))
+end
+
+"""
+ Φ_flat_index(grid, ix, iξ)
+
+Flat-state-vector index of `Φ[ix, iξ]` under the `flatten!` layout.
+"""
+function Φ_flat_index(grid::IslandGrid, ix::Int, iξ::Int)
+ nx, nξ, ny, nE, nσ = nnodes(grid)
+ return nx * nξ * ny * nE * nσ + ix + nx * (iξ - 1)
+end
+
+end # module Operators
diff --git a/src/Islands/phasespace/PhaseSpace.jl b/src/Islands/phasespace/PhaseSpace.jl
new file mode 100644
index 00000000..025132fa
--- /dev/null
+++ b/src/Islands/phasespace/PhaseSpace.jl
@@ -0,0 +1,342 @@
+"""
+ PhaseSpace
+
+Phase-space grids and discretization operators for the Islands drift-kinetic
+solver: the `(x, ξ, λ→y, E, σ)` coordinates of design doc `03 §1` with the
+layer-clustered mappings of `04 §1`.
+
+This module is **pure numerics** — grid node placement, spectral/finite-difference
+differentiation matrices, and quadrature weights. It contains no physics
+coefficients: nothing here carries a `[VERIFY]`/`[CHECKED]` tag, and the
+milestone-M1 discipline (build the discretization, not the physics numbers)
+lives one layer up in `Operators`.
+
+Design-order summary (verified by the MMS ladder A1, `Verify`):
+
+ - `ξ` (helical angle): Fourier pseudo-spectral on the periodic domain `[0, L)`
+ — exponential convergence for smooth periodic data (`04 §1`).
+ - `x` (radial) and `y` (pitch): high-order finite differences on a stretched,
+ layer-clustered grid — algebraic convergence at the requested `order`
+ (`04 §1`; the layer packing targets `x = 0` and `y = y_c = 1`).
+ - `E` (energy): Gauss quadrature on the `F₀`-weighted semi-infinite domain
+ (`04 §1`, Maxwellian weight at Level 0 via Gauss–Laguerre).
+ - `σ = ±1`: the two `sgn(v_∥)` sheets.
+"""
+module PhaseSpace
+
+using LinearAlgebra
+import FastGaussQuadrature
+
+export FourierGrid, MappedFDGrid, GaussGrid, IslandGrid
+export nnodes, differentiate_fourier, fd_weights
+
+# ---------------------------------------------------------------------------
+# Finite-difference weights (Fornberg 1988, Math. Comp. 51, 699) — weights for
+# derivatives 0..m of a function sampled at arbitrary `nodes`, evaluated at x0.
+# Returns `w[j+1, d+1]` = weight of node j for the d-th derivative.
+# ---------------------------------------------------------------------------
+"""
+ fd_weights(m, nodes, x0)
+
+Finite-difference weights for derivatives `0:m` of a function sampled at
+`nodes`, evaluated at `x0`, via Fornberg's recurrence. `w[j, d+1]` multiplies
+`f(nodes[j])` in the approximation of the `d`-th derivative. Exact for
+polynomials up to degree `length(nodes) - 1`.
+"""
+function fd_weights(m::Int, nodes::AbstractVector{<:Real}, x0::Real)
+ n = length(nodes)
+ @assert m < n "need at least m+1 nodes for the m-th derivative"
+ w = zeros(Float64, n, m + 1)
+ c1 = 1.0
+ c4 = nodes[1] - x0
+ w[1, 1] = 1.0
+ for i in 2:n
+ mn = min(i, m + 1)
+ c2 = 1.0
+ c5 = c4
+ c4 = nodes[i] - x0
+ for j in 1:(i-1)
+ c3 = nodes[i] - nodes[j]
+ c2 *= c3
+ if j == i - 1
+ for k in mn:-1:2
+ w[i, k] = c1 * ((k - 1) * w[i-1, k-1] - c5 * w[i-1, k]) / c2
+ end
+ w[i, 1] = -c1 * c5 * w[i-1, 1] / c2
+ end
+ for k in mn:-1:2
+ w[j, k] = (c4 * w[j, k] - (k - 1) * w[j, k-1]) / c3
+ end
+ w[j, 1] = c4 * w[j, 1] / c3
+ end
+ c1 = c2
+ end
+ return w
+end
+
+# ---------------------------------------------------------------------------
+# ξ: Fourier pseudo-spectral, periodic on [0, L).
+# ---------------------------------------------------------------------------
+"""
+ FourierGrid(n; L=2π)
+
+Uniform periodic grid of `n` nodes on `[0, L)` with the Fourier spectral
+first-derivative matrix `D1` (`04 §1`, `ξ` coordinate). `n` must be even.
+
+## Fields
+
+ - `n` — number of nodes.
+ - `L` — period length.
+ - `nodes` — node positions `j·L/n`, `j = 0:n-1`.
+ - `D1` — `n×n` spectral first-derivative matrix.
+"""
+struct FourierGrid
+ n::Int
+ L::Float64
+ nodes::Vector{Float64}
+ D1::Matrix{Float64}
+end
+
+function FourierGrid(n::Int; L::Real=2π)
+ iseven(n) || throw(ArgumentError("FourierGrid needs an even node count (got $n)"))
+ h = 2π / n # computational grid spacing on [0, 2π)
+ nodes = collect((0:(n-1)) .* (L / n))
+ D1 = zeros(Float64, n, n)
+ @inbounds for i in 1:n, j in 1:n
+ if i != j
+ k = i - j
+ D1[i, j] = 0.5 * (-1.0)^k / tan(k * h / 2)
+ end
+ end
+ D1 .*= (2π / L) # rescale d/dξ from [0,2π) to [0,L)
+ return FourierGrid(n, Float64(L), nodes, D1)
+end
+
+"""
+ differentiate_fourier(g, grid)
+
+Spectral first derivative of a periodic sample vector `g` on `grid` (allocating
+convenience wrapper around `grid.D1 * g`; hot paths use the matrix directly).
+"""
+differentiate_fourier(g::AbstractVector, grid::FourierGrid) = grid.D1 * g
+
+# ---------------------------------------------------------------------------
+# x, y: high-order finite differences on a layer-clustered stretched grid.
+#
+# A uniform computational coordinate s ∈ [-1, 1] is mapped to the physical
+# coordinate by a smooth, monotonic, layer-clustering map; physical derivative
+# matrices follow by the chain rule so the FD order is preserved (`04 §1`).
+# ---------------------------------------------------------------------------
+"""
+ MappedFDGrid(n; halfwidth, clustering=0.0, center=0.0, domain=:symmetric, order=4)
+
+High-order finite-difference grid of `n` nodes with layer clustering.
+
+The uniform computational coordinate `s ∈ [-1, 1]` is mapped to the physical
+coordinate by `sinh` stretching (`clustering = β`): points cluster toward the
+map center as `β` grows, and the map degenerates to uniform as `β → 0`. This is
+the design-doc packing that targets the internal layers — `x = 0` (rational
+surface) and `y = y_c = 1` (trapped–passing boundary), `04 §1–2`.
+
+`domain = :symmetric` gives `[center - halfwidth, center + halfwidth]` with
+clustering at `center`; `domain = :half` gives `[0, halfwidth]` with clustering
+at `center` (used for `y ∈ [0, y_max]` packed at `y_c`).
+
+Physical first/second-derivative matrices `D1`, `D2` are built with Fornberg
+weights on windows sized per derivative (`order + d` points for the `d`-th
+derivative, shifted one-sided near the boundaries) so both reach `order`-th
+order accuracy *uniformly*, including at the boundary rows; the chain rule uses
+the analytic map Jacobian `x'(s)` and `x''(s)`.
+
+`wq` holds composite-Simpson quadrature weights on the same nodes (built on the
+uniform computational grid and pushed through the map Jacobian), so
+`∫ f dx ≈ Σ wq[j] f(nodes[j])` at fourth order — matching the FD order for the
+velocity-moment integrals (`03 §2`, moments). Requires an odd `n`.
+
+## Fields
+
+ - `n`, `order` — node count and nominal FD order.
+ - `nodes` — physical node positions.
+ - `D1`, `D2` — `n×n` physical first/second-derivative matrices.
+ - `wq` — composite-Simpson quadrature weights on `nodes`.
+"""
+struct MappedFDGrid
+ n::Int
+ order::Int
+ nodes::Vector{Float64}
+ D1::Matrix{Float64}
+ D2::Matrix{Float64}
+ wq::Vector{Float64}
+end
+
+function MappedFDGrid(n::Int; halfwidth::Real, clustering::Real=0.0, center::Real=0.0,
+ domain::Symbol=:symmetric, order::Int=4)
+ isodd(n) || throw(ArgumentError("MappedFDGrid needs odd n for composite Simpson (got $n)"))
+ # widest window is for D2 (order+2 points); need enough nodes for it.
+ n > order + 2 || throw(ArgumentError("need n > order+2 ($n ≤ $(order + 2))"))
+
+ s = collect(range(-1.0, 1.0; length=n)) # uniform computational grid
+ hw = Float64(halfwidth)
+ β = Float64(clustering)
+
+ # Map s → physical, with analytic first/second derivatives of the map.
+ if domain === :symmetric
+ # x(s) = center + hw·sinh(β s)/sinh(β): monotone, clusters at s=0 ↔ x=center.
+ if abs(β) < 1e-12
+ xs = center .+ hw .* s
+ dxds = fill(hw, n)
+ d2xds2 = zeros(n)
+ else
+ sb = sinh(β)
+ xs = center .+ hw .* sinh.(β .* s) ./ sb
+ dxds = hw .* β .* cosh.(β .* s) ./ sb
+ d2xds2 = hw .* β^2 .* sinh.(β .* s) ./ sb
+ end
+ elseif domain === :half
+ # Map [-1,1] → [0, hw] with clustering at s* ↔ x=center via sinh about s*.
+ # u(s) = sinh(β(s - s*)); x = hw·(u - u(-1))/(u(1) - u(-1)).
+ sstar = clamp(2 * (center / hw) - 1, -1.0, 1.0)
+ if abs(β) < 1e-12
+ xs = hw .* (s .+ 1) ./ 2
+ dxds = fill(hw / 2, n)
+ d2xds2 = zeros(n)
+ else
+ u(z) = sinh(β * (z - sstar))
+ um1 = u(-1.0)
+ span = u(1.0) - um1
+ xs = hw .* (u.(s) .- um1) ./ span
+ dxds = hw .* β .* cosh.(β .* (s .- sstar)) ./ span
+ d2xds2 = hw .* β^2 .* sinh.(β .* (s .- sstar)) ./ span
+ end
+ else
+ throw(ArgumentError("unknown domain $domain (use :symmetric or :half)"))
+ end
+
+ D1s = _fd_matrix(s, 1, order)
+ D2s = _fd_matrix(s, 2, order)
+
+ # Chain rule: d/dx = (1/x')·d/ds ; d²/dx² = (1/x'²)·d²/ds² − (x''/x'³)·d/ds.
+ invp = 1.0 ./ dxds
+ D1 = Diagonal(invp) * D1s
+ D2 = Diagonal(invp .^ 2) * D2s - Diagonal(d2xds2 .* invp .^ 3) * D1s
+
+ # Composite Simpson on uniform s (ds = 2/(n-1)), times the map Jacobian dx/ds.
+ ds = 2.0 / (n - 1)
+ wq = similar(dxds)
+ @inbounds for j in 1:n
+ c = (j == 1 || j == n) ? 1.0 : (iseven(j) ? 4.0 : 2.0)
+ wq[j] = c * ds / 3 * dxds[j]
+ end
+
+ return MappedFDGrid(n, order, xs, Matrix(D1), Matrix(D2), wq)
+end
+
+# Dense derivative matrix for the `deriv`-th derivative on an arbitrary 1D node
+# set at the requested `order`, using windows of `order + deriv` points (the
+# minimum for `order`-th accuracy of the `deriv`-th derivative), rounded up to
+# an odd width so the interior window is centered; near the ends the window is
+# shifted one-sided. Weights are Fornberg's, exact for polynomials up to the
+# window degree.
+function _fd_matrix(nodes::AbstractVector{<:Real}, deriv::Int, order::Int)
+ n = length(nodes)
+ stencil = order + deriv
+ isodd(stencil) || (stencil += 1) # centered interior window needs odd width
+ stencil = min(stencil, n)
+ D = zeros(Float64, n, n)
+ half = stencil ÷ 2
+ @inbounds for i in 1:n
+ lo = clamp(i - half, 1, n - stencil + 1)
+ hi = lo + stencil - 1
+ w = fd_weights(deriv, @view(nodes[lo:hi]), nodes[i])
+ for (col, j) in enumerate(lo:hi)
+ D[i, j] = w[col, deriv+1]
+ end
+ end
+ return D
+end
+
+# ---------------------------------------------------------------------------
+# E: Gauss quadrature on the F₀-weighted semi-infinite energy domain.
+# ---------------------------------------------------------------------------
+"""
+ GaussGrid(n; kind=:laguerre)
+
+Gauss quadrature nodes/weights for velocity-space (energy) integrals over the
+`F₀`-weighted semi-infinite domain (`04 §1`). `kind = :laguerre` gives the
+Level-0 Maxwellian weight `∫₀^∞ f(E) e^{-E} dE ≈ Σ wᵢ f(Eᵢ)` (Gauss–Laguerre);
+the slowing-down weight (Level 2) only changes `kind`, not the machinery.
+
+## Fields
+
+ - `n` — number of quadrature nodes.
+ - `nodes` — abscissae `Eᵢ`.
+ - `weights` — quadrature weights `wᵢ` (the `F₀` weight is folded in).
+"""
+struct GaussGrid
+ n::Int
+ nodes::Vector{Float64}
+ weights::Vector{Float64}
+end
+
+function GaussGrid(n::Int; kind::Symbol=:laguerre)
+ if kind === :laguerre
+ nodes, weights = FastGaussQuadrature.gausslaguerre(n)
+ elseif kind === :legendre
+ nodes, weights = FastGaussQuadrature.gausslegendre(n)
+ else
+ throw(ArgumentError("unknown quadrature kind $kind"))
+ end
+ return GaussGrid(n, collect(nodes), collect(weights))
+end
+
+# ---------------------------------------------------------------------------
+# Bundle: the full Level-0 orbit-averaged 4D phase space (x, ξ, y, E) plus σ.
+# ---------------------------------------------------------------------------
+"""
+ IslandGrid(; nx, nxi, ny, nE, halfwidth_x, clustering_x, y_max, y_c,
+ clustering_y, xi_period=2π, energy_kind=:laguerre, order=4)
+
+The assembled Level-0 phase-space grid: radial `x`, helical `ξ`, pitch `y`,
+energy `E`, and the two `σ = ±1` sheets (`03 §1`). Grids are packed at the
+internal layers (`x = 0`, `y = y_c`) per `04 §1`.
+
+## Fields
+
+ - `x` — radial `MappedFDGrid` (clustered at `x = 0`).
+ - `ξ` — helical `FourierGrid`.
+ - `y` — pitch `MappedFDGrid` on `[0, y_max]` (clustered at `y_c`).
+ - `E` — energy `GaussGrid`.
+ - `σ` — `[+1.0, -1.0]`.
+ - `y_c` — trapped–passing boundary location in `y` (the layer the pitch grid
+ packs toward; consumed by the `y_c`-block conditioning monitor, ladder A8).
+"""
+struct IslandGrid
+ x::MappedFDGrid
+ ξ::FourierGrid
+ y::MappedFDGrid
+ E::GaussGrid
+ σ::Vector{Float64}
+ y_c::Float64
+end
+
+function IslandGrid(; nx::Int, nxi::Int, ny::Int, nE::Int,
+ halfwidth_x::Real, clustering_x::Real=0.0,
+ y_max::Real, y_c::Real=1.0, clustering_y::Real=0.0,
+ xi_period::Real=2π, energy_kind::Symbol=:laguerre, order::Int=4)
+ x = MappedFDGrid(nx; halfwidth=halfwidth_x, clustering=clustering_x, center=0.0,
+ domain=:symmetric, order=order)
+ ξ = FourierGrid(nxi; L=xi_period)
+ y = MappedFDGrid(ny; halfwidth=y_max, clustering=clustering_y, center=y_c,
+ domain=:half, order=order)
+ E = GaussGrid(nE; kind=energy_kind)
+ return IslandGrid(x, ξ, y, E, [1.0, -1.0], Float64(y_c))
+end
+
+"""
+ nnodes(grid::IslandGrid)
+
+Tuple `(nx, nξ, ny, nE, nσ)` of the phase-space grid dimensions.
+"""
+nnodes(g::IslandGrid) = (g.x.n, g.ξ.n, g.y.n, g.E.n, length(g.σ))
+
+end # module PhaseSpace
diff --git a/src/Islands/solvers/Solvers.jl b/src/Islands/solvers/Solvers.jl
new file mode 100644
index 00000000..2b4799cf
--- /dev/null
+++ b/src/Islands/solvers/Solvers.jl
@@ -0,0 +1,331 @@
+"""
+ Solvers
+
+The Islands steady-state solve (design `03 §3`, `04 §5`, Decision D2):
+matrix-free **Newton–Krylov** — never initial-value time-stepping, never nested
+Picard loops (kokuchou's Picard criterion was *never met* in production; the
+sources' Φ-outer/ū_∥i-inner iteration is the explicit anti-pattern).
+
+Pieces:
+
+ - [`flat_residual`](@ref) — wrap an operator stack (+ optional far-field BCs)
+ as a flat-vector residual `f!(out, u)`, generic over `eltype` with
+ preallocated real and dual workspaces.
+ - [`JVPOperator`](@ref) — the exact Jacobian–vector product via ForwardDiff
+ duals through the stack (`04 §5`); no global sparse Jacobian is ever formed
+ except in [`dense_jacobian`](@ref) tiny-grid debug mode.
+ - [`YBlockJacobi`](@ref) — the physics-block preconditioner skeleton: per-pitch-
+ pencil blocks with **TSVD-regularized** solves, the explicit `y_c`-matching
+ treatment of `04 §3` (GMRES must never resolve the near-singular directions
+ itself).
+ - [`newton_krylov`](@ref) — inexact Newton with Eisenstat–Walker forcing and
+ a backtracking line search.
+ - [`pseudo_arclength`](@ref) — the continuation scaffold with fold detection
+ from day one (`03 §3`: the Level-3 penetration bifurcation is a fold; the
+ solver must step around folds, not fail at them).
+
+Pure numerics — no physics coefficients (the stack it solves carries the gated
+parameters).
+"""
+module Solvers
+
+using LinearAlgebra
+import Krylov
+import ForwardDiff
+import ..PhaseSpace: IslandGrid, nnodes
+import ..Operators: IslandState, IslandCache, IslandStack, FarFieldConditions,
+ residual!, flatten!, unflatten!, statelength
+
+export flat_residual, JVPOperator, dense_jacobian
+export YBlockJacobi, newton_krylov, pseudo_arclength
+
+# Tag for the solver's ForwardDiff duals (one directional derivative).
+struct JVPTag end
+const JVPDual = ForwardDiff.Dual{JVPTag,Float64,1}
+
+# ---------------------------------------------------------------------------
+# Flat residual wrapper
+# ---------------------------------------------------------------------------
+"""
+ flat_residual(stack, grid; bc=nothing)
+
+Wrap `residual!` for `stack` on `grid` (with optional `FarFieldConditions`) as
+a flat-vector function `f!(out, u)` usable by [`newton_krylov`](@ref) and
+[`JVPOperator`](@ref). Real (`Float64`) and dual workspaces are preallocated so
+repeated evaluations allocate nothing.
+"""
+function flat_residual(stack::IslandStack, grid::IslandGrid; bc::Union{Nothing,FarFieldConditions}=nothing)
+ U = IslandState(grid)
+ R = IslandState(grid)
+ cache = IslandCache(grid)
+ Ud = IslandState{JVPDual}(grid)
+ Rd = IslandState{JVPDual}(grid)
+ cached = IslandCache{JVPDual}(grid)
+ function f!(out, u)
+ if eltype(u) === Float64
+ unflatten!(U, u)
+ bc === nothing ? residual!(R, U, stack, grid, cache) : residual!(R, U, stack, grid, cache, bc)
+ flatten!(out, R)
+ else
+ unflatten!(Ud, u)
+ bc === nothing ? residual!(Rd, Ud, stack, grid, cached) : residual!(Rd, Ud, stack, grid, cached, bc)
+ flatten!(out, Rd)
+ end
+ return out
+ end
+ return f!
+end
+
+# ---------------------------------------------------------------------------
+# Matrix-free Jacobian-vector product
+# ---------------------------------------------------------------------------
+"""
+ JVPOperator(f!, u)
+
+Matrix-free linear operator for the Jacobian of `f!` at the point `u` (which is
+**aliased**, so updating `u` in place moves the linearization point): `mul!(y, J, v)` computes the exact directional derivative via one dual-mode evaluation
+of `f!` (`04 §5`). Satisfies the `mul!`/`size`/`eltype` interface Krylov.jl
+expects.
+"""
+struct JVPOperator{F}
+ f!::F
+ u::Vector{Float64}
+ udual::Vector{JVPDual}
+ rdual::Vector{JVPDual}
+end
+
+function JVPOperator(f!, u::Vector{Float64})
+ n = length(u)
+ return JVPOperator(f!, u, Vector{JVPDual}(undef, n), Vector{JVPDual}(undef, n))
+end
+
+Base.size(J::JVPOperator) = (length(J.u), length(J.u))
+Base.size(J::JVPOperator, d::Int) = d <= 2 ? length(J.u) : 1
+Base.eltype(::JVPOperator) = Float64
+
+function LinearAlgebra.mul!(y::AbstractVector, J::JVPOperator, v::AbstractVector)
+ @inbounds for i in eachindex(J.u)
+ J.udual[i] = JVPDual(J.u[i], ForwardDiff.Partials((v[i],)))
+ end
+ J.f!(J.rdual, J.udual)
+ @inbounds for i in eachindex(y)
+ y[i] = ForwardDiff.partials(J.rdual[i])[1]
+ end
+ return y
+end
+
+"""
+ dense_jacobian(f!, u)
+
+Dense Jacobian of `f!` at `u` by column-wise dual sweeps — **tiny-grid debug
+mode only** (`04 §5`): used for eigenvalue/conditioning diagnostics (the
+`y_c`-block singular-value monitor, ladder A8), never in the production solve.
+"""
+function dense_jacobian(f!, u::Vector{Float64})
+ n = length(u)
+ J = zeros(n, n)
+ ud = Vector{JVPDual}(undef, n)
+ rd = Vector{JVPDual}(undef, n)
+ for j in 1:n
+ @inbounds for i in 1:n
+ ud[i] = JVPDual(u[i], ForwardDiff.Partials((i == j ? 1.0 : 0.0,)))
+ end
+ f!(rd, ud)
+ @inbounds for i in 1:n
+ J[i, j] = ForwardDiff.partials(rd[i])[1]
+ end
+ end
+ return J
+end
+
+# ---------------------------------------------------------------------------
+# Physics-block preconditioner skeleton (04 §3, §5)
+# ---------------------------------------------------------------------------
+"""
+ YBlockJacobi(grid, block; svd_cutoff=1e-10, phi_scale=1.0)
+
+Block-Jacobi preconditioner over pitch pencils — the skeleton of the `04 §5`
+physics-block preconditioner. `block(ix, iξ, iE, iσ)` returns the `ny × ny`
+stiff-direction block for that pencil (typically the identity-plus-collisions
+operator); each block is factored by SVD and **truncated below
+`svd_cutoff · σ_max`** — the explicit, regularized `y_c`-matching treatment of
+`04 §3` (kokuchou's rcond ≈ 1e-16 block gave machine-dependent noise under
+plain LU; TSVD is the documented fix). `phi_scale` is the diagonal applied to
+the `Φ` rows. Apply via `ldiv!` (pass as `N=...` with `ldiv=true` to GMRES).
+"""
+struct YBlockJacobi
+ grid::IslandGrid
+ pinvs::Array{Matrix{Float64},4}
+ phi_scale::Float64
+end
+
+function YBlockJacobi(grid::IslandGrid, block; svd_cutoff::Real=1e-10, phi_scale::Real=1.0)
+ nx, nξ, ny, nE, nσ = nnodes(grid)
+ pinvs = Array{Matrix{Float64},4}(undef, nx, nξ, nE, nσ)
+ for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx
+ B = Matrix{Float64}(block(ix, iξ, iE, iσ))
+ size(B) == (ny, ny) || throw(ArgumentError("block must be ny×ny = ($ny, $ny)"))
+ F = svd(B)
+ smax = F.S[1]
+ sinv = [s > svd_cutoff * smax ? 1.0 / s : 0.0 for s in F.S]
+ pinvs[ix, iξ, iE, iσ] = F.V * Diagonal(sinv) * F.U'
+ end
+ return YBlockJacobi(grid, pinvs, Float64(phi_scale))
+end
+
+function LinearAlgebra.ldiv!(y::AbstractVector, P::YBlockJacobi, x::AbstractVector)
+ nx, nξ, ny, nE, nσ = nnodes(P.grid)
+ ng = nx * nξ * ny * nE * nσ
+ xg = reshape(view(x, 1:ng), nx, nξ, ny, nE, nσ)
+ yg = reshape(view(y, 1:ng), nx, nξ, ny, nE, nσ)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx
+ Pi = P.pinvs[ix, iξ, iE, iσ]
+ for a in 1:ny
+ acc = 0.0
+ for b in 1:ny
+ acc += Pi[a, b] * xg[ix, iξ, b, iE, iσ]
+ end
+ yg[ix, iξ, a, iE, iσ] = acc
+ end
+ end
+ @inbounds for i in (ng+1):length(x)
+ y[i] = x[i] / P.phi_scale
+ end
+ return y
+end
+
+# ---------------------------------------------------------------------------
+# Inexact Newton-Krylov
+# ---------------------------------------------------------------------------
+"""
+ newton_krylov(f!, u0; rtol=1e-8, atol=1e-10, max_iter=25, precond=nothing,
+ eta0=0.5, eta_max=0.9, gamma=0.9, ls_max=10, memory=30,
+ verbose=false)
+
+Matrix-free inexact Newton–Krylov solve of `f!(out, u) = 0` (design `03 §3`,
+`04 §5`, Decision D2):
+
+ - GMRES (Krylov.jl) on the ForwardDiff [`JVPOperator`](@ref), with the
+ **Eisenstat–Walker** (choice-2) forcing term `η_k = γ(‖F_k‖/‖F_{k−1}‖)²` so
+ early Newton steps do not over-solve the linear system;
+ - a backtracking (Armijo-on-`‖F‖`) line search;
+ - optional right preconditioner `precond` applied via `ldiv!` (e.g.
+ [`YBlockJacobi`](@ref)).
+
+Convergence is declared on **both** the global residual norm and its max-norm
+(`04 §5`: the array-averaged residual hid locally divergent regions in the
+prior art). Returns a NamedTuple `(u, converged, iterations, residual_norms, residual_max, gmres_iters)` — the total Krylov iteration count is a tracked
+preconditioner-quality metric (`04 §5`), not folklore.
+"""
+function newton_krylov(f!, u0::AbstractVector{Float64};
+ rtol::Real=1e-8, atol::Real=1e-10, max_iter::Int=25, precond=nothing,
+ eta0::Real=0.5, eta_max::Real=0.9, gamma::Real=0.9, ls_max::Int=10,
+ memory::Int=30, verbose::Bool=false)
+ u = collect(u0)
+ n = length(u)
+ F = similar(u)
+ f!(F, u)
+ nF = norm(F)
+ nF0 = max(nF, eps())
+ tol = max(atol, rtol * nF0)
+ hist = Float64[nF]
+ J = JVPOperator(f!, u) # aliases u: updates move the linearization point
+ Ftrial = similar(u)
+ utrial = similar(u)
+ eta = float(eta0)
+ nF_prev = nF
+ k = 0
+ gmres_total = 0
+ while nF > tol && k < max_iter
+ k += 1
+ k > 1 && (eta = clamp(gamma * (nF / nF_prev)^2, 1e-4, eta_max))
+ rhs = -F
+ du, stats =
+ precond === nothing ?
+ Krylov.gmres(J, rhs; rtol=eta, atol=0.1 * atol, memory=memory, itmax=10n) :
+ Krylov.gmres(J, rhs; rtol=eta, atol=0.1 * atol, memory=memory, itmax=10n, N=precond, ldiv=true)
+ gmres_total += stats.niter
+ verbose && @info "newton iter $k: ‖F‖=$nF, η=$eta, gmres iters=$(stats.niter)"
+ # backtracking line search on ‖F‖
+ λ = 1.0
+ nFtrial = Inf
+ for _ in 1:ls_max
+ @. utrial = u + λ * du
+ f!(Ftrial, utrial)
+ nFtrial = norm(Ftrial)
+ nFtrial <= (1 - 1e-4 * λ) * nF && break
+ λ /= 2
+ end
+ nF_prev = nF
+ if nFtrial < nF
+ u .= utrial
+ F .= Ftrial
+ nF = nFtrial
+ else
+ verbose && @warn "newton line search stagnated at iter $k"
+ break
+ end
+ push!(hist, nF)
+ end
+ return (u=u, converged=(nF <= tol), iterations=k, residual_norms=hist, residual_max=maximum(abs, F), gmres_iters=gmres_total)
+end
+
+# ---------------------------------------------------------------------------
+# Pseudo-arclength continuation scaffold (03 §3)
+# ---------------------------------------------------------------------------
+"""
+ pseudo_arclength(f!, u0, p0; ds=0.1, nsteps=10, newton_kwargs...)
+
+Pseudo-arclength continuation scaffold for a parameterized residual
+`f!(out, u, p)` (`03 §3`): predictor along the (secant) tangent, corrector =
+[`newton_krylov`](@ref) on the extended system `[F(u, p); t·(z − z_pred)]`, and
+**fold detection from day one** via sign changes of the tangent's `p`-component
+(the Level-3 penetration bifurcation is a fold in this curve). Returns
+`(us, ps, folds)` where `folds` lists the step indices at which `dp/ds`
+reversed.
+"""
+function pseudo_arclength(f!, u0::AbstractVector{Float64}, p0::Real;
+ ds::Real=0.1, nsteps::Int=10, newton_kwargs...)
+ n = length(u0)
+ # converge onto the curve at fixed p0
+ sol = newton_krylov((out, u) -> f!(out, u, p0), collect(u0); newton_kwargs...)
+ sol.converged || error("pseudo_arclength: initial solve at p0 did not converge")
+ us = [copy(sol.u)]
+ ps = [float(p0)]
+ folds = Int[]
+ # initial tangent from du/dp: J du = -dF/dp (finite difference in p)
+ Fp = zeros(n)
+ Fm = zeros(n)
+ hp = max(1e-7, 1e-7 * abs(p0))
+ f!(Fp, sol.u, p0 + hp)
+ f!(Fm, sol.u, p0 - hp)
+ dFdp = (Fp .- Fm) ./ (2hp)
+ Jop = JVPOperator((out, u) -> f!(out, u, p0), copy(sol.u))
+ dudp, _ = Krylov.gmres(Jop, -dFdp; rtol=1e-10)
+ t = vcat(dudp, 1.0)
+ t ./= norm(t)
+ tp_prev = t[end]
+ for step in 1:nsteps
+ zpred = vcat(us[end], ps[end]) .+ ds .* t
+ tloc = copy(t) # freeze tangent for this corrector
+ function ext!(out, z)
+ f!(view(out, 1:n), view(z, 1:n), z[n+1])
+ out[n+1] = LinearAlgebra.dot(tloc, z) - LinearAlgebra.dot(tloc, zpred)
+ return out
+ end
+ sol = newton_krylov(ext!, zpred; newton_kwargs...)
+ sol.converged || break
+ push!(us, sol.u[1:n])
+ push!(ps, sol.u[n+1])
+ # secant tangent for the next step; fold when dp/ds changes sign
+ t = vcat(us[end] .- us[end-1], ps[end] - ps[end-1])
+ t ./= norm(t)
+ if t[end] * tp_prev < 0
+ push!(folds, step)
+ end
+ tp_prev = t[end]
+ end
+ return (us=us, ps=ps, folds=folds)
+end
+
+end # module Solvers
diff --git a/src/Islands/species/Species.jl b/src/Islands/species/Species.jl
new file mode 100644
index 00000000..f1948150
--- /dev/null
+++ b/src/Islands/species/Species.jl
@@ -0,0 +1,170 @@
+"""
+ SpeciesLists
+
+Species as a first-class dimension of the Islands solve (design `02 §1`,
+Decision D3): every kinetic object is indexed by species, and the species list
+is an array from day one even though Level-0 *physics* is single-bulk-ion.
+
+Pure data structures and bookkeeping — no physics coefficients live here.
+Quantities follow the Level-0 normalization (`01 §5`): densities to `n_e`,
+temperatures to `T_i`, masses to the reference bulk ion, gradients as inverse
+scale lengths at `r_s`.
+"""
+module SpeciesLists
+
+export AbstractBackground, Maxwellian, SlowingDown
+export SpeciesRole, Bulk, Trace
+export Species, is_bulk, is_trace, bulk_species, trace_species
+export validate_species, check_trace_criteria
+
+# ---------------------------------------------------------------------------
+# Backgrounds
+# ---------------------------------------------------------------------------
+"""
+ AbstractBackground
+
+Supertype of species background distributions (`02 §1.1`). `Maxwellian` is the
+Level-0 background; `SlowingDown` enters at Level 2 (energetic particles) and
+changes the energy-grid weight map, not the machinery.
+"""
+abstract type AbstractBackground end
+
+"""
+ Maxwellian(; n, T, dlnn_dr, dlnT_dr)
+
+Maxwellian background at the rational surface `r_s` (`02 §1.1`), carrying the
+strictly-local constant gradients of ordering O2.
+
+## Fields
+
+ - `n` — density normalized to `n_e`.
+ - `T` — temperature normalized to `T_i`.
+ - `dlnn_dr` — inverse density scale length `L̂_n⁻¹` at `r_s`.
+ - `dlnT_dr` — inverse temperature scale length `L̂_T⁻¹` at `r_s`.
+"""
+Base.@kwdef struct Maxwellian <: AbstractBackground
+ n::Float64
+ T::Float64
+ dlnn_dr::Float64 = 0.0
+ dlnT_dr::Float64 = 0.0
+end
+
+"""
+ SlowingDown(; S0, v_birth, v_crit, dlnS_dr)
+
+Slowing-down background (isotropic at Level-2 entry, `02 §3`). Present now so
+the background abstraction is exercised from day one; the Level-2 physics that
+consumes it is out of Level-0 scope.
+
+## Fields
+
+ - `S0` — source rate normalization.
+ - `v_birth` — birth speed (normalized to the reference thermal speed).
+ - `v_crit` — critical speed (from `T_e` and composition).
+ - `dlnS_dr` — inverse source scale length at `r_s`.
+"""
+Base.@kwdef struct SlowingDown <: AbstractBackground
+ S0::Float64
+ v_birth::Float64
+ v_crit::Float64
+ dlnS_dr::Float64 = 0.0
+end
+
+# ---------------------------------------------------------------------------
+# Roles and the species struct
+# ---------------------------------------------------------------------------
+"""
+ SpeciesRole
+
+`Bulk` = full nonlinear participant (its `g` enters the Newton state vector).
+`Trace` = linear post-processing pass with frozen bulk fields (`02 §1.2`) —
+one linear solve, an additive contribution to `Δ_cos`/`Δ_sin`.
+"""
+@enum SpeciesRole Bulk Trace
+
+"""
+ Species(; name, Z, m, background, role, collisional_coupling=false)
+
+One species of the Islands solve (`02 §1.1`).
+
+## Fields
+
+ - `name` — identifying symbol (unique within a list).
+ - `Z` — charge number (`Float64` to allow mean-charge-state impurities).
+ - `m` — mass ratio to the reference bulk ion.
+ - `background` — an `AbstractBackground` (Maxwellian at Level 0).
+ - `role` — `Bulk` or `Trace` (`02 §1.2`).
+ - `collisional_coupling` — whether the bulk feels this species' field-particle
+ back-reaction (`02 §1.3`; L1+ physics, plumbing present from day one).
+"""
+Base.@kwdef struct Species{B<:AbstractBackground}
+ name::Symbol
+ Z::Float64
+ m::Float64
+ background::B
+ role::SpeciesRole = Bulk
+ collisional_coupling::Bool = false
+end
+
+is_bulk(sp::Species) = sp.role == Bulk
+is_trace(sp::Species) = sp.role == Trace
+
+"""
+ bulk_species(list) / trace_species(list)
+
+Filter a species list by role.
+"""
+bulk_species(list::AbstractVector{<:Species}) = filter(is_bulk, list)
+trace_species(list::AbstractVector{<:Species}) = filter(is_trace, list)
+
+# ---------------------------------------------------------------------------
+# Validation and the trace-criteria check
+# ---------------------------------------------------------------------------
+"""
+ validate_species(list)
+
+Structural validation of a species list: unique names, positive `Z`-magnitude,
+`m`, density and temperature, and at least one `Bulk` species. Throws
+`ArgumentError` on violation; returns the list for chaining.
+"""
+function validate_species(list::AbstractVector{<:Species})
+ isempty(list) && throw(ArgumentError("species list is empty"))
+ names = [sp.name for sp in list]
+ length(unique(names)) == length(names) || throw(ArgumentError("duplicate species names: $names"))
+ any(is_bulk, list) || throw(ArgumentError("species list needs at least one Bulk species"))
+ for sp in list
+ sp.m > 0 || throw(ArgumentError("species $(sp.name): mass ratio must be positive"))
+ abs(sp.Z) > 0 || throw(ArgumentError("species $(sp.name): charge must be nonzero"))
+ if sp.background isa Maxwellian
+ sp.background.n > 0 || throw(ArgumentError("species $(sp.name): density must be positive"))
+ sp.background.T > 0 || throw(ArgumentError("species $(sp.name): temperature must be positive"))
+ end
+ end
+ return list
+end
+
+"""
+ check_trace_criteria(list; threshold=0.05)
+
+Check every `Trace` species against both trace criteria (`02 §1.2`): charge
+trace (`n_j Z_j ≪ n_e`) and current trace (`n_j ≪ n_e`), with densities already
+normalized to `n_e`. A violating species gets an `@warn` recommending `Bulk`
+promotion — **warn, never silently degrade** (`02 §1.2` promotion rule).
+`threshold` is a diagnostic heuristic knob for "≪", not a physics coefficient.
+Returns the vector of violating species names (empty when clean).
+"""
+function check_trace_criteria(list::AbstractVector{<:Species}; threshold::Real=0.05)
+ violators = Symbol[]
+ for sp in trace_species(list)
+ sp.background isa Maxwellian || continue
+ n = sp.background.n
+ charge_frac = n * abs(sp.Z)
+ if charge_frac > threshold || n > threshold
+ push!(violators, sp.name)
+ @warn "species $(sp.name) violates trace criteria (n Z = $(charge_frac), n = $(n) vs threshold $(threshold)); run it as Bulk" maxlog = 1
+ end
+ end
+ return violators
+end
+
+end # module SpeciesLists
diff --git a/src/Islands/verify/Verify.jl b/src/Islands/verify/Verify.jl
new file mode 100644
index 00000000..b41f3054
--- /dev/null
+++ b/src/Islands/verify/Verify.jl
@@ -0,0 +1,460 @@
+"""
+ Verify
+
+The Islands verification harness (`04 §4`, `05 §A`): manufactured-solution (MMS)
+convergence checks and AD-vs-finite-difference JVP checks for the operator
+stack. Callable from the test suite (`test/runtests_islands_*.jl`) and from
+scripts.
+
+**These are structural (pre-physics) checks — ladder A1/A2.** They exercise the
+*discretization order* and the *AD plumbing* using arbitrary, order-unity
+manufactured coefficients. That is deliberate and policy-clean: nothing here is
+a physics coefficient, so nothing here carries a `[VERIFY]` tag. Physics
+benchmarks (ladder B+) live in `benchmarks/islands/` and stay `[VERIFY]`-gated
+until human-cleared.
+
+Design orders checked:
+
+ - `ξ` derivatives — Fourier spectral (a bandlimited manufactured `ξ`-profile is
+ differentiated to machine precision).
+ - `x`, `y` derivatives — high-order finite differences at the grid `order`
+ (default 4): halving the mesh cuts the error by `2^order`.
+ - assembled kinetic residual — the min of the above (algebraic, set by `x`/`y`).
+"""
+module Verify
+
+using LinearAlgebra
+import ForwardDiff
+import ..PhaseSpace: IslandGrid, MappedFDGrid, GaussGrid, nnodes
+import ..Operators: IslandState, IslandCache, IslandStack, residual!, velocity_moment!,
+ apply!, statelength, flatten!, unflatten!, g_flat_index,
+ ParallelStreaming, MagneticDrift, ExBDrift, Collisions, GradientDrive,
+ PerpTransport, RadiationSink, Quasineutrality, FarFieldConditions
+import ..Solvers: flat_residual, newton_krylov
+
+export manufactured_state,
+ test_coefficients, build_stack,
+ mms_operator_error, mms_assembled_error, estimate_order,
+ jvp_fd_maxerror, moment_selfconvergence,
+ term_allocations, residual_allocations,
+ yc_block_sigma_min, solve_mms, zero_drive_setup
+
+# ---------------------------------------------------------------------------
+# Manufactured solution: smooth, separable, ξ-periodic and bandlimited.
+# Each factor's analytic first/second derivatives are known in closed form,
+# so the continuous value of every operator is exact at the nodes.
+# ---------------------------------------------------------------------------
+_Gx(x) = exp(-x^2 / 2)
+_Gx′(x) = -x * _Gx(x)
+_Gx″(x) = (x^2 - 1) * _Gx(x)
+_Gξ(ξ) = sin(ξ) + 0.5 * cos(2ξ)
+_Gξ′(ξ) = cos(ξ) - sin(2ξ)
+_Gy(y) = exp(-(y - 1)^2 / 2)
+_Gy′(y) = -(y - 1) * _Gy(y)
+_Gy″(y) = ((y - 1)^2 - 1) * _Gy(y)
+_GE(E) = exp(-0.3 * E)
+_Gσ(σ) = 1 + 0.25 * σ
+
+_Px(x) = exp(-x^2 / 4)
+_Px′(x) = -(x / 2) * _Px(x)
+_Pξ(ξ) = cos(ξ)
+_Pξ′(ξ) = -sin(ξ)
+
+# Fill a 5D array over (x, ξ, y, E, σ) from a scalar function of the node coords.
+function _fill5(grid::IslandGrid, f)
+ nx, nξ, ny, nE, nσ = nnodes(grid)
+ A = Array{Float64}(undef, nx, nξ, ny, nE, nσ)
+ @inbounds for iσ in 1:nσ, iE in 1:nE, iy in 1:ny, iξ in 1:nξ, ix in 1:nx
+ A[ix, iξ, iy, iE, iσ] = f(grid.x.nodes[ix], grid.ξ.nodes[iξ], grid.y.nodes[iy],
+ grid.E.nodes[iE], grid.σ[iσ])
+ end
+ return A
+end
+
+function _fill2(grid::IslandGrid, f)
+ nx, nξ, = nnodes(grid)
+ A = Array{Float64}(undef, nx, nξ)
+ @inbounds for iξ in 1:nξ, ix in 1:nx
+ A[ix, iξ] = f(grid.x.nodes[ix], grid.ξ.nodes[iξ])
+ end
+ return A
+end
+
+"""
+ manufactured_state(grid)
+
+Return `(U, deriv)` where `U::IslandState` holds the manufactured `g*`, `Φ*` on
+`grid` and `deriv` is a NamedTuple of the analytic partial-derivative arrays
+(`dgdx`, `dgdξ`, `dgdy`, `d2gdy2`, `d2gdx2`, `dΦdx`, `dΦdξ`) used to form exact
+continuous operator values.
+"""
+function manufactured_state(grid::IslandGrid)
+ g = _fill5(grid, (x, ξ, y, E, σ) -> _Gx(x) * _Gξ(ξ) * _Gy(y) * _GE(E) * _Gσ(σ))
+ Φ = _fill2(grid, (x, ξ) -> _Px(x) * _Pξ(ξ))
+ U = IslandState(g, Φ)
+ deriv = (
+ dgdx=_fill5(grid, (x, ξ, y, E, σ) -> _Gx′(x) * _Gξ(ξ) * _Gy(y) * _GE(E) * _Gσ(σ)),
+ dgdξ=_fill5(grid, (x, ξ, y, E, σ) -> _Gx(x) * _Gξ′(ξ) * _Gy(y) * _GE(E) * _Gσ(σ)),
+ dgdy=_fill5(grid, (x, ξ, y, E, σ) -> _Gx(x) * _Gξ(ξ) * _Gy′(y) * _GE(E) * _Gσ(σ)),
+ d2gdy2=_fill5(grid, (x, ξ, y, E, σ) -> _Gx(x) * _Gξ(ξ) * _Gy″(y) * _GE(E) * _Gσ(σ)),
+ d2gdx2=_fill5(grid, (x, ξ, y, E, σ) -> _Gx″(x) * _Gξ(ξ) * _Gy(y) * _GE(E) * _Gσ(σ)),
+ dΦdx=_fill2(grid, (x, ξ) -> _Px′(x) * _Pξ(ξ)),
+ dΦdξ=_fill2(grid, (x, ξ) -> _Px(x) * _Pξ′(ξ))
+ )
+ return U, deriv
+end
+
+# ---------------------------------------------------------------------------
+# Test coefficients: arbitrary, smooth, order-unity. NOT physics — they exercise
+# the discretization. `a_y > 0` keeps the pitch operator a sensible diffusion.
+# ---------------------------------------------------------------------------
+"""
+ test_coefficients(grid)
+
+Build a NamedTuple of arbitrary smooth manufactured coefficient arrays/scalars
+for every term (`a_xi`, `a_x`, `c_D`, `a_y`, `b_y`, `drive`, `c_E`, `χ`, `α`).
+These are MMS test values, not physics coefficients.
+"""
+function test_coefficients(grid::IslandGrid)
+ return (
+ a_xi=_fill5(grid, (x, ξ, y, E, σ) -> 1.0 + 0.2 * cos(ξ) + 0.1 * x),
+ a_x=_fill5(grid, (x, ξ, y, E, σ) -> 0.8 + 0.1 * sin(2ξ) + 0.05 * y),
+ c_D=_fill5(grid, (x, ξ, y, E, σ) -> 0.5 * σ * (1 + 0.1 * E)),
+ a_y=_fill5(grid, (x, ξ, y, E, σ) -> 0.3 + 0.05 * x^2),
+ b_y=_fill5(grid, (x, ξ, y, E, σ) -> 0.1 * (y - 1)),
+ drive=_fill5(grid, (x, ξ, y, E, σ) -> 0.2 * exp(-x^2) * cos(ξ)),
+ c_E=0.7,
+ χ=0.15,
+ α=1.3
+ )
+end
+
+# ---------------------------------------------------------------------------
+# Continuous (exact) operator values from the analytic manufactured derivatives.
+# ---------------------------------------------------------------------------
+function _continuous(term::Symbol, deriv, c)
+ if term === :streaming
+ return c.a_xi .* deriv.dgdξ .+ c.a_x .* deriv.dgdx
+ elseif term === :drift
+ return c.c_D .* deriv.dgdξ
+ elseif term === :collisions
+ return c.a_y .* deriv.d2gdy2 .+ c.b_y .* deriv.dgdy
+ elseif term === :perp
+ return c.χ .* deriv.d2gdx2
+ elseif term === :drive
+ return c.drive
+ elseif term === :exb
+ # broadcast the 2D potential gradients over (y, E, σ)
+ dΦdx = reshape(deriv.dΦdx, size(deriv.dΦdx, 1), size(deriv.dΦdx, 2), 1, 1, 1)
+ dΦdξ = reshape(deriv.dΦdξ, size(deriv.dΦdξ, 1), size(deriv.dΦdξ, 2), 1, 1, 1)
+ return c.c_E .* (dΦdξ .* deriv.dgdx .- dΦdx .* deriv.dgdξ)
+ else
+ throw(ArgumentError("unknown term $term"))
+ end
+end
+
+function _term_object(term::Symbol, c)
+ if term === :streaming
+ return ParallelStreaming(c.a_xi, c.a_x)
+ elseif term === :drift
+ return MagneticDrift(c.c_D; variant=:original)
+ elseif term === :collisions
+ return Collisions(c.a_y, c.b_y; model=:pitch_angle)
+ elseif term === :perp
+ return PerpTransport(c.χ)
+ elseif term === :drive
+ return GradientDrive(c.drive)
+ elseif term === :exb
+ return ExBDrift(c.c_E)
+ else
+ throw(ArgumentError("unknown term $term"))
+ end
+end
+
+# ---------------------------------------------------------------------------
+# MMS error drivers
+# ---------------------------------------------------------------------------
+"""
+ mms_operator_error(grid, term)
+
+Max-norm error between the discrete `apply!` of a single kinetic `term`
+(`:streaming`, `:drift`, `:exb`, `:collisions`, `:perp`, `:drive`) on the
+manufactured state and its exact continuous value on `grid`.
+"""
+function mms_operator_error(grid::IslandGrid, term::Symbol)
+ U, deriv = manufactured_state(grid)
+ c = test_coefficients(grid)
+ R = IslandState(grid)
+ cache = IslandCache(grid)
+ apply!(R, _term_object(term, c), U, grid, cache)
+ exact = _continuous(term, deriv, c)
+ return maximum(abs, R.g .- exact)
+end
+
+"""
+ mms_assembled_error(grid; terms=(:streaming,:drift,:exb,:collisions,:perp,:drive))
+
+Max-norm error between the assembled discrete kinetic residual (sum of `terms`)
+on the manufactured state and the summed continuous values.
+"""
+function mms_assembled_error(grid::IslandGrid;
+ terms=(:streaming, :drift, :exb, :collisions, :perp, :drive))
+ U, deriv = manufactured_state(grid)
+ c = test_coefficients(grid)
+ R = IslandState(grid)
+ cache = IslandCache(grid)
+ exact = zeros(size(R.g))
+ for t in terms
+ apply!(R, _term_object(t, c), U, grid, cache)
+ exact .+= _continuous(t, deriv, c)
+ end
+ return maximum(abs, R.g .- exact)
+end
+
+"""
+ estimate_order(errors, refine)
+
+Observed convergence order from a sequence of `errors` at mesh-refinement
+factors `refine` (ratio of successive node counts): `log(eₖ/eₖ₊₁)/log(rₖ)`.
+Returns the vector of per-step orders.
+"""
+function estimate_order(errors::AbstractVector, refine::AbstractVector)
+ return [log(errors[k] / errors[k+1]) / log(refine[k]) for k in 1:(length(errors)-1)]
+end
+
+# ---------------------------------------------------------------------------
+# Assembled stack for the JVP check (includes the E×B nonlinearity + field).
+# ---------------------------------------------------------------------------
+"""
+ build_stack(grid; coeffs=test_coefficients(grid))
+
+Assemble an `IslandStack` with the full Level-0-shaped term set (streaming,
+drift, E×B, collisions, drive) plus the quasineutrality field term, using the
+supplied (manufactured) coefficients. Used by the JVP check.
+"""
+function build_stack(grid::IslandGrid; coeffs=test_coefficients(grid))
+ kinetic = (
+ ParallelStreaming(coeffs.a_xi, coeffs.a_x),
+ MagneticDrift(coeffs.c_D; variant=:original),
+ ExBDrift(coeffs.c_E),
+ Collisions(coeffs.a_y, coeffs.b_y; model=:pitch_angle),
+ GradientDrive(coeffs.drive)
+ )
+ return IslandStack(kinetic, Quasineutrality(coeffs.α))
+end
+
+"""
+ jvp_fd_maxerror(grid; h=1e-6, seed=1)
+
+Compare the forward-mode-AD Jacobian–vector product of the assembled residual
+against a central finite-difference directional derivative, at the manufactured
+state in a deterministic direction. Returns the max-norm difference (ladder A2).
+The residual is nonlinear (E×B bracket + g/Φ coupling), so this is a genuine
+check of both the AD plumbing and its correctness.
+"""
+function jvp_fd_maxerror(grid::IslandGrid; h::Float64=1e-6, seed::Int=1)
+ stack = build_stack(grid)
+ cache_f = IslandCache{Float64}(grid)
+ U, = manufactured_state(grid)
+
+ N = statelength(grid)
+ u0 = Vector{Float64}(undef, N)
+ flatten!(u0, U)
+ # deterministic direction (no RNG dependence)
+ v = Float64[0.5 + 0.5 * sin(seed * k) for k in 1:N]
+ v ./= norm(v)
+
+ # residual as a flat-vector function, generic over eltype
+ function resid_vec!(out, uvec)
+ T = eltype(uvec)
+ Ut = IslandState(reshape(view(uvec, 1:length(U.g)), size(U.g)),
+ reshape(view(uvec, (length(U.g)+1):length(uvec)), size(U.Φ)))
+ Rt = IslandState(reshape(view(out, 1:length(U.g)), size(U.g)),
+ reshape(view(out, (length(U.g)+1):length(out)), size(U.Φ)))
+ cache = IslandCache{T}(grid)
+ residual!(Rt, Ut, stack, grid, cache)
+ return out
+ end
+
+ # AD JVP: d/dε residual(u0 + ε v) |_{ε=0}
+ jvp_ad = ForwardDiff.derivative(0.0) do ε
+ out = Vector{typeof(ε)}(undef, N)
+ resid_vec!(out, u0 .+ ε .* v)
+ end
+
+ # central-difference JVP
+ rp = similar(u0)
+ rm = similar(u0)
+ resid_vec!(rp, u0 .+ h .* v)
+ resid_vec!(rm, u0 .- h .* v)
+ jvp_fd = (rp .- rm) ./ (2h)
+
+ return maximum(abs, jvp_ad .- jvp_fd)
+end
+
+# ---------------------------------------------------------------------------
+# Velocity-moment quadrature self-convergence (Simpson in y, at grid order).
+# ---------------------------------------------------------------------------
+"""
+ moment_selfconvergence(grid_coarse, grid_fine)
+
+Max-norm difference between the velocity moment of the manufactured `g*` on two
+`y`-resolutions. The two grids must share `(nx, nξ)` so the moments live on the
+same `(x, ξ)` grid and compare pointwise; refine `ny` (and/or `nE`) between them
+to probe the `y`-quadrature order.
+"""
+function moment_selfconvergence(grid_coarse::IslandGrid, grid_fine::IslandGrid)
+ nnodes(grid_coarse)[1:2] == nnodes(grid_fine)[1:2] ||
+ throw(ArgumentError("moment_selfconvergence needs grids sharing (nx, nξ)"))
+ Uc, = manufactured_state(grid_coarse)
+ Uf, = manufactured_state(grid_fine)
+ Mc = zeros(nnodes(grid_coarse)[1], nnodes(grid_coarse)[2])
+ Mf = zeros(nnodes(grid_fine)[1], nnodes(grid_fine)[2])
+ velocity_moment!(Mc, Uc.g, grid_coarse)
+ velocity_moment!(Mf, Uf.g, grid_fine)
+ return maximum(abs, Mc .- Mf)
+end
+
+# ---------------------------------------------------------------------------
+# Allocation probes (hot-path allocation regression, `04 §9`).
+# ---------------------------------------------------------------------------
+"""
+ term_allocations(grid, term)
+
+Bytes allocated by a single warmed `apply!` of kinetic `term` on `grid`. Should
+be zero for the allocation-free hot path.
+"""
+function term_allocations(grid::IslandGrid, term::Symbol)
+ c = test_coefficients(grid)
+ U, = manufactured_state(grid)
+ R = IslandState(grid)
+ cache = IslandCache(grid)
+ t = _term_object(term, c)
+ apply!(R, t, U, grid, cache) # warm up compilation
+ return @allocated apply!(R, t, U, grid, cache)
+end
+
+"""
+ residual_allocations(grid; coeffs=test_coefficients(grid))
+
+Bytes allocated by a single warmed full-`residual!` assembly on `grid`. Should
+be zero for the allocation-free hot path.
+"""
+function residual_allocations(grid::IslandGrid; coeffs=test_coefficients(grid))
+ stack = build_stack(grid; coeffs=coeffs)
+ U, = manufactured_state(grid)
+ R = IslandState(grid)
+ cache = IslandCache(grid)
+ residual!(R, U, stack, grid, cache) # warm up compilation
+ return @allocated residual!(R, U, stack, grid, cache)
+end
+
+# ---------------------------------------------------------------------------
+# Solve-level MMS configurations (ladder A1-solve, A5) — the manufactured
+# configurations live here per 04 §4 so tests and benchmark scripts share them.
+# ---------------------------------------------------------------------------
+"""
+ zero_drive_setup(grid)
+
+The ladder-A5 configuration: the manufactured advective stack with **all drives
+off** (no `GradientDrive`, no source), so `g ≡ 0, Φ ≡ 0` is the exact solution
+and the residual there is machine zero (`01 §6`). Returns `(f, N)` — the flat
+residual function and state length — for the null test and the trivial-Newton
+check. The `RadiationSink(−1)` entry is a **unit relaxation shift** (a
+manufactured test coefficient, not physics) making the zero state locally unique.
+"""
+function zero_drive_setup(grid::IslandGrid)
+ c = test_coefficients(grid)
+ shift = fill(-1.0, size(c.drive))
+ kin = (ParallelStreaming(c.a_xi, c.a_x), MagneticDrift(c.c_D; variant=:original),
+ ExBDrift(c.c_E), Collisions(c.a_y, c.b_y; model=:pitch_angle), RadiationSink(shift))
+ stack = IslandStack(kin, Quasineutrality(c.α))
+ return (f=flat_residual(stack, grid), N=statelength(grid))
+end
+
+"""
+ solve_mms(nx; nxi=8, ny=9, nE=2, rtol=1e-10, memory=300)
+
+The assembled **solve-level** MMS (the ladder-A1 extension to a full converged
+Newton–Krylov solve): the advective manufactured stack (streaming + drift +
+E×B + unit relaxation shift + quasineutrality) with far-field matching BCs
+taken from the manufactured state, forced by the *analytic* continuous source
+so the discrete solution differs from `g*` by the discretization error. The
+first-order-in-`x` stack needs only the `x` far-field conditions (the pitch
+operator's degenerate zero-flux structure is exercised separately, ladder A4).
+Returns `(err, converged, iterations, gmres_iters)` where `err` is the max-norm
+solution error against the manufactured state — refining `nx` must show the
+design order.
+"""
+function solve_mms(nx::Int; nxi::Int=8, ny::Int=9, nE::Int=2, rtol::Real=1e-10, memory::Int=300)
+ grid = IslandGrid(; nx=nx, nxi=nxi, ny=ny, nE=nE, halfwidth_x=6.0, clustering_x=1.0,
+ y_max=4.0, y_c=1.0, clustering_y=0.8, order=4)
+ c = test_coefficients(grid)
+ shift = fill(-1.0, size(c.drive))
+ kin = (ParallelStreaming(c.a_xi, c.a_x), MagneticDrift(c.c_D; variant=:original),
+ ExBDrift(c.c_E), RadiationSink(shift))
+ stack = IslandStack(kin, Quasineutrality(c.α))
+ Ustar, deriv = manufactured_state(grid)
+ bc = FarFieldConditions(Ustar.g[1, :, :, :, :], Ustar.g[nx, :, :, :, :], Ustar.Φ[1, :], Ustar.Φ[nx, :])
+ f0! = flat_residual(stack, grid; bc=bc)
+ N = statelength(grid)
+ # continuous source: the analytic values of every active term at (g*, Φ*)
+ Sg = c.a_xi .* deriv.dgdξ .+ c.a_x .* deriv.dgdx .+ c.c_D .* deriv.dgdξ .+ Ustar.g
+ dPx = reshape(deriv.dΦdx, nx, nxi, 1, 1, 1)
+ dPξ = reshape(deriv.dΦdξ, nx, nxi, 1, 1, 1)
+ Sg .+= c.c_E .* (dPξ .* deriv.dgdx .- dPx .* deriv.dgdξ)
+ Sg[1, :, :, :, :] .= 0.0
+ Sg[nx, :, :, :, :] .= 0.0 # BC rows carry no source
+ MΦ = zeros(nx, nxi)
+ velocity_moment!(MΦ, Ustar.g, grid)
+ SΦ = MΦ .- c.α .* Ustar.Φ # field rows: discretely consistent source
+ SΦ[1, :] .= 0.0
+ SΦ[nx, :] .= 0.0
+ S = zeros(N)
+ flatten!(S, IslandState(Sg, SΦ))
+ f!(out, u) = (f0!(out, u); out .-= S; out)
+ sol = newton_krylov(f!, zeros(N); rtol=rtol, atol=1e-13, max_iter=30, memory=memory)
+ ustar = zeros(N)
+ flatten!(ustar, Ustar)
+ return (err=maximum(abs, sol.u .- ustar), converged=sol.converged,
+ iterations=sol.iterations, gmres_iters=sol.gmres_iters)
+end
+
+# ---------------------------------------------------------------------------
+# y_c matching-block conditioning monitor (ladder A8, 04 §3).
+# ---------------------------------------------------------------------------
+"""
+ yc_block_sigma_min(J, grid; window=3)
+
+Ladder-A8 conditioning monitor: the smallest singular value of the pitch-pencil
+sub-block of the (tiny-grid debug) Jacobian `J` nearest the trapped–passing
+boundary `y_c`, minimized over all `(x, ξ, E, σ)` pencils. The prior art's
+`y_c` matching system was intrinsically near-singular (rcond ≈ 1e-16, L23 §4.2)
+and produced machine-dependent **noise, not crashes** — so this must be
+*tested for*, not observed. Returns `(sigma_min, pencil)` where `pencil` is the
+`(ix, iξ, iE, iσ)` of the minimizing block.
+"""
+function yc_block_sigma_min(J::AbstractMatrix, grid::IslandGrid; window::Int=3)
+ nx, nξ, ny, nE, nσ = nnodes(grid)
+ window <= ny || throw(ArgumentError("window ($window) exceeds ny ($ny)"))
+ # contiguous y-index window centered on the node nearest y_c
+ ic = argmin(abs.(grid.y.nodes .- grid.y_c))
+ lo = clamp(ic - window ÷ 2, 1, ny - window + 1)
+ iys = lo:(lo+window-1)
+ σmin = Inf
+ pencil = (0, 0, 0, 0)
+ idx = Vector{Int}(undef, window)
+ for iσ in 1:nσ, iE in 1:nE, iξ in 1:nξ, ix in 1:nx
+ for (k, iy) in enumerate(iys)
+ idx[k] = g_flat_index(grid, ix, iξ, iy, iE, iσ)
+ end
+ s = svdvals(J[idx, idx])[end]
+ if s < σmin
+ σmin = s
+ pencil = (ix, iξ, iE, iσ)
+ end
+ end
+ return (sigma_min=σmin, pencil=pencil)
+end
+
+end # module Verify
diff --git a/test/runtests.jl b/test/runtests.jl
index 2ae4125e..98a7bfb5 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -36,4 +36,7 @@ else
include("./runtests_coils.jl")
include("./runtests_imas.jl")
include("./runtests_rerun_from_h5.jl")
+ include("./runtests_islands_grids.jl")
+ include("./runtests_islands_operators.jl")
+ include("./runtests_islands_solve.jl")
end
diff --git a/test/runtests_islands_grids.jl b/test/runtests_islands_grids.jl
new file mode 100644
index 00000000..10dd3833
--- /dev/null
+++ b/test/runtests_islands_grids.jl
@@ -0,0 +1,93 @@
+# runtests_islands_grids.jl
+#
+# Islands module — phase-space grid / discretization unit tests (src/Islands/phasespace).
+# Pure numerics: spectral and finite-difference differentiation and quadrature.
+# No physics coefficients here (nothing [VERIFY]-tagged); these back the MMS
+# ladder A1 checks in runtests_islands_operators.jl.
+
+const PS = GeneralizedPerturbedEquilibrium.Islands.PhaseSpace
+
+@testset "Islands phase-space grids" begin
+
+ @testset "Fourier spectral ∂ξ is exact for bandlimited data" begin
+ fg = PS.FourierGrid(16; L=2π)
+ x = fg.nodes
+ g = @. sin(3x) + cos(2x) - 0.5 * sin(x)
+ dg_exact = @. 3cos(3x) - 2sin(2x) - 0.5cos(x)
+ @test maximum(abs, fg.D1 * g .- dg_exact) < 1e-12
+ # odd node count is rejected
+ @test_throws ArgumentError PS.FourierGrid(15)
+ end
+
+ @testset "Mapped FD converges at design order (uniform grid)" begin
+ # smooth decaying test function on [-6, 6]
+ f(x) = exp(-x^2 / 2)
+ f′(x) = -x * exp(-x^2 / 2)
+ f″(x) = (x^2 - 1) * exp(-x^2 / 2)
+ err1 = Float64[]
+ err2 = Float64[]
+ ns = [17, 33, 65]
+ for n in ns
+ g = PS.MappedFDGrid(n; halfwidth=6.0, order=4)
+ xn = g.nodes
+ push!(err1, maximum(abs, g.D1 * f.(xn) .- f′.(xn)))
+ push!(err2, maximum(abs, g.D2 * f.(xn) .- f″.(xn)))
+ end
+ # 4th-order: halving h cuts error by ≳ 2^4 (allow margin for pre-asymptotics)
+ @test log(err1[2] / err1[3]) / log(ns[3] / ns[2]) > 3.7
+ @test log(err2[2] / err2[3]) / log(ns[3] / ns[2]) > 3.7
+ @test err1[end] < 1e-3
+ @test err2[end] < 1e-3
+ end
+
+ @testset "Layer-clustered map preserves order and packs the center" begin
+ # a strongly clustered grid still differentiates a smooth function accurately
+ g = PS.MappedFDGrid(65; halfwidth=6.0, clustering=2.0, order=4)
+ # node spacing is smallest near the clustering center (x = 0)
+ Δ = diff(g.nodes)
+ icenter = argmin(abs.(g.nodes[1:(end-1)] .+ g.nodes[2:end]) ./ 2)
+ @test Δ[icenter] < Δ[1]
+ @test Δ[icenter] < Δ[end]
+ f(x) = sin(x) * exp(-x^2 / 8)
+ f′(x) = (cos(x) - x / 4 * sin(x)) * exp(-x^2 / 8)
+ @test maximum(abs, g.D1 * f.(g.nodes) .- f′.(g.nodes)) < 1e-3
+ end
+
+ @testset "Half-domain grid packs at y_c and spans [0, y_max]" begin
+ g = PS.MappedFDGrid(17; halfwidth=4.0, clustering=1.0, center=1.0, domain=:half, order=4)
+ @test g.nodes[1] ≈ 0.0 atol = 1e-12
+ @test g.nodes[end] ≈ 4.0 atol = 1e-12
+ @test issorted(g.nodes)
+ end
+
+ @testset "Simpson quadrature weights integrate at design order" begin
+ # ∫_0^4 exp(-(y-1)^2/2) dy — self-convergence (no closed form needed):
+ # successive-refinement differences must shrink at ≳ 4th order.
+ quad(n) =
+ let g = PS.MappedFDGrid(n; halfwidth=4.0, clustering=1.0, center=1.0, domain=:half, order=4)
+ sum(g.wq .* exp.(-(g.nodes .- 1) .^ 2 ./ 2))
+ end
+ q = quad.([17, 33, 65, 129])
+ d1, d2, d3 = abs(q[2] - q[1]), abs(q[3] - q[2]), abs(q[4] - q[3])
+ @test log(d1 / d2) / log(2) > 3.3
+ @test log(d2 / d3) / log(2) > 3.3
+ end
+
+ @testset "Gauss–Laguerre quadrature is exact on polynomials × e^{-E}" begin
+ gg = PS.GaussGrid(6; kind=:laguerre)
+ # ∫_0^∞ E^k e^{-E} dE = k!
+ for (k, want) in ((0, 1.0), (1, 1.0), (2, 2.0), (3, 6.0), (4, 24.0))
+ @test sum(gg.weights .* gg.nodes .^ k) ≈ want rtol = 1e-10
+ end
+ end
+
+ @testset "IslandGrid assembles all five coordinates" begin
+ ig = PS.IslandGrid(; nx=17, nxi=16, ny=9, nE=4, halfwidth_x=6.0, clustering_x=1.5,
+ y_max=4.0, y_c=1.0, clustering_y=1.0)
+ @test PS.nnodes(ig) == (17, 16, 9, 4, 2)
+ @test ig.σ == [1.0, -1.0]
+ @test length(ig.x.wq) == 17
+ # even nx is rejected (Simpson needs odd)
+ @test_throws ArgumentError PS.MappedFDGrid(16; halfwidth=6.0)
+ end
+end
diff --git a/test/runtests_islands_operators.jl b/test/runtests_islands_operators.jl
new file mode 100644
index 00000000..007a3ee7
--- /dev/null
+++ b/test/runtests_islands_operators.jl
@@ -0,0 +1,97 @@
+# runtests_islands_operators.jl
+#
+# Islands operator-stack skeleton — verification ladder A1/A2 (docs/src/islands/design/05).
+# A1 MMS: per-operator and assembled-system convergence at design order.
+# A2 JVP (forward-mode AD) vs. finite-difference residual directional derivative.
+# + allocation regression for the `apply!` / `residual!` hot paths (docs/04 §9).
+#
+# These are STRUCTURAL (pre-physics) checks. The manufactured coefficients they
+# use are arbitrary order-unity test values — not physics — so nothing here is
+# [VERIFY]-gated. Physics benchmarks (ladder B+) stay skipped until human-cleared.
+
+const Isl = GeneralizedPerturbedEquilibrium.Islands
+const V = Isl.Verify
+const PSg = Isl.PhaseSpace
+const Op = Isl.Operators
+
+# nx = ny refined together; ξ bandlimited (nxi small is exact); nE small.
+_grid(n) = PSg.IslandGrid(; nx=n, nxi=8, ny=n, nE=3, halfwidth_x=6.0, clustering_x=1.0,
+ y_max=4.0, y_c=1.0, clustering_y=0.8, order=4)
+_order(e_coarse, e_fine, n_coarse, n_fine) = log(e_coarse / e_fine) / log(n_fine / n_coarse)
+
+@testset "Islands operator stack (A1/A2)" begin
+
+ @testset "A1 — per-operator MMS convergence at design order" begin
+ # x/y differential operators: fourth-order finite differences.
+ for term in (:streaming, :exb, :collisions, :perp)
+ e17 = V.mms_operator_error(_grid(17), term)
+ e33 = V.mms_operator_error(_grid(33), term)
+ @test _order(e17, e33, 17, 33) > 3.3
+ @test e33 < e17
+ end
+ # ξ-only operator (magnetic drift): Fourier spectral → machine precision
+ # on the bandlimited manufactured ξ-profile.
+ @test V.mms_operator_error(_grid(17), :drift) < 1e-10
+ # state-independent source is reproduced exactly (no discretization).
+ @test V.mms_operator_error(_grid(17), :drive) < 1e-12
+ end
+
+ @testset "A1 — assembled kinetic-residual MMS convergence" begin
+ e17 = V.mms_assembled_error(_grid(17))
+ e33 = V.mms_assembled_error(_grid(33))
+ @test _order(e17, e33, 17, 33) > 3.3
+ @test e33 < 5e-3
+ end
+
+ @testset "A1 — velocity-moment quadrature convergence (refine y)" begin
+ mky(ny) = PSg.IslandGrid(; nx=9, nxi=8, ny=ny, nE=3, halfwidth_x=6.0, clustering_x=1.0,
+ y_max=4.0, y_c=1.0, clustering_y=0.8, order=4)
+ d1 = V.moment_selfconvergence(mky(17), mky(33))
+ d2 = V.moment_selfconvergence(mky(33), mky(65))
+ @test _order(d1, d2, 33, 65) > 3.3
+ # grids must share (nx, nξ)
+ @test_throws ArgumentError V.moment_selfconvergence(mky(17), _grid(17))
+ end
+
+ @testset "A2 — AD JVP matches finite-difference directional derivative" begin
+ # residual is nonlinear (E×B bracket couples g and Φ), so this exercises
+ # the AD plumbing and its correctness together.
+ @test V.jvp_fd_maxerror(_grid(9)) < 1e-6
+ @test V.jvp_fd_maxerror(_grid(9); seed=7) < 1e-6
+ end
+
+ @testset "allocation regression — apply!/residual! are allocation-free" begin
+ g = _grid(9)
+ for term in (:streaming, :drift, :exb, :collisions, :perp, :drive)
+ @test V.term_allocations(g, term) == 0
+ end
+ @test V.residual_allocations(g) == 0
+ end
+
+ @testset "structural — state, stack, flatten/unflatten" begin
+ g = _grid(9)
+ U, = V.manufactured_state(g)
+ @test eltype(U) == Float64
+ @test size(U.g) == PSg.nnodes(g)
+ @test size(U.Φ) == (9, 8)
+
+ # flatten/unflatten round-trips
+ n = Op.statelength(g)
+ @test n == prod(PSg.nnodes(g)) + 9 * 8
+ v = zeros(n)
+ Op.flatten!(v, U)
+ U2 = Op.IslandState(g)
+ Op.unflatten!(U2, v)
+ @test U2.g == U.g && U2.Φ == U.Φ
+
+ # residual! runs and produces a finite, correctly-shaped result
+ stack = V.build_stack(g)
+ R = Op.IslandState(g)
+ cache = Op.IslandCache(g)
+ Op.residual!(R, U, stack, g, cache)
+ @test all(isfinite, R.g) && all(isfinite, R.Φ)
+
+ # the magnetic-drift variant toggle is carried on the term
+ @test Op.MagneticDrift(zeros(1, 1, 1, 1, 1); variant=:improved).variant == :improved
+ end
+end
diff --git a/test/runtests_islands_solve.jl b/test/runtests_islands_solve.jl
new file mode 100644
index 00000000..2a56c7cf
--- /dev/null
+++ b/test/runtests_islands_solve.jl
@@ -0,0 +1,227 @@
+# runtests_islands_solve.jl
+#
+# Islands M2 — Level-0 solve machinery, structural verification gates
+# (docs/src/islands/design/05 §A; the M2 milestone contract):
+# A5 zero-drive null: g ≡ 0 ⇒ residual = machine zero; Newton converges trivially.
+# A1+ assembled solve-MMS: Newton–Krylov recovers the manufactured state at design order.
+# A8 y_c matching-block smallest-singular-value conditioning monitor.
+# A4 (L0) exact discrete particle conservation + entropy sign of the mimetic
+# pitch-angle collision operator.
+# A3 Δ_cos even / Δ_sin odd parity under ξ-reflection (manufactured J̄_∥).
+# A7 the coefficient-free closure identity ⟨∂²h/∂x²⟩_Ω = 0.
+# + preconditioner quality (GMRES iteration reduction), far-field BC rows,
+# pseudo-arclength fold detection, species/frames plumbing.
+#
+# STRUCTURAL (pre-physics) checks: manufactured order-unity coefficients only.
+# Every physics coefficient in src/ is a [VERIFY]-gated supplied parameter
+# (QUESTIONS Q2–Q4); the York-gate physics benchmarks stay skipped in
+# benchmarks/islands/ until human clearance.
+
+using LinearAlgebra
+
+const IslM2 = GeneralizedPerturbedEquilibrium.Islands
+const PS2 = IslM2.PhaseSpace
+const Op2 = IslM2.Operators
+const So2 = IslM2.Solvers
+const V2 = IslM2.Verify
+const Mo2 = IslM2.Moments
+const Fi2 = IslM2.Fields
+const Sp2 = IslM2.SpeciesLists
+const Fr2 = IslM2.Frames
+
+_sgrid(n; ny=n) = PS2.IslandGrid(; nx=n, nxi=8, ny=ny, nE=2, halfwidth_x=6.0, clustering_x=1.0,
+ y_max=4.0, y_c=1.0, clustering_y=0.8, order=4)
+
+@testset "Islands L0 solve machinery (M2)" begin
+
+ @testset "species plumbing (02 §1, D3)" begin
+ bg = Sp2.Maxwellian(; n=1.0, T=1.0, dlnn_dr=-1.0)
+ ion = Sp2.Species(; name=:D, Z=1.0, m=1.0, background=bg, role=Sp2.Bulk)
+ trc = Sp2.Species(; name=:Dtrace, Z=1.0, m=1.0, background=Sp2.Maxwellian(; n=1e-4, T=1.0), role=Sp2.Trace)
+ @test Sp2.validate_species([ion, trc]) == [ion, trc]
+ @test Sp2.is_bulk(ion) && Sp2.is_trace(trc)
+ @test Sp2.bulk_species([ion, trc]) == [ion]
+ # the L0 test pair: trace deuterium copy of the bulk passes the criteria
+ @test isempty(Sp2.check_trace_criteria([ion, trc]))
+ # a heavy trace at bulk-like density violates and is flagged (warn, never degrade)
+ w = Sp2.Species(; name=:W, Z=40.0, m=92.0, background=Sp2.Maxwellian(; n=0.01, T=1.0), role=Sp2.Trace)
+ @test Sp2.check_trace_criteria([ion, w]) == [:W]
+ # structural validation
+ @test_throws ArgumentError Sp2.validate_species([trc]) # no Bulk
+ @test_throws ArgumentError Sp2.validate_species([ion, ion]) # duplicate names
+ end
+
+ @testset "frames: gated conventions poison, mechanics work (01 §5)" begin
+ conv = Fr2.FrameConvention() # all NaN until human-cleared (Q3)
+ @test !Fr2.is_cleared(conv)
+ @test isnan(Fr2.omega_dia_form(2, 1.0, -1.0, 2.0, conv))
+ @test isnan(Fr2.effective_dlnn_form(-1.0, 1.0, 0.5, conv))
+ # the frame-invariant combination is pure bookkeeping
+ @test Fr2.frame_shift(1.7, 0.4) ≈ 1.3
+ # parameter-vector validation
+ p = Fr2.Level0Parameters(; w_hat=1.0, omega_E_hat=0.0, epsilon=0.1, inv_Lq_hat=1.0, q_s=2.0)
+ @test p.tau == 1.0
+ @test_throws ArgumentError Fr2.Level0Parameters(-1.0, 0.0, 0.1, 1.0, 2.0, 1.0, Dict{Symbol,Float64}())
+ end
+
+ @testset "A4 — mimetic pitch operator: exact conservation + entropy sign" begin
+ g = _sgrid(9)
+ P = @. g.y.nodes * (4.0 - g.y.nodes) + 0.1 # arbitrary positive test profile
+ wm = @. 1.0 + 0.1 * g.y.nodes
+ K, Wq = Op2.conservative_pitch_operator(g.y, P, wm)
+ for gv in (sin.(g.y.nodes) .+ 0.3 .* g.y.nodes .^ 2, exp.(-g.y.nodes), g.y.nodes)
+ @test abs(dot(Wq, K * gv)) < 1e-11 # particle conservation, machine level
+ @test dot(gv .* Wq, K * gv) <= 1e-13 # entropy sign (0 only for constants)
+ end
+ @test abs(dot(ones(g.y.n) .* Wq, K * ones(g.y.n))) < 1e-12 # constants in the null space
+ @test_throws ArgumentError Op2.conservative_pitch_operator(g.y, -P, wm)
+ # the term applies c ⋅ (K g) and is allocation-free
+ nx, nξ, ny, nE, nσ = PS2.nnodes(g)
+ c4 = fill(2.0, nx, nξ, nE, nσ)
+ term = Op2.PitchAngleDiffusion(K, c4)
+ U, = V2.manufactured_state(g)
+ R = Op2.IslandState(g)
+ cache = Op2.IslandCache(g)
+ Op2.apply!(R, term, U, g, cache)
+ @test R.g[3, 2, :, 1, 1] ≈ 2.0 .* (K * U.g[3, 2, :, 1, 1])
+ @test (@allocated Op2.apply!(R, term, U, g, cache)) == 0
+ end
+
+ @testset "A5 — zero-drive null test" begin
+ g = _sgrid(7; ny=7)
+ setup = V2.zero_drive_setup(g)
+ r = ones(setup.N)
+ setup.f(r, zeros(setup.N))
+ @test maximum(abs, r) == 0.0 # residual is EXACTLY machine zero
+ # Newton from a small perturbation falls back to the zero state
+ sol = So2.newton_krylov(setup.f, 1e-3 .* sin.(1:setup.N); rtol=1e-12, atol=1e-12)
+ @test sol.converged
+ @test norm(sol.u) < 1e-9
+ end
+
+ @testset "A1 — assembled solve-MMS at design order" begin
+ r17 = V2.solve_mms(17)
+ r33 = V2.solve_mms(33)
+ @test r17.converged && r33.converged
+ # solution error against the manufactured state converges at design order
+ @test log(r17.err / r33.err) / log(33 / 17) > 3.3
+ @test r33.err < 5e-3
+ end
+
+ @testset "preconditioner: y-block Jacobi with TSVD cuts GMRES iterations (04 §5)" begin
+ g = _sgrid(9)
+ nx, nξ, ny, nE, nσ = PS2.nnodes(g)
+ P = @. g.y.nodes * (4.0 - g.y.nodes) # degenerate endpoints: zero-flux built in
+ K, = Op2.conservative_pitch_operator(g.y, P, ones(ny))
+ cstiff = fill(30.0, nx, nξ, nE, nσ) # stiff collisional pencil
+ shift = fill(-1.0, nx, nξ, ny, nE, nσ)
+ stack = Op2.IslandStack((Op2.PitchAngleDiffusion(K, cstiff), Op2.RadiationSink(shift)),
+ Op2.Quasineutrality(1.3))
+ f0! = So2.flat_residual(stack, g)
+ N = Op2.statelength(g)
+ b = sin.((1:N) ./ 7)
+ f!(out, u) = (f0!(out, u); out .-= b; out)
+ pc = So2.YBlockJacobi(g, (ix, iξ, iE, iσ) -> I(ny) + cstiff[ix, iξ, iE, iσ] .* K; phi_scale=-1.3)
+ s0 = So2.newton_krylov(f!, zeros(N); rtol=1e-10, memory=300)
+ s1 = So2.newton_krylov(f!, zeros(N); rtol=1e-10, memory=300, precond=pc)
+ @test s0.converged && s1.converged
+ @test maximum(abs, s0.u .- s1.u) < 1e-8 # same solution
+ @test s1.gmres_iters < s0.gmres_iters ÷ 2 # preconditioner earns its keep
+ end
+
+ @testset "far-field BCs replace the boundary residual rows (01 §3)" begin
+ g = _sgrid(7; ny=7)
+ nx, nξ, ny, nE, nσ = PS2.nnodes(g)
+ U, = V2.manufactured_state(g)
+ bc = Op2.FarFieldConditions(0.1 .+ zeros(nξ, ny, nE, nσ), 0.2 .+ zeros(nξ, ny, nE, nσ),
+ fill(0.3, nξ), fill(0.4, nξ))
+ stack = V2.build_stack(g)
+ R = Op2.IslandState(g)
+ cache = Op2.IslandCache(g)
+ Op2.residual!(R, U, stack, g, cache, bc)
+ @test R.g[1, 2, 3, 1, 1] ≈ U.g[1, 2, 3, 1, 1] - 0.1
+ @test R.g[nx, 2, 3, 1, 2] ≈ U.g[nx, 2, 3, 1, 2] - 0.2
+ @test R.Φ[1, 5] ≈ U.Φ[1, 5] - 0.3
+ @test R.Φ[nx, 5] ≈ U.Φ[nx, 5] - 0.4
+ # interior rows are untouched by the BC application
+ R2 = Op2.IslandState(g)
+ Op2.residual!(R2, U, stack, g, cache)
+ @test R.g[2:(nx-1), :, :, :, :] == R2.g[2:(nx-1), :, :, :, :]
+ end
+
+ @testset "A8 — y_c matching-block conditioning monitor" begin
+ g = _sgrid(7; ny=7)
+ setup = V2.zero_drive_setup(g)
+ J = So2.dense_jacobian(setup.f, zeros(setup.N))
+ mon = V2.yc_block_sigma_min(J, g)
+ @test isfinite(mon.sigma_min) && mon.sigma_min > 0
+ @test all(mon.pencil .>= 1)
+ # the monitor detects an artificially singularized pencil block (the
+ # silent-noise regression of L23 §4.2 must be *tested for*)
+ idx = [Op2.g_flat_index(g, 1, 1, iy, 1, 1) for iy in 3:5]
+ Jsing = copy(J)
+ Jsing[idx, :] .= 0.0
+ @test V2.yc_block_sigma_min(Jsing, g).sigma_min < 1e-14
+ end
+
+ @testset "A3 — Δ_cos even / Δ_sin odd under ξ-reflection" begin
+ g = _sgrid(9)
+ J = [exp(-x^2) * (2.0 + 1.5 * cos(ξ) + 0.7 * sin(ξ)) for x in g.x.nodes, ξ in g.ξ.nodes]
+ Jr = [exp(-x^2) * (2.0 + 1.5 * cos(-ξ) + 0.7 * sin(-ξ)) for x in g.x.nodes, ξ in g.ξ.nodes]
+ d = Mo2.delta_moments(J, g; prefactor_cos=1.0, prefactor_sin=1.0)
+ dr = Mo2.delta_moments(Jr, g; prefactor_cos=1.0, prefactor_sin=1.0)
+ @test d.Δcos ≈ dr.Δcos atol = 1e-12 # even
+ @test d.Δsin ≈ -dr.Δsin atol = 1e-12 # odd
+ @test abs(d.Δsin) > 0.1 # the projection actually sees the sin part
+ # ξ-projection is spectrally exact: a pure cos ξ current has zero sin moment
+ Jc = [cos(ξ) for x in g.x.nodes, ξ in g.ξ.nodes]
+ @test abs(Mo2.delta_moments(Jc, g; prefactor_cos=1.0, prefactor_sin=1.0).Δsin) < 1e-13
+ end
+
+ @testset "moments: J̄_∥ assembly and Ω-average diagnostics" begin
+ g = _sgrid(9)
+ nx, nξ, ny, nE, nσ = PS2.nnodes(g)
+ U, = V2.manufactured_state(g)
+ W = ones(ny, nE, nσ)
+ ion = Sp2.Species(; name=:D, Z=1.0, m=1.0, background=Sp2.Maxwellian(; n=1.0, T=1.0), role=Sp2.Bulk)
+ anti = Sp2.Species(; name=:A, Z=-1.0, m=1.0, background=Sp2.Maxwellian(; n=1.0, T=1.0), role=Sp2.Bulk)
+ Jp = zeros(nx, nξ)
+ # equal & opposite charges with identical g cancel exactly
+ Mo2.parallel_current!(Jp, [U.g, U.g], [ion, anti], [W, W], g)
+ @test maximum(abs, Jp) < 1e-14
+ # single species with W ≡ 1 reduces to the plain velocity moment
+ Mo2.parallel_current!(Jp, [U.g], [ion], [W], g)
+ Mref = zeros(nx, nξ)
+ Op2.velocity_moment!(Mref, U.g, g)
+ @test Jp ≈ Mref
+ # ⟨1⟩_Ω = 1 outside and inside the separatrix; constant J has no polarization part
+ @test Mo2.omega_average((x, ξ) -> 1.0, 1.5, 1.0) ≈ 1.0 rtol = 1e-8
+ @test Mo2.omega_average((x, ξ) -> 1.0, 0.2, 1.0) ≈ 1.0 rtol = 1e-6
+ cs = Mo2.channel_split((x, ξ) -> 3.0, 2.0, 1.0)
+ @test cs.bs ≈ 3.0 rtol = 1e-8
+ @test abs(cs.pol(0.5, 1.0)) < 1e-8
+ @test Mo2.omega_label(0.0, 0.0, 1.0) ≈ -1.0 # O-point
+ @test Mo2.omega_label(1.0, float(π), 1.0) ≈ 3.0
+ end
+
+ @testset "A7 — flattened-electron identity ⟨∂²h/∂x²⟩_Ω = 0 (coefficient-free)" begin
+ for Ω in (1.2, 2.0, 5.0), pref in (1.0, 3.7) # prefactor-independent
+ @test abs(Fi2.flat_average_d2h_dx2(Ω, 1.0; prefactor=pref)) < 1e-10
+ end
+ @test Fi2.h_profile(0.5; prefactor=1.0) == 0.0 # exactly flat inside the separatrix
+ @test Fi2.h_profile(2.0; prefactor=1.0) > 0.0
+ @test Fi2.Q_omega(3.0) > Fi2.Q_omega(1.5) # Q grows with Ω
+ @test !Fi2.is_cleared(Fi2.ElectronClosure()) # closure constants stay NaN-gated (Q3)
+ @test isnan(Fi2.ElectronClosure().k_HS)
+ end
+
+ @testset "pseudo-arclength continuation detects the toy fold (03 §3)" begin
+ ftoy!(out, u, p) = (out[1] = u[1]^2 + p; out)
+ pa = So2.pseudo_arclength(ftoy!, [1.0], -1.0; ds=0.3, nsteps=15, rtol=1e-12, atol=1e-12)
+ @test length(pa.ps) > 10 # stepped through, no stall at the fold
+ @test !isempty(pa.folds) # the fold at p = 0 is detected
+ @test maximum(pa.ps) < 0.05 # never steps past the fold parameter
+ # both branches visited: u > 0 before the fold, u < 0 after
+ @test any(z -> z[1] > 0.5, pa.us) && any(z -> z[1] < -0.5, pa.us)
+ end
+end