Skip to content

Commit f169052

Browse files
seanhancaseanhancaclaude
authored
feat(math): geodesic v2 — strong-field Schwarzschild (RFC #3 v2) (#66)
## Summary Extends `data.shape: "geodesic"` from v1's weak-field post-Newtonian approximation to the full equatorial-plane Schwarzschild null-geodesic equations. The `metric` enum widens to `["schwarzschild-weak", "schwarzschild-strong"]`; v1 specs continue to parse + render bit-for-bit identically. The new branch integrates a clean 3-state ODE in `(r, φ, ṙ)`: - `dr/dλ = ṙ` - `dφ/dλ = L / r²` - `d²r/dλ² = L²·(r − 3M) / r⁴` where `L = x0·vy0 − y0·vx0` derived from the conserved-energy/angular-momentum formulation for null geodesics. The radial restoring term flips sign at `r = 3M` — the photon sphere. Photons with sufficient L scatter (with larger deflection than the weak-field 4M/b formula); photons aimed too close are captured at the event horizon `r = 2M`. Simulation truncates at `r ≤ 2.01·M`. New fixture `black-hole-orbits.json` illustrates the three strong-field regimes (scatter / orbit / capture) with four locked photon rays around a unit-mass lens. ## Test plan - [x] 16 unit tests in `geodesic.test.ts` (10 v1 + 6 v2 strong-field) - [x] Value-correctness check: strong-field deflection exceeds weak-field at b > b_crit - [x] Qualitative check: sub-critical-b photon captured under strong-field, scattered under weak-field - [x] Photon-sphere orbit check: critical-b sweeps > π radians in φ - [x] Byte-stability (two-call determinism) - [x] Fixture SVG snapshot locked - [x] Full vitest pass: 655/655 - [x] CI matrix: Ubuntu/macOS/Windows × Node 20/22 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: seanhanca <infraservice@livepeer.org> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 14428a3 commit f169052

8 files changed

Lines changed: 649 additions & 37 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"version": "glyph/0.1",
3+
"title": "Strong-field Schwarzschild: photon orbits near the photon sphere (RFC #3 v2)",
4+
"data": {
5+
"geodesic": {
6+
"shape": "geodesic",
7+
"metric": "schwarzschild-strong",
8+
"mass": 1,
9+
"seeds": [
10+
{ "x0": -30, "y0": 6, "vx0": 1, "vy0": 0 },
11+
{ "x0": -30, "y0": 5.4, "vx0": 1, "vy0": 0 },
12+
{ "x0": -30, "y0": 4, "vx0": 1, "vy0": 0 },
13+
{ "x0": -30, "y0": -5.4, "vx0": 1, "vy0": 0 }
14+
],
15+
"step": 0.05,
16+
"max_lambda": 200
17+
}
18+
},
19+
"layers": [
20+
{
21+
"mark": "line",
22+
"encoding": {
23+
"x": {
24+
"field": "x",
25+
"type": "quantitative",
26+
"scale": { "domain": [-32, 32] }
27+
},
28+
"y": {
29+
"field": "y",
30+
"type": "quantitative",
31+
"scale": { "domain": [-16, 16] }
32+
},
33+
"color": {
34+
"field": "seed_id",
35+
"type": "nominal"
36+
}
37+
}
38+
}
39+
]
40+
}

packages/core/__fixtures__/math/black-hole-orbits.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* RFC #3 v2 end-to-end fixture — strong-field Schwarzschild photon
3+
* orbits demo as a Glyph spec.
4+
*
5+
* Four rays around a unit-mass black hole illustrate three distinct
6+
* strong-field regimes:
7+
*
8+
* - b = 6.0 (above b_crit ≈ 5.196 — scatters with a large
9+
* deflection that exceeds the 4M/b weak-field formula)
10+
* - b = 5.4 (just above b_crit — orbits the photon sphere once
11+
* before scattering; this is the "Einstein ring"
12+
* geometry)
13+
* - b = 4.0 (below b_crit — captured at the event horizon, the
14+
* trajectory truncates at r ≈ 2M)
15+
* - b = -5.4 (mirror of the orbiting ray, below the lens)
16+
*
17+
* Determinism gate: byte-identical SVG across runs and platforms,
18+
* protected by `clampSamplerPrecision` on every RK4 step.
19+
*/
20+
import { readFileSync } from "node:fs";
21+
import { fileURLToPath } from "node:url";
22+
import { describe, expect, it } from "vitest";
23+
import { type CompileFieldInfo, compileSpec } from "../../src/compiler/compile.js";
24+
import { renderSvg } from "../../src/render/svg.js";
25+
import { parseSpec } from "../../src/spec/parse.js";
26+
27+
const fixtureUrl = new URL("./black-hole-orbits.json", import.meta.url);
28+
const fixturePath = fileURLToPath(fixtureUrl);
29+
30+
const schema: CompileFieldInfo[] = [
31+
{ name: "x", type: "DOUBLE" },
32+
{ name: "y", type: "DOUBLE" },
33+
];
34+
35+
function buildAnchorRows(): ReadonlyArray<ReadonlyArray<number>> {
36+
return [
37+
[-32, -16],
38+
[32, 16],
39+
];
40+
}
41+
42+
describe("math examples — black-hole-orbits (RFC #3 v2 — geodesic strong-field)", () => {
43+
it("renders the 4-ray strong-field Schwarzschild geometry deterministically", async () => {
44+
const raw = JSON.parse(readFileSync(fixturePath, "utf8"));
45+
const spec = parseSpec(raw);
46+
const rows = buildAnchorRows();
47+
const svg = renderSvg(compileSpec({ spec, rows, schema }));
48+
const svg2 = renderSvg(compileSpec({ spec, rows, schema }));
49+
expect(svg2).toBe(svg);
50+
// 4 seeds → 4 distinct polylines.
51+
const moveToCount = (svg.match(/d="M /g) ?? []).length;
52+
expect(moveToCount).toBe(4);
53+
await expect(svg).toMatchFileSnapshot("./black-hole-orbits.svg");
54+
});
55+
});

0 commit comments

Comments
 (0)