<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://hill.xyz</id>
    <title>Tom Hill's Blog</title>
    <updated>2026-09-07T05:30:20.368Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <author>
        <name>Tom Hill</name>
        <email>tom@hill.xyz</email>
        <uri>https://hill.xyz</uri>
    </author>
    <link rel="alternate" href="https://hill.xyz"/>
    <subtitle>Software engineer writing about web development, bioinformatics, and building things.</subtitle>
    <logo>https://hill.xyz/favicon.ico</logo>
    <icon>https://hill.xyz/favicon.ico</icon>
    <rights>All rights reserved 2026, Tom Hill</rights>
    <entry>
        <title type="html"><![CDATA[IDs Are a User Interface]]></title>
        <id>https://hill.xyz/2026/03/21/human-readable-ids</id>
        <link href="https://hill.xyz/2026/03/21/human-readable-ids"/>
        <updated>2026-03-21T00:00:00.000Z</updated>
        <content type="html"><![CDATA[
You're debugging a production issue at 11pm, tailing logs, and you see this:

```
Error processing entity 7b3e4d2a-8f1c-4a5b-9e6d-3c2f1a0b8e7d
```

Which entity is that? A user? A payment? An order? Now compare:

```
Error processing entity cus_9R2f1a0b8e7d
```
<SideNote numbered={false}>
Stripe's full prefix list is surprisingly long. There's a [community gist](https://gist.github.com/fnky/76f533366f75cf75802c8052b577e2a5) cataloguing over 100 distinct prefixes.
</SideNote>
Instantly: it's a customer. You know which table, which service, which dashboard. Stripe has been doing this since ~2012: `cus_` for customers, `pi_` for payment intents, `ch_` for charges, `sk_live_` and `sk_test_` for keys.

Whether you intend it or not, IDs become a user interface. They get pasted into Slack threads, dropped into support tickets, spoken aloud on incident calls, and shared across teams who have no idea what your database schema looks like. Internal users especially end up treating IDs as handles for things in your system. It's worth putting some thought into their design.

## Desirable properties

Here's a short list of properties that I've found make IDs more human-friendly.

| Property | How it helps humans |
|---|---|
| **Type-prefixed** | Include a clue for the kind of object the ID references. `cus_` is a customer, `pi_` is a payment intent, `ord_` is an order. Slack and Stripe both do this. A support engineer can triage a bug report without ever opening a database console. |
| **Double-clickable** | `cus_9R2f1a0b8e7d` selects as a single token. `7b3e4d2a-8f1c-4a5b-9e6d-3c2f1a0b8e7d` doesn't. Try it. When you're copying IDs out of logs dozens of times a day, this matters. |
| **Short** | The longer the opaque string, the more your eyes glaze over and the more likely you are to misjudge equivalence between two IDs. ~25 chars vs 36 for a UUID. |
| **URL-safe** | People drop IDs into query params, curl commands, and browser address bars all the time. |
| **Greppable** | `grep "^pi_"` filters logs by resource type using the ID alone. All UUIDs look the same. |
| **Environment-aware** | `sk_live_` vs `sk_test_` prevents someone from accidentally using test keys in production. The ID itself tells you which environment you're in. |
| **Anonymous** | Auto-incrementing integers leak information (user ID `4521` tells observers you have ~4,521 users). Random suffixes reveal nothing about creation order, count, or growth rate. |
| **Type-safe** | ``type CustomerId = `cus_${string}` `` in TypeScript. Passing a payment ID where a customer ID is expected becomes a compile error. |

]]></content>
        <author>
            <name>Tom Hill</name>
            <email>tom@hill.xyz</email>
            <uri>https://hill.xyz</uri>
        </author>
        <category label="engineering"/>
        <category label="api-design"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Simulating a black hole in Rust]]></title>
        <id>https://hill.xyz/2025/08/31/black-hole-rust</id>
        <link href="https://hill.xyz/2025/08/31/black-hole-rust"/>
        <updated>2025-08-31T00:00:00.000Z</updated>
        <content type="html"><![CDATA[
<Epigraph author="Gérard de
Nerval" source="The Grounding" year="1854">
In seeking the eye of God, I saw nought but an orbit<br/>
Vast, black, and bottomless, from which the night which there lives<br/>
Shines on the world and continually thickens<br/>

A strange rainbow surrounds this somber well,<br/>
Threshold of the ancient chaos whose offspring is shadow,<br/>
A spiral engulfing Worlds and Days !<br/>
</Epigraph>

[Max Bo](https://maxbo.me) has this really awesome [Observable notebook](https://observablehq.com/@mjbo/plotting-a-black-hole) that
plots a black hole from Bjorge Meulemeester's [Luminet](https://github.com/bgmeulem/Luminet) data.

It was based on Jean-Pierre Luminet's 1979 paper [*Image of a spherical black hole with thin accretion disk*](https://articles.adsabs.harvard.edu/pdf/1979A%26A....75..228L), where he plotted this image *by hand(?!)*:

<img src="/media/blackhole.jpg" alt="Image of a spherical black hole with thin accretion disk"
  className="large" />

[Source](https://arxiv.org/pdf/1902.11196)

I want to learn about WASM and Rust and this seems like a great project to replicate to learn about those things.
I have limited Rust experience and no WebAssembly experience.
Hopefully I learn something about black holes too, which I currently know nothing about.<SideNote>I'm calling this form of post a *learnlog*. I think its interesting to document my learning process,
as I go along. Hopefully it will be useful to someone else.</SideNote>

I've previously done [The Rust Book](https://doc.rust-lang.org/book/) and
[Rustlings](https://rustlings.rust-lang.org/), so I have a basic understanding of
the language. But I haven't done anything with wasm so need to get up to speed with that.

### What is a Schwarzschild black hole?

*Non-rotating black holes described by the Schwarzschild metric*.

> The Schwarzschild metric is an exact solution to Einstein's field equations which
> describes the gravitational field outside a spherical mass, assuming electric charge,
> angular momentum and the universal cosmological constant<SideNote>The **Universal Cosmological Constant** sounds very grandiose and important.
I looked it up, it's the "energy density of space" (or vacuum energy) that is causing the expansion of the universe to accelerate.</SideNote> are all zero.

### Constraints make it cool!

I think part of the appeal of Luminet's original image is that it was done by pen
plotting points on graph paper. The constraints of the medium add to the beauty of the
image.

I'd like to introduce some constraints to my implementation too.
I'm inspired by a1k0n's [donut.c](https://www.a1k0n.net/2011/07/20/donut-math.html).<SideNote>a1k0n's implementation is a bit more intense - the source code is obfuscated C, and it itself looks like a donut!</SideNote>

![donut.c](/media/donut.gif)

So I'd like to render using ASCII characters as my form of constraint. Here's the result, running as WASM in your browser:

<BlackHoleDemo />

The full source is at [hill/ergosphere](https://github.com/hill/ergosphere) on GitHub.

### What you're seeing

The black circle in the centre is the **event horizon**, the boundary beyond which nothing escapes. Around it is the **accretion disk**: a flat ring of superheated gas orbiting the black hole, sitting in the $y = 0$ plane between the innermost stable circular orbit ($r = 6M$) and an outer edge at $r = 30M$.

Notice the disk appears brighter on one side. That's the relativistic Doppler effect: gas on the approaching side is blueshifted (rendered in cyan), while the receding side is redshifted (rendered in amber/orange). The brightness asymmetry is extreme because the *observed flux* (how much light energy actually reaches us per unit area per second) scales as $1/(1+z)^4$. Flux is just a measure of brightness, essentially "how many photons hit your eye and how energetic are they". That fourth power comes from four separate relativistic effects stacking up: the photon energy shifts, time dilation stretches the arrival rate, and relativistic beaming concentrates the light. So even a modest redshift dramatically dims one side while a modest blueshift dramatically brightens the other.

The background stars aren't just decoration. As starlight passes near the black hole, spacetime curvature bends the light paths. Stars near the edge of the black hole's silhouette appear smeared and displaced. This is **gravitational lensing**. You can see it by dragging the view around: stars behind the black hole get warped into arcs around it.

The density of the ASCII characters encodes brightness. The renderer maps intensity to a ramp of characters from dim (` `, `.`, `,`) through to bright (`*`, `#`, `$`, `@`). Brighter regions of the disk get denser characters, darker regions get sparser ones. Rays that fall into the event horizon render as empty space.

The rest of this post walks through how the simulation actually works.

## Starting simple: shooting rays

The basic idea is raytracing. For every character cell on screen, we fire a ray outward from the camera and ask: what does this ray hit? In normal raytracing, rays travel in straight lines. Near a black hole, they don't. But let's start with the straight-line version and add the physics later.

We place the camera somewhere in space, pointed at the origin (where the black hole sits). For each cell, we compute a ray direction based on the cell's position on screen, a field of view, and a look-at basis that keeps the black hole centred. Standard perspective projection stuff.

The question is: what happens to that ray?

## Three things can happen

Every ray has exactly one of three fates:

```rust
pub enum RayResult {
    Absorbed { step: usize },
    DiskHit { hit_pos: Vec3, disk_radius: f64 },
    Escaped { final_direction: Vec3 },
}
```

It either falls into the event horizon (absorbed), hits the accretion disk, or escapes to infinity (where it might hit a background star). The renderer maps each outcome to a character and colour.

## Bending light: the geodesic equation

In flat spacetime, a photon just travels in a straight line forever. Near a massive object, spacetime is curved, and the photon follows a *geodesic*, the curved-space equivalent of a straight line.

For a Schwarzschild black hole, we can simplify things enormously by working in *natural units* where the gravitational constant $G = 1$, the speed of light $c = 1$, and the black hole mass $M = 1$. This collapses a lot of constants and gives us clean landmark radii:
<SideNote numbered={false}>
The event horizon ($r = 2M$) is where escape velocity equals the speed of light. The photon sphere ($r = 3M$) is where photons can orbit in unstable circles. The ISCO ($r = 6M$), the innermost stable circular orbit, is the inner edge of the accretion disk: any closer and orbiting matter spirals inward.
</SideNote>

```rust
pub const EVENT_HORIZON_RADIUS: f64 = 2.0;     // r = 2M
pub const PHOTON_SPHERE_RADIUS: f64 = 3.0;     // r = 3M
pub const DISK_INNER_RADIUS: f64 = 6.0;        // ISCO = 6M
```

The key equation that governs how a photon's path curves is the Schwarzschild geodesic acceleration:

$$ \vec{a} = -\frac{3M \cdot h^2}{r^5} \cdot \vec{x} $$

where:

- $\vec{a}$ is the acceleration applied to the photon's direction at each step
- $M$ is the black hole mass (1.0 in our natural units)
- $\vec{x}$ is the photon's current position in 3D space
- $r = |\vec{x}|$ is the photon's distance from the singularity at the origin
- $h = |\vec{x} \times \vec{v}|$ is the *specific angular momentum*: the cross product of position and velocity. This measures how "sideways" the photon is moving relative to the black hole. A photon aimed directly at the centre has $h = 0$ and falls straight in. A photon passing by at a distance has large $h$ and barely deflects.

What's interesting here is the $1/r^5$ falloff. Newtonian gravity pulls on massive objects with $1/r^2$. The extra $1/r^3$ factor is what general relativity adds for light, and it's what produces the dramatic lensing near the black hole. Far away, the correction is negligible and light travels almost straight. Close in, it dominates and light can loop multiple times around the black hole before escaping (or not).

## Raymarching

We don't solve this equation analytically. Instead we *march* the ray forward in small steps, applying the acceleration at each step to nudge the direction. It's essentially Euler integration:

```rust
pub fn march_ray(mut pos: Vec3, mut dir: Vec3) -> RayResult {
    for step in 0..500 {
        let r_sq = pos.length_squared();
        let r = r_sq.sqrt();

        // Crossed the event horizon? Absorbed.
        if r < EVENT_HORIZON_RADIUS {
            return RayResult::Absorbed { step };
        }

        // Compute the geodesic acceleration
        let h_sq = pos.cross(dir).length_squared();
        let accel = pos * (-3.0 * BH_MASS * h_sq / (r_sq * r_sq * r));

        // Update direction and position
        dir = (dir + accel * STEP_SIZE).normalize();
        pos = pos + dir * STEP_SIZE;

        // Check if ray crossed the disk plane (y ≈ 0)
        // between the ISCO and outer edge...
    }
    RayResult::Escaped { final_direction: dir }
}
```

500 steps of 0.2 units each covers a total path of 100 units, enough for a ray to either fall in, hit the disk, or get far enough away that the curvature becomes negligible. There's also an early-escape optimisation: if $r > 40$ and the ray is heading outward, we bail early since the $1/r^5$ term is vanishingly small.

The disk intersection check watches for the ray's $y$ coordinate changing sign (crossing the $y = 0$ plane). If the crossing point is between the ISCO radius ($6M$) and the outer edge ($30M$), we have a disk hit.

## How bright is the disk?

Once a ray hits the disk, we need to figure out how bright that point should appear to the camera. This involves two pieces of physics.

### Intrinsic flux: how hot is the gas?

The gas in the accretion disk isn't uniformly bright. It follows the Page & Thorne (1974) model, which has a counterintuitive result: the disk is actually brightest at $r \approx 9.55M$, *not* at its inner edge. The gas near the ISCO is moving so fast that viscous heating is less efficient there. Peak temperature is further out.
<SideNote>
Page & Thorne's formula involves a logarithmic integral that's surprisingly fiddly to implement. The key insight is that it must be normalised against its own peak value so the brightness maps cleanly to the 0-1 range we need for rendering.
</SideNote>

### Redshift: what the camera actually sees

The gas is orbiting at relativistic speeds, and it's deep in a gravitational well. Both of these shift the frequency of light reaching the camera.

The gravitational component is $1/\sqrt{1 - 3M/r}$. This diverges at the photon sphere ($r = 3M$), which makes physical sense: light emitted right at the photon sphere has to "climb" out of an enormous gravitational potential.

The Doppler component depends on whether the gas is moving toward or away from the camera. On one side of the disk the orbital motion is approaching us (blueshift), on the other it's receding (redshift).

These combine into a total redshift factor $z$ (where $z > 1$ means the light has been stretched to lower frequencies/redder, and $z < 1$ means compressed to higher frequencies/bluer). The observed flux then scales as:

$$F_{\text{obs}} = \frac{F_{\text{intrinsic}}}{(1+z)^4}$$

where $F_{\text{obs}}$ is what the camera actually registers and $F_{\text{intrinsic}}$ is the brightness emitted by the gas itself. That fourth power is where the dramatic asymmetry comes from. It stacks four effects: the photon's energy shifts by $(1+z)$, time dilation stretches the photon arrival rate by another $(1+z)$, and relativistic beaming contributes $(1+z)^2$. The result is that the approaching side of the disk can be amplified by up to $81\times$ while the receding side is suppressed by $16\times$. This is why Luminet's original hand-plotted image looks so lopsided, and why the demo above has such a stark bright side and dim side.

The colour mapping follows from the redshift direction: blueshifted light (approaching) is rendered in cyan, redshifted light (receding) in amber. Intensity goes through a gamma correction (square root) to bring out detail in the dimmer regions.

## Mapping rays to ASCII

Once the physics is done, every ray has a `RayResult`. The renderer's job is to turn each one into a character and an optional colour. The core of this is `render_cell`:

**Absorbed rays** (swallowed by the event horizon) become a space character. No colour. This is what creates the black circle in the centre.

**Disk hits** are the most involved. The renderer computes the redshift and observed flux for the hit point, then does two things with the intensity value. First, it picks a character from the ASCII brightness ramp:

```rust
pub const ASCII_RAMP: &[u8] = b" .,-~:;=!*#$@";
```

This is the same idea as a1k0n's donut.c. Each character has a different visual "density" when rendered in a monospace font. A space is empty, a `.` is barely visible, and `@` is as dense as it gets. The intensity (0.0 to 1.0) is mapped linearly to an index into this ramp:

```rust
let index = (intensity * (ASCII_RAMP.len() - 1) as f64) as usize;
let ch = ASCII_RAMP[index] as char;
```

Second, it picks an RGB colour by blending between amber `(255, 160, 40)` for fully redshifted light and cyan `(120, 220, 255)` for fully blueshifted, with the intensity controlling overall brightness. A gamma correction (`intensity.sqrt()`) boosts the dim end of the range so the receding side of the disk doesn't disappear entirely.

**Escaped rays** that miss both the horizon and the disk check for background stars. The starfield is deterministic: the sky is divided into a grid of cells, and a seeded PRNG (`SmallRng`) decides whether each cell contains a star (~4% chance). Bright stars get `*`, dim ones get `.`.

The important detail is that the starfield uses the ray's *final* direction after lensing, not the original direction the camera fired it in. This is what makes gravitational lensing visible: stars near the black hole's silhouette appear displaced from where they actually are, because the ray curved on its way past.

For escaped rays that don't hit a star, the renderer measures how much the ray was deflected by comparing the original and final directions with a dot product. More deflection means the ray passed closer to the black hole, so it gets a brighter character. This creates a subtle glow around the shadow's edge.

```rust
let deflection = original_dir.dot(*final_direction);
let brightness = (1.0 - deflection).clamp(0.0, 1.0);
let brightness = brightness * brightness; // sharpen falloff
let index = (brightness * (ASCII_RAMP.len() - 1) as f64) as usize;
```
]]></content>
        <author>
            <name>Tom Hill</name>
            <email>tom@hill.xyz</email>
            <uri>https://hill.xyz</uri>
        </author>
        <category label="rust"/>
        <category label="simulation"/>
        <category label="wasm"/>
        <category label="learnlog"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[recipe.cleaning]]></title>
        <id>https://hill.xyz/2024/01/22/recipe-cleaning</id>
        <link href="https://hill.xyz/2024/01/22/recipe-cleaning"/>
        <updated>2024-01-22T00:00:00.000Z</updated>
        <content type="html"><![CDATA[
<img src="/media/recipe.png" alt="recipe.cleaning screenshot" className="large" />

Tired of scrolling past eight paragraphs about someone's dead grandmother to get to the banana bread recipe?

I'm sick of scrolling through SEO slop just to get to the actual recipe.

I wrote a tiny Go service, [recipe.cleaning](https://recipe.cleaning), to skip all of that. Prepend `recipe.cleaning/` to any recipe URL and you get the recipe, nothing else.

```
https://recipe.cleaning/https://www.recipetineats.com/banana-bread-recipe/
```

Title, ingredients, instructions. Glorious plain text. Source is at [github.com/hill/recipe-cleaning](https://github.com/hill/recipe-cleaning).

## How it works

The trick is that basically every recipe site already embeds its recipe as structured data so Google can render rich results in search. The data I want is already sitting in the HTML, I just have to pull it out.

Two formats get checked, in order of preference.

**1. JSON-LD.** The modern standard. Recipe sites drop a `<script type="application/ld+json">` tag somewhere in the page that looks roughly like this.

```html
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Recipe",
  "name": "Banana Bread",
  "recipeIngredient": [
    "2 ripe bananas, mashed",
    "1 cup plain flour",
    "1/2 cup brown sugar"
  ],
  "recipeInstructions": [
    { "@type": "HowToStep", "text": "Preheat oven to 180°C." },
    { "@type": "HowToStep", "text": "Mix bananas, flour and sugar in a bowl." },
    { "@type": "HowToStep", "text": "Bake for 45 minutes." }
  ]
}
</script>
```

I parse the JSON and look for an object where `@type` equals `Recipe`, or the recipe sitting inside a `@graph` array. From there I pull `name`, `recipeIngredient`, and `recipeInstructions`. Instructions can come through as plain strings, as objects with a `text` field, or as nested `itemListElement` lists, so there's a bit of type-switching to cover the variants.

**2. Schema.org microdata.** Older sites annotate their DOM with `itemtype` and `itemprop` attributes directly on markup.

```html
<div itemscope itemtype="http://schema.org/Recipe">
  <h1 itemprop="name">Banana Bread</h1>
  <ul>
    <li itemprop="recipeIngredient">2 ripe bananas, mashed</li>
    <li itemprop="recipeIngredient">1 cup plain flour</li>
    <li itemprop="recipeIngredient">1/2 cup brown sugar</li>
  </ul>
  <ol itemprop="recipeInstructions">
    <li>Preheat oven to 180°C.</li>
    <li>Mix bananas, flour and sugar in a bowl.</li>
    <li>Bake for 45 minutes.</li>
  </ol>
</div>
```

If JSON-LD isn't there, I walk the DOM with goquery and pick those out.

If neither exists, you get a 400 and I log the URL so I can go take a look.
]]></content>
        <author>
            <name>Tom Hill</name>
            <email>tom@hill.xyz</email>
            <uri>https://hill.xyz</uri>
        </author>
        <category label="web"/>
        <category label="project"/>
        <category label="go"/>
        <category label="url-app"/>
    </entry>
</feed>