diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ce36204..c07d7f1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,7 +14,7 @@ If this changes what any renderer draws: -- [ ] Gallery regenerated (`go run ./cmd/blazon gallery -o docs/gallery -readme README.md`), +- [ ] Gallery regenerated (`go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md`), and a second run produced no diff - [ ] I looked at the image diff diff --git a/.github/workflows/gallery.yml b/.github/workflows/gallery.yml index 1f72ec9..75ebf6f 100644 --- a/.github/workflows/gallery.yml +++ b/.github/workflows/gallery.yml @@ -34,7 +34,7 @@ jobs: cache: true - name: regenerate - run: go run ./cmd/blazon gallery -o docs/gallery -readme README.md + run: go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md - name: the committed gallery must match shell: bash @@ -46,7 +46,7 @@ jobs: echo "The committed gallery does not match what the code renders." echo "Run this and commit the result:" echo - echo " go run ./cmd/blazon gallery -o docs/gallery -readme README.md" + echo " go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md" echo git diff --stat exit 1 @@ -56,7 +56,7 @@ jobs: run: | # A non-idempotent generator would make this job fail on every push # even when nothing changed. - go run ./cmd/blazon gallery -o docs/gallery -readme README.md + go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md if ! git diff --quiet; then echo "The gallery generator is not idempotent: a second run changed files." git diff --stat diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7171bb9..c5dff07 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,10 +128,14 @@ looks like now, and why the new value is still a useful bound. Numbers say whether a renderer passes. A contact sheet is how you find out whether it is any good. -7. Regenerate the gallery in the same pull request: +7. Describe it in one row of the table in `docs/renderers.md`, above the + gallery markers — what it draws, and whether the version can be read back + out of it. + +8. Regenerate the gallery in the same pull request: ``` - go run ./cmd/blazon gallery -o docs/gallery -readme README.md + go run ./cmd/blazon gallery -o docs/gallery -readme docs/renderers.md ``` Run it twice. The second run must produce no diff. CI regenerates the diff --git a/README.md b/README.md index 6cd28be..6e1f12d 100644 --- a/README.md +++ b/README.md @@ -15,23 +15,52 @@ MIT

-Identicons for version numbers, in Go. - -Not a general-purpose hash-to-picture library pointed at a version string. The -version's structure is the input: major, minor and patch feed separate entropy -streams, so how much two neighbouring releases resemble each other is a -decision you make, not an accident of the hash. - -And distinguishability is a test, not a claim. Every renderer is measured -across a corpus of 184 versions, and a change that makes patch bumps harder to -tell apart turns the build red. +**Give every version of your software a face.** blazon turns a version number +into a small picture that a person recognises at a glance, without reading it. + +## Why + +Version numbers are hard to see. `1.4.12` and `1.4.2` differ by one character in +the middle of a string, and every mistake that follows from mixing them up looks +like a different bug. A picture fails differently: two versions either look the +same or they do not, and the eye answers in one glance. + +- **Spot the odd one in a list.** A release table, a deploy dashboard, a fleet of + agents, a row of build artifacts — one mark per row and the outlier stops + hiding among digits. +- **Show the version without showing a version.** A mark in a corner, a favicon, + a splash screen, a login footer: users never see a number, but a screenshot in + a bug report still tells you which build it came from, and two screenshots + from "the same version" stop being a guess. +- **Confirm what shipped.** The mark on the artifact, on the running service's + health page and in the release notes either match or they do not. No diffing + strings across three tabs. +- **Make releases memorable.** A changelog entry, a tag, a launch post — the mark + is what people remember the release *as*. + +Two properties make that work, and both are enforced by tests rather than +claimed: + +1. **Neighbours are never confusable.** A patch bump visibly changes the mark. A + change that makes patch bumps harder to tell apart turns the build red. +2. **Family resemblance is deliberate.** The version's structure is the input — + major, minor and patch feed separate entropy streams — so all `1.x` marks read + as relatives while `2.0.0` starts over. How closely neighbours resemble each + other is [a setting](docs/how-it-works.md#policy-how-related-should-neighbours-look), + not an accident of a hash. + +And if you would rather a person be able to *read* the version back out of the +mark, three of the renderers encode the number itself instead of hashing it. + +## Use it + +Hosted, for a README or a web UI — no build step: + +```html +v1.4.2 +``` -**No dependencies.** Standard library only — including the rasteriser and the -font. Those are the two things that decide what the pixels are, and a library -whose whole premise is that a version always produces the same mark cannot -outsource them: a dependency that sharpened its anti-aliasing would change -every mark ever generated, arriving as a routine version bump. A test enforces -it. +In Go: ```go import "github.com/timzifer/blazon" @@ -41,245 +70,30 @@ png, err := blazon.PNG("1.4.2", blazon.Options{Renderer: "flowfield", Size: 512} txt, err := blazon.Text("1.4.2", blazon.Options{Renderer: "bishop"}) ``` +From a terminal or a build script: + ``` go install github.com/timzifer/blazon/cmd/blazon@latest -blazon 1.4.2 # terminal +blazon 1.4.2 # draw it in the terminal blazon 1.4.2 -r flowfield -o x.svg -blazon dist -r superformula # the distance histogram behind the thresholds -``` - -## How it works - -Three stages. Each one is a seam you can cut at. - -**1 — Entropy.** Not `sha256("1.4.2")` in one piece. Four separated streams: - -``` -h0 = hash(major) archetype -h1 = hash(major, minor) variant -h2 = hash(major, minor, patch) detail -h3 = hash(full version) prerelease -``` - -Fields are length-prefixed, so `1.23.4` and `12.3.4` cannot collide. - -**2 — Parameter mapping.** A `Renderer` draws its parameters from those streams -and decides what the mark *means*. This is the stage that determines whether -the library is useful. Not every renderer hashes: `cistercian`, `dial` and -`orbit` encode the number itself, so the version can be read back out of the -mark. Those are marked `Ordered` and are held to uniqueness rather than to the -distance thresholds — being systematic is the point of them. - -**3 — Render primitives.** A `canvas.Canvas` turns drawing calls into SVG, -pixels or terminal cells. This stage decides only how it looks. Every renderer -works on every back end. - -## Policy: how related should neighbours look? - -An API choice, not a design decision: - -| Policy | Effect | -| --- | --- | -| `PolicyFamilyAmplified` (default) | Major sets the archetype, minor the variant, patch the detail — and each renderer routes two or three of its highest-contrast parameters through the patch stream, so adjacent patches stay in the family but are never hard to tell apart. | -| `PolicyFamily` | The plain cascade. Patch changes only fine detail. | -| `PolicyIndependent` | Every stream comes from the full version. Neighbours look unrelated: maximum distinguishability, no family information. | - -A prerelease never changes the core streams: `1.0.0-rc.1` still reads as a -`1.0.0`, marked by a small band of dots. - -## The testable invariant - -This is the part that separates blazon from any identicon port. - -Marks are rendered across a version corpus, reduced to a 128-bit perceptual -hash — a DCT hash plus a difference hash, because either alone confuses shapes -the other separates — and the Hamming distances between related versions are -held to per-renderer thresholds: - -```go -blazontest.Suite{ - Renderer: myRenderer, - Thresholds: blazontest.Thresholds{MinPatch: 6, MinMinor: 9, MinMajor: 20, Floor: 2}, -}.Run(t) -``` - -The suite checks: - -- **patch, minor and major neighbours** clear their minimum distances; -- **no pair anywhere** in the corpus falls below the global floor; -- **family cohesion** — versions sharing a major really are closer to each - other than to other majors, so the family policy is not a lie; -- **prereleases stay close** to their release, bounded from above rather than - below; -- **uniqueness** — no two versions render to the same bytes, exactly rather - than perceptually; -- **determinism** — the same version renders identically, byte for byte where a - renderer declares it can. - -Measurement is monochrome on purpose. A luminance-based hash scores two marks -differing only in hue as identical, so measuring in colour would credit colour -for distinctness the form does not have. Judging form alone makes the guarantee -hold in print, in a monochrome terminal, and for a colour-blind reader. - -Thresholds are calibrated from each renderer's own histogram, not guessed: - -``` -blazon dist -r truchet -``` - -## Renderers - -| Name | What it is | -| --- | --- | -| `superformula` | Gielis' superformula, drawn from curated shape families — star, flower, gear, crystal, leaf, shield. The default. | -| `flowfield` | Streamlines through a Sherlock–Monro orientation field with fingerprint cores and deltas. Arch, loop and whorl come from the major version; minutiae emerge from the ridge spacing rule. | -| `truchet` | Truchet tiles. Arcs meet at cell edge midpoints, so the marks join across the grid into loops and labyrinths. | -| `polar` | A bitmap in rings and sectors instead of rows and columns, mirrored across a fold from the archetype stream, with a centre device in the hole and a radial profile that varies ring by ring. | -| `cistercian` | Medieval numerals: one stave per version component, four digits per stave. Legible — you can read the version back out of it. | -| `dial` | One quadrant per component, the value written in binary as radial bands. The separator is the angle: the spokes are the dots of the version string. | -| `orbit` | One ring per component, major innermost, the value written in binary as cells running clockwise from noon. The separator is the radius. | -| `bishop` | The drunken bishop walk from OpenSSH randomart, for the terminal. | - -## Output - -- **SVG** — plain string building, deterministic number formatting. -- **PNG** — anti-aliased by exact area coverage: each edge deposits the signed - area it sweeps per pixel column, and a running sum along the row gives - coverage. Filling a shape and summing the alpha returns its geometric area, - which is how the anti-aliasing is tested. -- **Terminal** — half-block characters give each cell two square pixels, so - vector renderers arrive as pictures rather than as ASCII art. Falls back to a - density ramp under `NO_COLOR`. - -Colour is derived in OKLCH with gamut mapping, held in a lightness band that -keeps contrast on both a white page and a dark terminal. - -## Hosted marks - -If all you want is a mark in a README, there is an endpoint for it: - -``` -https://api.blazon.build/v1/mark/1.4.2.svg -https://api.blazon.build/v1/mark/1.4.2.svg?renderer=flowfield&bg=dark -https://api.blazon.build/v1/mark/1.4.2.png?renderer=bishop&size=128 -``` - -`/v1/mark/{version}.{svg|png|txt}`, with `renderer`, `size`, `padding`, -`policy`, `palette`, `bg`, `cols` and `nocolor` spelled exactly as the CLI -spells them. `/v1/renderers` lists what exists and the current size ceilings; -`/v1/healthz` reports which release is deployed. A mark is a pure function of -its URL, so the answers are cacheable forever and are served that way. - -It runs the same code this repository publishes — the deploy compares every -response against a `go run` of the same version, byte for byte, and refuses to -ship on a disagreement. So the marks are the marks. What differs is the budget. - -`size` runs from 16 up, with a ceiling per format: **1024 for SVG, 128 for -PNG**. The difference is not arbitrary. SVG emits geometry, and its cost is -whatever the renderer's own maths costs — the requested size only ever reaches -the `viewBox` attribute. PNG rasterises and deflates, so its cost grows with -the square of the edge length. - -That is a limit of one small deployment, not of the library. `blazon.PNG` has -no ceiling, no renderer is excluded, and rendering locally costs a few -milliseconds: - -```go -data, err := blazon.PNG("1.4.2", blazon.Options{Renderer: "flowfield", Size: 1024}) ``` -Fetching a mark that has already been drawn is not rate limited; drawing a new -one is, per address. Asking for a few hundred distinct marks in a burst will -meet a `429`, and the answer to that is a `for` loop against the library rather -than against the endpoint. - -The service is best effort and carries no availability promise. Anything that -matters should render its own marks — that is the whole point of a library -with no dependencies. - -## Gallery - -Generated by `blazon gallery` and verified in CI: if the committed images stop -matching what the code renders, the build fails. - - - - - -### bishop - -Terminal renderer on a 17×10 character grid. - -![bishop across a version corpus](docs/gallery/bishop-sheet.png) - -Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` - -![bishop family strip](docs/gallery/bishop-family.png) - -### cistercian - -![cistercian across a version corpus](docs/gallery/cistercian-sheet.png) - -Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` - -![cistercian family strip](docs/gallery/cistercian-family.png) - -### dial - -![dial across a version corpus](docs/gallery/dial-sheet.png) - -Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` - -![dial family strip](docs/gallery/dial-family.png) - -### flowfield - -![flowfield across a version corpus](docs/gallery/flowfield-sheet.png) - -Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` - -![flowfield family strip](docs/gallery/flowfield-family.png) - -### orbit - -![orbit across a version corpus](docs/gallery/orbit-sheet.png) - -Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` - -![orbit family strip](docs/gallery/orbit-family.png) - -### polar - -![polar across a version corpus](docs/gallery/polar-sheet.png) - -Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` - -![polar family strip](docs/gallery/polar-family.png) - -### superformula (default) - -![superformula across a version corpus](docs/gallery/superformula-sheet.png) - -Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` - -![superformula family strip](docs/gallery/superformula-family.png) - -### truchet - -![truchet across a version corpus](docs/gallery/truchet-sheet.png) - -Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` - -![truchet family strip](docs/gallery/truchet-family.png) - - - -## Contributing - -A new renderer needs an entry in the threshold table and has to pass -`blazontest.Suite`; see [CONTRIBUTING.md](CONTRIBUTING.md) for how to calibrate -one. +**No dependencies** — standard library only, including the rasteriser and the +font. A library whose premise is that a version always produces the same mark +cannot outsource the code that decides what the pixels are. +[Why that matters](docs/how-it-works.md#no-dependencies). + +## Docs + +- **[Renderers and gallery](docs/renderers.md)** — the eight marks, and what + every version looks like in each. +- **[How it works](docs/how-it-works.md)** — entropy streams, family policy, the + output back ends, and the distinctness test suite. +- **[Hosted service](docs/service.md)** — the endpoint, its parameters and its + limits. +- **[Contributing](CONTRIBUTING.md)** — a new renderer needs an entry in the + threshold table and has to pass `blazontest.Suite`. ## License diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..ecd69ac --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,104 @@ +# How it works + +Three stages. Each one is a seam you can cut at. + +## 1 — Entropy + +Not `sha256("1.4.2")` in one piece. Four separated streams: + +``` +h0 = hash(major) archetype +h1 = hash(major, minor) variant +h2 = hash(major, minor, patch) detail +h3 = hash(full version) prerelease +``` + +Fields are length-prefixed, so `1.23.4` and `12.3.4` cannot collide. + +## 2 — Parameter mapping + +A `Renderer` draws its parameters from those streams and decides what the mark +*means*. This is the stage that determines whether the library is useful. Not +every renderer hashes: `cistercian`, `dial` and `orbit` encode the number +itself, so the version can be read back out of the mark. + +## 3 — Render primitives + +A `canvas.Canvas` turns drawing calls into SVG, pixels or terminal cells. This +stage decides only how it looks. Every renderer works on every back end. + +## Policy: how related should neighbours look? + +An API choice, not a design decision: + +| Policy | Effect | +| --- | --- | +| `PolicyFamilyAmplified` (default) | Major sets the archetype, minor the variant, patch the detail — and each renderer routes two or three of its highest-contrast parameters through the patch stream, so adjacent patches stay in the family but are never hard to tell apart. | +| `PolicyFamily` | The plain cascade. Patch changes only fine detail. | +| `PolicyIndependent` | Every stream comes from the full version. Neighbours look unrelated: maximum distinguishability, no family information. | + +A prerelease never changes the core streams: `1.0.0-rc.1` still reads as a +`1.0.0`, marked by a small band of dots. + +## The testable invariant + +Distinguishability is a test, not a claim. This is the part that separates +blazon from any identicon port. + +Marks are rendered across a corpus of 184 versions, reduced to a 128-bit +perceptual hash — a DCT hash plus a difference hash, because either alone +confuses shapes the other separates — and the Hamming distances between related +versions are held to per-renderer thresholds: + +```go +blazontest.Suite{ + Renderer: myRenderer, + Thresholds: blazontest.Thresholds{MinPatch: 6, MinMinor: 9, MinMajor: 20, Floor: 2}, +}.Run(t) +``` + +The suite checks: + +- **patch, minor and major neighbours** clear their minimum distances; +- **no pair anywhere** in the corpus falls below the global floor; +- **family cohesion** — versions sharing a major really are closer to each + other than to other majors, so the family policy is not a lie; +- **prereleases stay close** to their release, bounded from above rather than + below; +- **uniqueness** — no two versions render to the same bytes, exactly rather + than perceptually; +- **determinism** — the same version renders identically, byte for byte where a + renderer declares it can. + +Measurement is monochrome on purpose. A luminance-based hash scores two marks +differing only in hue as identical, so measuring in colour would credit colour +for distinctness the form does not have. Judging form alone makes the guarantee +hold in print, in a monochrome terminal, and for a colour-blind reader. + +Thresholds are calibrated from each renderer's own histogram, not guessed: + +``` +blazon dist -r truchet +``` + +## Output + +- **SVG** — plain string building, deterministic number formatting. +- **PNG** — anti-aliased by exact area coverage: each edge deposits the signed + area it sweeps per pixel column, and a running sum along the row gives + coverage. Filling a shape and summing the alpha returns its geometric area, + which is how the anti-aliasing is tested. +- **Terminal** — half-block characters give each cell two square pixels, so + vector renderers arrive as pictures rather than as ASCII art. Falls back to a + density ramp under `NO_COLOR`. + +Colour is derived in OKLCH with gamut mapping, held in a lightness band that +keeps contrast on both a white page and a dark terminal. + +## No dependencies + +Standard library only — including the rasteriser and the font. Those are the two +things that decide what the pixels are, and a library whose whole premise is +that a version always produces the same mark cannot outsource them: a dependency +that sharpened its anti-aliasing would change every mark ever generated, +arriving as a routine version bump. A test enforces it. diff --git a/docs/renderers.md b/docs/renderers.md new file mode 100644 index 0000000..ea4875e --- /dev/null +++ b/docs/renderers.md @@ -0,0 +1,97 @@ +# Renderers + +Every renderer works on every back end — SVG, PNG and terminal. Pick one with +`Options{Renderer: "name"}` or `blazon -r name`. + +| Name | What it is | Readable? | +| --- | --- | --- | +| `superformula` | Gielis' superformula, drawn from curated shape families — star, flower, gear, crystal, leaf, shield. The default. | no | +| `flowfield` | Streamlines through a Sherlock–Monro orientation field with fingerprint cores and deltas. Arch, loop and whorl come from the major version; minutiae emerge from the ridge spacing rule. | no | +| `truchet` | Truchet tiles. Arcs meet at cell edge midpoints, so the marks join across the grid into loops and labyrinths. | no | +| `polar` | A bitmap in rings and sectors instead of rows and columns, mirrored across a fold from the archetype stream, with a centre device in the hole. | no | +| `cistercian` | Medieval numerals: one stave per version component, four digits per stave. | yes | +| `dial` | One quadrant per component, the value written in binary as radial bands. The separator is the angle: the spokes are the dots of the version string. | yes | +| `orbit` | One ring per component, major innermost, the value written in binary as cells running clockwise from noon. The separator is the radius. | yes | +| `bishop` | The drunken bishop walk from OpenSSH randomart, for the terminal. | no | + +The readable ones encode the number itself rather than a hash, so the version +can be read back out of the mark. They are marked `Ordered` and held to +uniqueness rather than to the perceptual distance thresholds — being systematic +is the point of them. See [how it works](how-it-works.md). + +## Gallery + +Generated by `blazon gallery` and verified in CI: if the committed images stop +matching what the code renders, the build fails. + + + + + +### bishop + +Terminal renderer on a 17×10 character grid. + +![bishop across a version corpus](gallery/bishop-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![bishop family strip](gallery/bishop-family.png) + +### cistercian + +![cistercian across a version corpus](gallery/cistercian-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![cistercian family strip](gallery/cistercian-family.png) + +### dial + +![dial across a version corpus](gallery/dial-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![dial family strip](gallery/dial-family.png) + +### flowfield + +![flowfield across a version corpus](gallery/flowfield-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![flowfield family strip](gallery/flowfield-family.png) + +### orbit + +![orbit across a version corpus](gallery/orbit-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![orbit family strip](gallery/orbit-family.png) + +### polar + +![polar across a version corpus](gallery/polar-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![polar family strip](gallery/polar-family.png) + +### superformula (default) + +![superformula across a version corpus](gallery/superformula-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![superformula family strip](gallery/superformula-family.png) + +### truchet + +![truchet across a version corpus](gallery/truchet-sheet.png) + +Family: `1.0.0` → `1.0.1` → `1.0.2` → `1.1.0` → `2.0.0` + +![truchet family strip](gallery/truchet-family.png) + + diff --git a/docs/service.md b/docs/service.md new file mode 100644 index 0000000..11547ad --- /dev/null +++ b/docs/service.md @@ -0,0 +1,49 @@ +# Hosted marks + +If all you want is a mark in a README or a web UI, there is an endpoint for it — +no build step, no asset pipeline: + +``` +https://api.blazon.build/v1/mark/1.4.2.svg +https://api.blazon.build/v1/mark/1.4.2.svg?renderer=flowfield&bg=dark +https://api.blazon.build/v1/mark/1.4.2.png?renderer=bishop&size=128 +``` + +```html +v1.4.2 +``` + +`/v1/mark/{version}.{svg|png|txt}`, with `renderer`, `size`, `padding`, +`policy`, `palette`, `bg`, `cols` and `nocolor` spelled exactly as the CLI +spells them. `/v1/renderers` lists what exists and the current size ceilings; +`/v1/healthz` reports which release is deployed. A mark is a pure function of +its URL, so the answers are cacheable forever and are served that way. + +It runs the same code this repository publishes — the deploy compares every +response against a `go run` of the same version, byte for byte, and refuses to +ship on a disagreement. So the marks are the marks. What differs is the budget. + +## Limits + +`size` runs from 16 up, with a ceiling per format: **1024 for SVG, 128 for +PNG**. The difference is not arbitrary. SVG emits geometry, and its cost is +whatever the renderer's own maths costs — the requested size only ever reaches +the `viewBox` attribute. PNG rasterises and deflates, so its cost grows with the +square of the edge length. + +That is a limit of one small deployment, not of the library. `blazon.PNG` has no +ceiling, no renderer is excluded, and rendering locally costs a few +milliseconds: + +```go +data, err := blazon.PNG("1.4.2", blazon.Options{Renderer: "flowfield", Size: 1024}) +``` + +Fetching a mark that has already been drawn is not rate limited; drawing a new +one is, per address. Asking for a few hundred distinct marks in a burst will meet +a `429`, and the answer to that is a `for` loop against the library rather than +against the endpoint. + +The service is best effort and carries no availability promise. Anything that +matters should render its own marks — that is the whole point of a library with +no dependencies.