|
| 1 | +# RFC #3 v2 Implementation Plan — `geodesic` adds `metric: "schwarzschild-strong"` |
| 2 | + |
| 3 | +**Goal:** Extend the geodesic shape from the weak-field Schwarzschild approximation (v1) to the full equatorial-plane Schwarzschild geodesic equations, capturing strong-field effects like photon orbits near `r = 3M` and capture at the event horizon `r = 2M`. |
| 4 | + |
| 5 | +**Architecture:** Reuse v1's `geodesic` data key + materialize dispatch. Solver becomes a switch over `metric`. Strong-field branch integrates in (r, φ, ṙ) state via RK4, derived from the conserved E/L formulation for null geodesics. Output schema unchanged: `[seed_id, lambda, x, y]`. |
| 6 | + |
| 7 | +**Tech Stack:** Same as v1 — TypeScript, Zod, vitest, `clampSamplerPrecision`. Zero new dependencies. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Design |
| 12 | + |
| 13 | +### The strong-field equations |
| 14 | + |
| 15 | +For a null geodesic in Schwarzschild spacetime, restricted to the equatorial plane (θ = π/2), conservation of energy and angular momentum give |
| 16 | + |
| 17 | +``` |
| 18 | +dt/dλ = E / (1 − 2M/r) |
| 19 | +dφ/dλ = L / r² |
| 20 | +(dr/dλ)² = E² − L²(1 − 2M/r)/r² |
| 21 | +``` |
| 22 | + |
| 23 | +Differentiating the third equation with respect to λ: |
| 24 | + |
| 25 | +``` |
| 26 | +d²r/dλ² = L²(r − 3M)/r⁴ |
| 27 | +``` |
| 28 | + |
| 29 | +This is a beautifully clean 2nd-order ODE in `r`. Combined with `dφ/dλ = L/r²`, it gives us a 3-state system `(r, φ, ṙ)` that's autonomous in λ and integrates cleanly with RK4. No turning-point sign-flips, no sqrt singularities, no special-casing radial trajectories (L=0 just makes φ constant and the radial equation reduces to `d²r/dλ² = 0` — straight radial fall, correct). |
| 30 | + |
| 31 | +The radial restoring term `(r − 3M)/r⁴` flips sign at `r = 3M` — the photon sphere. Photons at r > 3M with sufficient L scatter; photons at r < 3M with inward ṙ are captured. This is the headline strong-field effect. |
| 32 | + |
| 33 | +### State translation |
| 34 | + |
| 35 | +The spec gives initial conditions in Cartesian `(x0, y0, vx0, vy0)`. We convert to `(r0, φ0, ṙ0)` plus the conserved `L`: |
| 36 | + |
| 37 | +``` |
| 38 | +r0 = sqrt(x0² + y0²) |
| 39 | +φ0 = atan2(y0, x0) |
| 40 | +ṙ0 = (x0·vx0 + y0·vy0) / r0 |
| 41 | +L = x0·vy0 − y0·vx0 |
| 42 | +``` |
| 43 | + |
| 44 | +At each emit step, convert back to Cartesian: `x = r·cos(φ)`, `y = r·sin(φ)`. |
| 45 | + |
| 46 | +### Truncation |
| 47 | + |
| 48 | +Two stops: |
| 49 | +- `r ≤ 2.01·M` — the event horizon plus a thin shell. The metric component `(1 − 2M/r)` flips sign at r = 2M; the simulation is well-defined only above. The 0.01 slack avoids a final-step blow-up. |
| 50 | +- non-finite state → terminate seed (defense in depth). |
| 51 | + |
| 52 | +The v1 weak-field truncation at `r < 1.5·M` is unchanged; the two branches use their own truncations. |
| 53 | + |
| 54 | +### Determinism |
| 55 | + |
| 56 | +Same contract as v1: `clampSamplerPrecision` on every emitted `lambda`, `x`, `y`. The strong-field formula introduces `Math.cos` and `Math.sin` (via the (r, φ) → (x, y) conversion), so the precision clamp does real work here, unlike the weak-field branch where only `sqrt` was in play. The clamp is the same one used across function/trajectory/recurrence — proven bit-identical on Ubuntu/macOS/Windows by the CI matrix. |
| 57 | + |
| 58 | +--- |
| 59 | + |
| 60 | +## File structure |
| 61 | + |
| 62 | +| Path | Action | Why | |
| 63 | +|------|--------|-----| |
| 64 | +| `packages/core/src/data/shapes/geodesic.ts` | Modify | Widen `metric` to `"schwarzschild-weak" \| "schwarzschild-strong"`; add `iterateStrongField(spec)` branch; preserve weak-field code path bit-for-bit | |
| 65 | +| `packages/core/src/data/shapes/geodesic.test.ts` | Modify | 4 new tests for strong-field (validation, value correctness at photon sphere, capture trajectory, determinism) | |
| 66 | +| `packages/core/src/spec/schemas.ts` | Modify | Widen `GeodesicDataSchema.metric` enum to accept the new value | |
| 67 | +| `packages/core/__fixtures__/math/black-hole-orbits.{json,test,svg}` | Create | Photon trajectories near the photon sphere — some scatter, some loop, some capture; byte-locked | |
| 68 | +| `site/math/joy.html` | Modify | Add a caption to the gravity-lens demo (or add a second demo) noting v2's strong-field option | |
| 69 | + |
| 70 | +--- |
| 71 | + |
| 72 | +## Task breakdown |
| 73 | + |
| 74 | +### Task 1: Widen the schema enum |
| 75 | + |
| 76 | +**Files:** |
| 77 | +- Modify: `packages/core/src/spec/schemas.ts:331` |
| 78 | + |
| 79 | +- [ ] **Step 1: Open schema, change literal to enum** |
| 80 | + |
| 81 | +Change: |
| 82 | +```ts |
| 83 | +metric: z.literal("schwarzschild-weak"), |
| 84 | +``` |
| 85 | +to: |
| 86 | +```ts |
| 87 | +metric: z.enum(["schwarzschild-weak", "schwarzschild-strong"]), |
| 88 | +``` |
| 89 | + |
| 90 | +- [ ] **Step 2: Run schema tests** |
| 91 | + |
| 92 | +```bash |
| 93 | +cd packages/core && npx vitest run src/spec/schemas |
| 94 | +``` |
| 95 | +Expected: PASS (the literal-vs-enum change is backwards-compatible). |
| 96 | + |
| 97 | +### Task 2: Widen the spec type + add strong-field solver |
| 98 | + |
| 99 | +**Files:** |
| 100 | +- Modify: `packages/core/src/data/shapes/geodesic.ts` |
| 101 | + |
| 102 | +- [ ] **Step 1: Widen the metric field on `GeodesicDataSpec`** |
| 103 | + |
| 104 | +Change `metric: "schwarzschild-weak";` to `metric: "schwarzschild-weak" | "schwarzschild-strong";`. |
| 105 | + |
| 106 | +- [ ] **Step 2: Extract weak-field branch into a helper** |
| 107 | + |
| 108 | +Rename the loop body in `iterateGeodesic` into `iterateWeakField(spec)` so the new strong-field branch slots cleanly next to it. (Pure refactor — should keep all v1 tests green.) |
| 109 | + |
| 110 | +- [ ] **Step 3: Add `iterateStrongField(spec)` with RK4 over (r, φ, ṙ)** |
| 111 | + |
| 112 | +Equations: |
| 113 | +- dr/dλ = ṙ |
| 114 | +- dφ/dλ = L/r² |
| 115 | +- dṙ/dλ = L²(r − 3M)/r⁴ |
| 116 | + |
| 117 | +Initial conditions: |
| 118 | +- r0 = sqrt(x0² + y0²); φ0 = atan2(y0, x0); ṙ0 = (x0·vx0 + y0·vy0)/r0 |
| 119 | +- L = x0·vy0 − y0·vx0 |
| 120 | + |
| 121 | +Truncate at `r ≤ 2.01·M` or non-finite state. Emit `clampSamplerPrecision` on lambda/x/y. |
| 122 | + |
| 123 | +- [ ] **Step 4: Branch `iterateGeodesic` on `spec.metric`** |
| 124 | + |
| 125 | +```ts |
| 126 | +if (spec.metric === "schwarzschild-strong") return iterateStrongField(spec); |
| 127 | +return iterateWeakField(spec); |
| 128 | +``` |
| 129 | + |
| 130 | +- [ ] **Step 5: Run all geodesic tests** |
| 131 | + |
| 132 | +```bash |
| 133 | +cd packages/core && npx vitest run src/data/shapes/geodesic |
| 134 | +``` |
| 135 | +Expected: all v1 tests pass (no regression). New strong-field tests added next. |
| 136 | + |
| 137 | +### Task 3: Strong-field tests |
| 138 | + |
| 139 | +**Files:** |
| 140 | +- Modify: `packages/core/src/data/shapes/geodesic.test.ts` |
| 141 | + |
| 142 | +- [ ] **Step 1: Add validation test (mass=0 still rejected, both metrics accepted)** |
| 143 | + |
| 144 | +- [ ] **Step 2: Add value-correctness test — photon at large b deflects more than weak-field** |
| 145 | + |
| 146 | +```ts |
| 147 | +it("strong-field deflects more than weak-field at moderate r/M", () => { |
| 148 | + const baseSeed = { x0: -100, y0: 5, vx0: 1, vy0: 0 }; |
| 149 | + const weak = iterateGeodesic({ ...common, metric: "schwarzschild-weak", seeds: [baseSeed] }); |
| 150 | + const strong = iterateGeodesic({ ...common, metric: "schwarzschild-strong", seeds: [baseSeed] }); |
| 151 | + // both end up bent downward (negative y exit). Strong-field exit |
| 152 | + // should be MORE deflected (more negative) than weak-field. |
| 153 | + expect(strong[strong.length-1].y).toBeLessThan(weak[weak.length-1].y); |
| 154 | +}); |
| 155 | +``` |
| 156 | + |
| 157 | +- [ ] **Step 3: Add capture test — photon aimed at the event horizon stops at r ~ 2M** |
| 158 | + |
| 159 | +```ts |
| 160 | +it("captures a photon aimed at the event horizon", () => { |
| 161 | + const rows = iterateGeodesic({ |
| 162 | + metric: "schwarzschild-strong", |
| 163 | + mass: 1, step: 0.1, max_lambda: 100, |
| 164 | + seeds: [{ x0: -10, y0: 0.01, vx0: 1, vy0: 0 }], // tiny b, nearly radial |
| 165 | + }); |
| 166 | + const lastR = Math.hypot(rows[rows.length-1].x, rows[rows.length-1].y); |
| 167 | + expect(lastR).toBeLessThan(3); // captured well inside photon sphere |
| 168 | + expect(lastR).toBeGreaterThan(2); // not below event horizon |
| 169 | +}); |
| 170 | +``` |
| 171 | + |
| 172 | +- [ ] **Step 4: Add determinism test (two strong-field calls produce identical JSON)** |
| 173 | + |
| 174 | +- [ ] **Step 5: Run tests, expect all pass** |
| 175 | + |
| 176 | +```bash |
| 177 | +cd packages/core && npx vitest run src/data/shapes/geodesic |
| 178 | +``` |
| 179 | + |
| 180 | +### Task 4: Black-hole-orbits fixture |
| 181 | + |
| 182 | +**Files:** |
| 183 | +- Create: `packages/core/__fixtures__/math/black-hole-orbits.json` |
| 184 | +- Create: `packages/core/__fixtures__/math/black-hole-orbits.test.ts` |
| 185 | +- Create: `packages/core/__fixtures__/math/black-hole-orbits.svg` (generated) |
| 186 | + |
| 187 | +- [ ] **Step 1: Author the fixture JSON** |
| 188 | + |
| 189 | +Four photons at impact parameters chosen to illustrate the strong-field regime: |
| 190 | +- b = 6 (scatters, larger deflection than weak field) |
| 191 | +- b = 3.5 (near the photon-sphere — loops once or twice before scattering) |
| 192 | +- b = 2.5 (captured) |
| 193 | +- b = -4 (scatters from below) |
| 194 | + |
| 195 | +mass = 1, step = 0.05, max_lambda = 100. |
| 196 | + |
| 197 | +- [ ] **Step 2: Author the test (renders SVG twice, expects byte-identical)** |
| 198 | + |
| 199 | +Pattern matches the existing `gravity-lens.test.ts`. |
| 200 | + |
| 201 | +- [ ] **Step 3: Run test once to generate the snapshot, then run twice to lock** |
| 202 | + |
| 203 | +```bash |
| 204 | +cd packages/core && npx vitest run __fixtures__/math/black-hole-orbits |
| 205 | +``` |
| 206 | + |
| 207 | +### Task 5: joy.html caption |
| 208 | + |
| 209 | +**Files:** |
| 210 | +- Modify: `site/math/joy.html` |
| 211 | + |
| 212 | +- [ ] **Step 1: Add a caption near the gravity-lens demo** |
| 213 | + |
| 214 | +Mention: "RFC #3 v2 shipped `metric: 'schwarzschild-strong'` — see black-hole-orbits.json for photon capture and orbits near the photon sphere." |
| 215 | + |
| 216 | +### Task 6: CI cycle |
| 217 | + |
| 218 | +- [ ] **Step 1: Commit + push the branch** |
| 219 | +- [ ] **Step 2: Open PR** |
| 220 | +- [ ] **Step 3: Watch CI (6-cell matrix + audit)** |
| 221 | +- [ ] **Step 4: Squash-merge** |
| 222 | +- [ ] **Step 5: Sync main** |
| 223 | + |
| 224 | +--- |
| 225 | + |
| 226 | +## Self-review |
| 227 | + |
| 228 | +- **RFC coverage:** v1 explicitly listed strong-field as a follow-up; this plan delivers it on the same data key + materialize path. |
| 229 | +- **Determinism:** `cos`/`sin` are the new transcendentals; `clampSamplerPrecision` per emitted row covers libm drift just like trajectory/function. |
| 230 | +- **Schema-clean:** `metric` literal widens to an enum; v1 specs (`metric: "schwarzschild-weak"`) continue to parse + render identically. |
| 231 | +- **Performance:** RK4 over 3 state vars × max 2000 λ-steps × 200 max seeds = 1.2M arithmetic ops worst-case — well under 100 ms. |
| 232 | +- **Snapshot churn:** zero existing fixtures touched. One new fixture locked. |
0 commit comments