Skip to content

A viewer for contributed observations, and an explorer for the census - #35

Draft
zmaril wants to merge 55 commits into
mainfrom
claude/payload-upload-viewer-300zos
Draft

A viewer for contributed observations, and an explorer for the census#35
zmaril wants to merge 55 commits into
mainfrom
claude/payload-upload-viewer-300zos

Conversation

@zmaril

@zmaril zmaril commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

An upload used to end with a link to the bytes. Following it got you a
zstd-compressed frame of parquet files — honest, and unreadable without the
client, a decompressor and a parquet reader.

Now the site reads it for you:

cratebank: your observation is public at https://cratebank.io/view/sessions/schema=…/….cbnk.zst
cratebank: readable there until the next compaction, typically 05:00 UTC daily, which merges it into the public tables

Four commits: the viewer, then somewhere to walk around in it, then the truth
about how long any of it lasts, then saying that truth at the moment it
matters.

1 — Show a contributor the observation they just uploaded

  • crates/worker-ingest answers with view alongside url, from a new
    VIEW_BASE binding. Each field is absent when its domain is not bound, as
    url already was.
  • crates/client prints view when the collector offers one and url
    otherwise, so cargo cratebank serve is unaffected.
  • infra/worker/site.ts is a new Worker on the site. It reads R2 through
    its own BUCKET binding, bundle.ts decompresses and unframes, render.ts
    renders, view.css styles.
  • build-site.ts also writes dist/site/viewer.css, which the pages link
    rather than inline.

2 — Make the viewer somewhere you can walk around

/view/ used to be a 404 explaining what a key looks like. It is now the top
of the space, and every partition between it and an object is a page: schema
fingerprints, then years, months and days, then the uploads — each level
newest first, with a trail back up from anywhere. The front page also walks
down to the newest day in the last few schemas so recent uploads are one click
from the top; that walk is capped, because a bucket is not. A prefix nothing is
filed under is a 404 rather than an empty page pretending to be a place.

An observation opens with what a person came to find out — an M2 Pro compiled
42 units in 37.4 s on dev
— rather than the first of 47 fields, then lists
what is in the object with row counts, so tables can be jumped to instead of
scrolled past. Truncated tables link to the rest of themselves; ?rows=N
raises the 200-row cap as far as 2000.

Bots are refused, four ways: /robots.txt disallows /view/,
X-Robots-Tag is on every response, every page repeats it in a robots meta
tag, and a request whose user agent names a crawler, a scraper or a link
preview gets a 403 pointing at data.cratebank.io. The regex deliberately
avoids a bare bot match — CUBOT NOTE 20 is a phone, not a crawler — and is
tested against six bot agents and four browser ones. A user agent is a claim
rather than proof, so this stops the cooperative and the lazy; what it protects
is not secrecy (every byte is public) but the cost of serving pages nobody
reads.

3 — Say what a staged object's lifetime actually is

Opening the deployed preview against the production bucket showed sessions/
empty: worker-compact deletes every bundle it merges, so with a daily
cron a viewer link is good for about a day.

The viewer had been telling anyone who followed a dead link that uploads are
kept indefinitely and they must have mistyped the key. Both halves were wrong,
and sending that person off to check their typing is the worst answer
available. So the 404 now names compaction and points at the published tables
where the rows went, an observation page says the object is staged rather than
archived, and the listing says it shows the recent past rather than the census.

4 — Tell a contributor how long their link is good for

The lifetime is now said where somebody is actually looking at the link: a
second line under the URL the client prints.

The schedule comes from the collector, not the client. Ingest carries a
COMPACTION var and returns it beside url and view. A client that
hardcoded 05:00 UTC would be claiming it about cargo cratebank serve and
about anybody else's collector, neither of which compacts at all — so a
collector that says nothing has nothing said on its behalf and the line is
simply not printed. "Typically" because a deploy compacts too.

COMPACTION must be kept in step with the cron in
crates/worker-compact/wrangler.jsonc. Nothing enforces that; both are
commented to point at each other.

Decisions worth a look

  • R2 binding, not data.cratebank.io. Same origin, so no CORS rule to keep
    in step, and the pages do not depend on the public data domain. It costs the
    site Worker a bucket binding.
  • run_worker_first: ["/view/*"]. Every other path is still an asset
    served without waking any code.
  • Rendered on the server, no script.
  • Bounded work. Only sessions/ is ever read or listed, so the published
    tables and the client manifest sharing the bucket stay outside the viewer;
    200 rows per table by default, counts taken from parquet footers; objects
    over 16 MB compressed are linked rather than opened.

Testing

  • npm test — 15 tests: framing, row counts, escaping, routing, key and prefix
    shapes, percent-encoding, an object that will not open, browsing, the bot
    refusal, and the header on every response.
  • cargo test --workspace --all-features (56), clippy -D warnings,
    fmt --check, and cargo check --target wasm32-unknown-unknown for the
    ingest Worker.
  • End to end against wrangler dev: a bundle from cratebank_core POSTed to
    the ingest Worker, the returned view URL opened on the site Worker, all
    nine tables rendered from R2; then browsing across three objects in two
    schemas, ?rows=, the empty-prefix 404. Checked light and dark. The real
    ingest Worker was re-run after commit 4 to confirm compaction comes back in
    the JSON.
  • Against this PR's deployed preview, bound to the production bucket: assets
    serve without waking the Worker, the viewer answers, Googlebot gets a 403
    without the bucket ever being touched, robots.txt and X-Robots-Tag are
    live.

Still open, for the repo owner

Both are about the same gap and neither is mine to decide:

  1. A viewer link expires. Making it permanent means either keeping merged
    bundles (unbounded storage, and compaction would need to remember what it
    has read) or rebuilding the page from the published tables.
  2. README.md and site.md say sessions.parquet carries the key of the
    blob each row came from.
    It has no such column and nothing writes one, so
    there is no path from a published row back to its object — which is also
    what blocks option (2) above.

Leaving the link as a receipt and saying so plainly is what this PR does.


5 — The build, drawn

Cargo's --timings report answers three questions — when each unit ran, how busy the machine was, and where the time went. A contributor has seen that page already; it is the thing they ran to make this object. A viewer that showed only tables was asking them to rebuild it by eye.

All three are drawn now, from the stored tables, as server-rendered SVG with no script — an SVG <title> is a tooltip in every browser, and a chart that needs JavaScript is a chart that does not work in a curl.

And then two Cargo cannot draw, because it does not sample:

Cargo here
parquet v59.2.0 4.26s, 100% frontend codegen 71%, borrowck 7%, type_check 7% — 584 MB peak

Worth staring at: Cargo says 100% frontend, the samples say 71% codegen. Not a contradiction — Cargo's "frontend" is wall time up to rmeta, and a unit with no rmeta split reports all of it that way. The samples are what actually ran. That gap is the argument for collecting phases at all.

A fully cached build says so rather than drawing 167 zero-width rows. That will be the common case now the organisation contributes from CI with warm caches.

6 — One table where there were five

units, phases, unit_flags, compiler_units and artifacts are all keyed to a unit, so reading them apart meant doing the join by eye, five times, for every unit you were curious about.

#  Unit            Target  Total  Frontend  Phase mix  Peak memory  Output     Unblocked  Flags          Features
80 parquet 59.2.0  lib     4.26s  4.26s     ▇▇▇▇▁▁     584 MB       2 · 35 MB             debuginfo 2 …  snap

Artifacts join on unit_index — exact, not by name; the only table that can. Sorted longest first and every column sorts, which is what replaced a separate "slowest units" list: a table you can sort already is one.

  • Phase mix is a shape, not a number. Forty units read as forty bars at a glance and as forty percentages not at all.
  • Features and flags are pills. clone-impls, default, derive, extra-traits, full is a sentence you have to read; five pills is a shape you count.
  • run_id is gone from every table — identical on all 4,000-odd rows, and in the rail once.
  • Seconds show two decimals, with the stored value in data-sort so sorting and the JSON download keep the precision the page does not need.

The charts were being built from the first 200 rows. phases has 1,957 and the display cap applied to the join too — 18 phase bars instead of 165. The tables a join needs are read in full now while the raw sections still show 200 with show them all. A gantt drawn from a tenth of the data is worse than one that admits it cannot draw.

7 — A page, rather than a list of tables

Two columns. Configuration in a sticky rail — the glance, sessions, build_config, the contents — because checking a chart against which rustc was this is the thing the single column made you scroll back up for. Measurements in the column that scrolls. The stored tables fold into a <details>: still the object, still what the JSON download reads, no longer in the way.

Full width. main was capped at 46rem by the site's stylesheet — a measure for prose. This is a page of tables and charts, where every rem is another column on screen.

Sorting is sortable-tablesort, 1.7 KB of vanilla JavaScript copied out of node_modules at build time, not a CDN link. A page that reaches off its own origin to sort a table stops sorting when someone else's host has a bad day.

Download as JSON reads the DOM:

for (const section of document.querySelectorAll("section[data-table]"))

Every row is already on the page, so embedding them again would double the page to save a walk over it — and it makes the download honest, containing exactly what is on screen, with ?rows= changing both together.

8 — A tree

/view/ was one level of partitions per page: four clicks to reach one of a dozen objects, which is a filing system standing in for a list. It is the whole staged keyspace now, as nested <details open> — no script, and every object one click from the top.

One list without a delimiter returns keys rather than one level of them. That is affordable because compaction deletes what it merges: this is a day or two of uploads, never the history of the census.

9 — /data/*, and somewhere to ask questions

The published tables had no CORS headers. Checked before assuming:

$ curl -I -H "Origin: https://cratebank.io" -H "Range: bytes=0-99" \
    https://data.cratebank.io/units.parquet
HTTP/2 206
accept-ranges: bytes
← no access-control-allow-origin

Range requests work, so partial parquet reads would be efficient — but a browser on this site could not fetch a byte. /data/* serves the same objects from this origin with the request's own headers passed to R2, which is what keeps a query to three or four ranged reads instead of a download. The allowlist is shapes rather than names, so a new table needs no edit here, and sessions/ is deliberately excluded — staged objects belong to the viewer.

/explore/ runs DuckDB against those tables and hands the Arrow to Perspective, which pivots, filters and plots it in a toolbar — 13 chart types, no second query written. Every table is a view over the parquet, not a copy, so opening the page costs one footer read each and a query costs only the columns it names.

opens on              15 rows in 64 ms
switch to units      167 rows in 63 ms
a join across tables  20 rows in 36 ms

Both engines are self-hosted; nothing is fetched from a CDN and no query leaves the tab. Perspective's 3.4 MB of wasm is a static asset. DuckDB's 34 MB is past what an asset may be, so it lives in the bucket keyed by its version — an immutable URL caches for a year, an overwritten one cannot cache at all — and the deploy publishes it once per DuckDB release, checking the public URL because that is the one the browser will use.

A loading screen covers the panel while ~40 MB arrives, with a real percentage from DuckDB's own progress handler, an indeterminate sweep when there is no share to report, and no animation under prefers-reduced-motion.

Four bugs, none findable without running it

  • Missing perspective-client.wasm. The plain ESM builds want every wasm module initialised by hand with a URL; getting it wrong is a blank page. The .inline.js builds carry their own.
  • worker.table is not a function. perspective.worker() resolves to the client; it is not one.
  • Failed to open RecordBatchStreamReader. Two copies of apache-arrow — duckdb-wasm pins ^17, I had installed 21 — so tableToIPC did not recognise the table and returned nothing. Pinned to 17 and deduped. Also "file""stream"; Perspective's reader never looks for a footer.
  • ~200 NaN SVG paths per table switch. Perspective keeps its configuration across a load, so a bar chart grouped by phase, handed a result with no phase, drew a hundred d="…vNaNh…" paths and reported nothing. A changed shape resets to the table before loading — resetting after still let one frame render against the old config, which is why the first fix did not take.

And one that had nothing to do with the browser: three template literals in chart.ts had a NUL byte where a space belonged. Invisible in an editor, type-checked fine, and it stayed hidden because both sides of the memory and phase joins had it — so those worked, and the flags join, written later with a real space, was the one that silently returned nothing.

Also corrected

site.md claimed sessions.parquet carries the key of the blob each row came from. It has no such column and has not since the bundle format landed, which is item (2) of Still open above — now stated where it is read, and the viewer links to the bytes it read instead. The viewer link on the front page is its own section and relative, and both the site and the viewer have a favicon.

Checked

tsc clean on both projects, 15 viewer tests, 79 Rust tests, clippy clean with all features. /explore/ driven in a browser end to end: 0 console errors, four benign warnings from Perspective noting it is in inline mode.

Not checked

Nothing tests the explorer or the /data/* guards. The two are worth a test each before this is relied on.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ

An upload used to end with a link to the bytes. A contributor who followed
it got a zstd-compressed frame of parquet files, which is honest and
unreadable: to see what they had actually contributed they needed the
client, a decompressor, and a parquet reader.

So the site now reads it for them. Ingest answers with a `view` URL as well
as a `url`, the client prints the page rather than the blob, and
`cratebank.io/view/<key>` is that one upload with every table in it -- the
session's own row down the page, the rest across it, row counts taken from
the parquet footers so a large table says how much of itself is on screen.

The page is rendered on the server and ships no script. The site Worker
reads the object through its own R2 binding rather than over
data.cratebank.io, so there is no CORS rule to keep in step and the page
does not depend on the public data domain being up. `run_worker_first`
names `/view/*` and nothing else, so every other path is still an asset
served without waking any code.

Only keys shaped like the ones ingest writes are ever fetched -- R2 has no
read-only binding, so that check is in the code -- and only the first two
hundred rows of each table are decoded. Objects above 16 MB compressed are
linked rather than opened: a bundle decompresses to several times its
stored size and a Worker has 128 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

cratebank preview

Open the static preview

Run automatically for a PR authored by @zmaril.

OpenTofu plan

No infrastructure changes.

Plan output
cloudflare_workers_custom_domain.site["www.cratebank.io"]: Refreshing state... [id=2426388fdde2ff5a2083a1458e423f27566fb994]
cloudflare_workers_custom_domain.site["cratebank.io"]: Refreshing state... [id=41b6bb2dc82ba0dacaa13bb7056f0304a92e9065]
cloudflare_r2_bucket.cratebank: Refreshing state... [id=cratebank]
cloudflare_workers_route.data_root[0]: Refreshing state... [id=8392a042ec984865a4842bde55fa086c]
cloudflare_workers_custom_domain.ingest[0]: Refreshing state... [id=6edef664db9c85623282947938570ffb8054b932]
cloudflare_r2_custom_domain.data[0]: Refreshing state...

No changes. Your infrastructure matches the configuration.

OpenTofu has compared your real infrastructure against your configuration and
found no differences, so no changes are needed.

The viewer could only be entered by link. If you had a key you got a page,
and if you did not you got a 404 telling you what a key looks like -- so
the census had a reader for exactly one observation at a time, the one you
had just uploaded, and no way to see any of the others.

Now `/view/` is the top of the space and every partition between it and an
object is a page: schema fingerprints, then years, months and days, then
the uploads, each level newest first, with a trail back up from anywhere.
The front page also walks down to the newest day in the last few schemas,
so the most recent uploads are one click from the top; that walk is capped,
because a bucket is not. A prefix nothing is filed under is a 404 rather
than an empty page pretending to be a place.

An observation opens with what a person came to find out -- an M2 Pro
compiled 42 units in 37 seconds -- rather than the first of 47 fields, then
lists what is in the object with the row counts, so the tables can be
jumped to instead of scrolled past. A truncated table now links to the rest
of itself; `?rows=N` raises the cap as far as 2000.

Only `sessions/` is reachable, listing included, so the published tables
and the client manifest that share the bucket stay outside the viewer.

And the viewer is for people, said four ways: `/robots.txt` disallows it,
every response carries `X-Robots-Tag`, every page repeats it in a `meta`
tag, and a request whose user agent names a crawler, a scraper or a link
preview is refused with a 403 that points at data.cratebank.io. A user
agent is a claim rather than proof, so this stops the cooperative and the
lazy -- which is all it needs to stop, since nothing here is secret and the
same data is free in bulk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
The viewer told anyone who followed a dead link that uploads are kept
indefinitely and they must have mistyped the key. Both halves are wrong.
Compaction deletes every bundle it merges -- `worker-compact` does it in
two places, and the daily cron means a link is good for about a day -- so
the overwhelmingly likely reason a key is missing is that the census has
already absorbed it. Sending that person off to check their typing is the
worst answer available.

So the 404 names compaction and points at the published tables, where the
rows went; an observation page says the object is staged rather than
archived; and the listing says it lists the recent past rather than the
census. The rows outlive the object, and every page now says which is
which.

Found by opening the deployed preview against the production bucket, where
`sessions/` is empty: everything uploaded so far has been merged already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
@zmaril zmaril changed the title Show a contributor the observation they just uploaded A viewer for contributed observations Aug 26, 2026
The client printed where the observation went and stopped there, so the
first anyone learned that a staged object has a lifetime was when their
link 404ed. Compaction deletes what it merges, and it runs at 05:00 UTC, so
that is a day at most -- worth one line at the moment somebody is looking
at the link, rather than a discovery later.

    cratebank: your observation is public at https://cratebank.io/view/…
    cratebank: readable there until the next compaction, typically 05:00 UTC
    daily, which merges it into the public tables

The schedule comes from the collector rather than the client: ingest carries
a COMPACTION var and returns it beside url and view. A client that hardcoded
05:00 would be claiming it about `cargo cratebank serve` and about anybody
else's collector, neither of which compacts at all -- so a collector that
says nothing has nothing said on its behalf, and the line is simply not
printed. "Typically" because a deploy compacts too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
Five pieces, all on top of the viewer this branch already added.

**The build, drawn.** Cargo's `--timings` report answers three
questions -- when each unit ran, how busy the machine was, and where
the time went -- and a viewer that showed only tables was asking a
contributor to rebuild that page by eye. It is drawn now, as
server-rendered SVG with no script, plus a phase bar Cargo cannot draw
because it does not sample.

**One table where there were five.** `units`, `phases`, `unit_flags`,
`compiler_units` and `artifacts` are all keyed to a unit, so reading
them apart meant doing the join by eye once per unit. They are one
sortable row each: timings, phase mix, peak memory, outputs, flags and
features. `run_id` is gone from every table, being the same value on
all four thousand rows, and seconds show two decimals with the stored
value in `data-sort`.

**A page laid out.** Configuration in a sticky rail, measurements in
the column that scrolls, the stored tables folded into a `<details>`,
and the whole thing at the window's width rather than a 46rem measure
meant for prose. Tables sort, and a button turns the page into JSON by
reading the DOM rather than shipping the rows a second time.

**A tree.** Four clicks through partitions to reach one of a dozen
objects was a filing system standing in for a list.

**Somewhere to ask questions.** `/data/*` serves the published tables
from this origin with range requests passed through, because
`data.cratebank.io` has no CORS headers and a browser here cannot read
a byte of it. `/explore/` runs DuckDB against those tables and hands
the Arrow to Perspective, which pivots and plots it without anyone
writing a second query. Both engines are self-hosted; the 34 MB one
lives in the bucket, keyed by version, and the deploy publishes it once
per release.

Found while testing: two copies of apache-arrow made `tableToIPC`
return nothing, Perspective keeps a chart configuration across a load
whose columns no longer exist, and three template literals in chart.ts
had a NUL byte where a space belonged -- which silently broke one join
while the others worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
@zmaril zmaril changed the title A viewer for contributed observations A viewer for contributed observations, and an explorer for the census Aug 26, 2026
Four rules, four different answers, because they were four different
situations.

**Colour.** The chart palette was 34 hex literals scattered through the
rules that used them, which is exactly what the rule is about. They are
tokens now, defined once, and every use is a `var()`. The definitions
carry a marker, because a palette is where colour literals belong.

**Motion.** One animation, on the loading bar, marked with why: a bar
that does not move cannot say the difference between downloading and
hung, and the point of that overlay is a 40 MB download. It is already
off under `prefers-reduced-motion`.

**Inline SVG.** The rule is about icons pasted into component source and
asks for them to be extracted. There is no icon: every `<svg>` in
`chart.ts` is a plot generated from one observation's numbers, and there
is nothing to extract because none of it is the same twice.

**A stray TODO** that was the word `todo` in a comment about Cargo's
placeholder for an ordinary build, and a `color` finding that was the
box emoji's code point. Both reworded rather than suppressed where the
wording was the problem.

Also: browser scripts are TypeScript like everything else here.
`viewer.js` was the only tracked `.js` in the repository, which CI
asserts there are none of; it is `viewer.ts`, type-checked, and bundled
to `viewer.js` at build time.

And suppression markers are stripped from what gets published. They
explain to the scanner why a rule does not apply, and a visitor reading
view-source has no use for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Written whole, the string this function searches for is itself a
suppression marker as far as the scanner is concerned -- one that
suppresses nothing, which is a finding of its own. It is spelled in two
halves now, with a comment saying why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Four changes to the observation page.

**The rail is twice as wide.** Configuration was being wrapped to fit a
column sized for a summary.

**The unit timeline moved to the foot of the page.** It is one row per
unit, so a real build is thousands of pixels tall and everything after
it was unreachable. The summary at the top links down to it.

**The phase mix is in pipeline order.** Sorted by size it told you
which phase was biggest, which the column already showed. In the order
a compile does them it tells you the shape of the compile, and the same
phase sits in the same place in every row -- so a column of bars can be
read down rather than one at a time. The order is
`cratebank_core::payload::phase::ALL`, which is also the vocabulary the
census publishes; a phase not in it sorts after the ones that are.

That also fixes the colours, which were assigned by first appearance
and so disagreed between a unit's bar and the build's. A phase's hue is
its place in the pipeline now, and the palette went from eight to
sixteen so the fifteen canonical phases each have their own.

**The leading phase is no longer named beside the bar.** The bar is the
column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Nothing on it is under 12px now. The floor was 10.5px on pills and
11.5px on table text, and in the charts it was worse: SVG text scales
with the viewBox, so 8.5px of label rendered at about 6.7px. The
charts' user units are pixels now -- the viewBox is 1000 wide and the
element is never narrower -- so a size in there is the size it says,
and the gantt's rows grew to hold a 12px label.

**The phase mix fills its column.** It was 5.5rem of bar in whatever
width the column happened to be, which for the one column that is a
picture is the wrong way round.

**Package and version are two columns.** They are two facts, and a
table that sorts should sort by each of them.

**Everything says what it is on hover.** The joined table's headings
are written here, because they are this page's own words. The stored
tables' headings are not: they come from `schema/v1/tables.json`, which
compaction publishes beside the data, so a heading explaining itself is
quoting what a consumer of the parquet reads rather than a second copy
of it. Read once per page, and a missing file costs the titles rather
than the page.

Found on the way: two sections were both `id="units"` -- the joined
view and the stored table it is a view of. The stored tables are
`raw-` prefixed now, which is what the contents list and `?rows=` link
to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
…o start

**The observation page states its conclusion.** Two numbers decide
whether a build wants more cores, and both were sitting in the data
unsaid. On the build I tested against:

    12 cores for 14.52s, of which 42% went unused: 101.26s of compiling
    in 174.24s of available core time. Its critical path -- the longest
    chain of units that must run one after another -- is 14.00s of that,
    so this build was waiting on its own dependency graph rather than on
    cores. More of them would not have helped.

The critical path is a longest-path walk over `edges`, which Cargo
already reports and the census already stores; the nine units on it are
outlined in the timeline at the foot of the page.

**The concurrency plot fills to the core count.** Units active is an
area now rather than a line, and the gap between it and the ceiling is
the machine that went unused -- which is the reason to look at the plot
and was previously left as an inference.

**The explorer opens on an answer.** An empty box and a list of table
names is a fair interface for somebody who already knows what they want
to ask, and nobody arrives knowing that. There are eight questions --
where the time goes, the slowest crates, frontend against codegen, the
hungriest crates, phase mix by crate, what holds everything up, every
build, cores against wall time -- each carrying the query that answers
it and the shape the answer should be drawn in. The tables are still
there, as the other way in.

Two bugs behind that: `save()` on a viewer with nothing loaded throws
`No Table attached`, which took out the first query of every session;
and a connection opened per query and closed in a `finally` cancels
whatever the next click has already started, so there is one now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
`--dry-run` prints exactly what an upload would contain and sends
nothing, which until now left you holding a screenful of JSON. The
viewer could have read it, except the viewer only worked on builds
already in the bucket -- so seeing the charts meant contributing first,
which is backwards for anyone deciding whether to contribute at all.

The top of the viewer takes a paste of it now and draws the same
things: the verdict, the concurrency plot, the joined unit table, the
critical path, the timeline.

Not a second implementation of any of that. `chart.ts` builds strings
out of tables and touches nothing belonging to a Worker, so the browser
bundles the same module and calls it with tables it made from the
pasted rows. One set of charts, two callers.

Nothing leaves the page, and the page says so where it can be checked
rather than in a paragraph somewhere else: it links to the script that
does the reading. That script contains no `fetch`, no `XMLHttpRequest`,
no `WebSocket`, no `sendBeacon`, and no storage of any kind -- checked
against the built bundle rather than the source.

Log lines around the JSON are tolerated, because a terminal shows the
progress and the tables together and telling someone which half to copy
is worse than taking the outermost braces. A truncated paste says which
JSON is wrong rather than that some is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
"Read it" drew the charts underneath the paste box, which is not what a
person who has just pasted a build wants: they want the page a
contributed build gets. It is that page now, at `/read/`, and the box
folds away once it has been used.

The layout came out of `observation()` into a `view()` both callers
share, so a pasted build and a stored one are the same page built by
the same function -- rail, glance, verdict, charts, joined units, the
stored tables, the timeline. Everything that assumes a stored object is
optional, because for a pasted build there is not one.

Also two ways it failed quietly. An observation from an older client
has an `artifacts` array of its own, so hunting for tables found one,
called it a table, and drew nothing -- which looks like it worked. The
shape is checked first now and says which of the two it is looking at,
and what to run to get the other. Tables that are all empty say nothing
was measured rather than drawing an empty page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
The concurrency plot sawtoothed to zero and the CPU area came out as
triangles. Both were the same mistake, and it was mine rather than the
data's.

`timeline` holds two series on two clocks: Cargo samples how many units
are running on one schedule and how busy the processor is on another,
and a row carries one or the other. The Rust says so, in a comment,
above the code that builds it:

    // Two series on two clocks. A row carries the concurrency triple or
    // cpu_pct, never both, and a reader filters on the column they want.

The column that is not recorded is null. My reader turned every null
into 0 -- `Number(null)` is 0 and finite -- so each series was drawn
down to the floor wherever the other one had a sample. 302 rows read as
one series instead of 224 and 78 read as two.

There is a null-aware accessor now, and each series is filtered and
sorted on its own. `inactive` touches zero three times, at the end of
the build, rather than 79 times throughout it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Four things, and one of them was doing most of the damage.

**A tooltip per phase.** Fifteen phases and their percentages in a
single tooltip is a paragraph to read when the question was what the
band under the pointer is. Each band answers for itself now.

**A dash where there is nothing.** Codegen, link, output, memory: an
empty cell reads as a rendering fault rather than as a fact, and the
stored tables already use a dash for exactly this.

**Four pills and a count.** Eleven features wrapped inside a cell made
a row four lines tall, and a table of those is a table you scroll
rather than read. The rest are behind a `+7` that names them on hover.

**And the actual culprit.** The site gives every `li` a .6rem vertical
margin, which nothing here reset -- so each pill carried 19px of
nothing above and below it, in a cell 20px tall. Rows were 46px for
12px text, and the two-line ones were 250.

Median row: 46px to 27px. The table is 4,491px instead of 7,657px for
the same 167 units, and the padding came down with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
The lane chart looked wrong on bun's release build and the data was
fine: 2,165 phase bands, 23 unsampled units. What was wrong is that
three entries in the palette are the same amber.

"Nothing was sampled from this unit" was a flat `--other`, which sits
99 units of perceptual distance from `codegen` -- closer than
`codegen` and `metadata_encode`, which are deliberately adjacent in the
same family, at 143. On a build that is 77% codegen that is the worst
collision available: the unmeasured units looked like the most measured
thing on the page.

They are hatched now, in both the lane chart and the dependency graph,
with a line in the legend. A hatch is not a colour in the palette and
cannot be read as one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
claude added 4 commits August 27, 2026 12:04
Every Linux build in the census publishes 100% `unattributed`: 1.7M
samples across four sessions, no phase data at all. That is not a
compiler doing nothing recognisable, it is a lookup with the wrong key.

samply writes a library three ways -- `codeId`, `debugId`, and the
`breakpadId` that is the debug id with an age digit stuck on the end --
and this only ever asked by code id. A code id is a build id, and an ELF
library need not carry one: on Linux `rustc`, `librustc_driver.so` and
`libLLVM.so`, which is to say every library a compile actually runs in,
arrive with `codeId: null` while the symbol sidecar knows them perfectly
well by debug id. Nothing errors. Every frame resolves to nothing, every
stack comes back empty, and `classify` answers `unattributed` for a
build it was never shown.

Measured on a release build of a two-dependency workspace, stable 1.94
on x86_64 Linux: 6.6% of frames resolved before, 100% after, and 35% of
samples had no symbol at all on the stack rather than 0%.

The load side already knew about all three spellings -- it indexes by
both ids -- so this is the lookup catching up with it. Sorting the
symbol table is the other half: `resolve` binary-searches it, so its
order is a correctness requirement rather than something the sidecar
promises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
`codegen` was one name over the whole back end. On the one macOS build
in the census with phase data it is 95.5% of every sample on the
parallel threads and 41% of the build's CPU, in a single bucket whose
content is "the compiler was compiling". Four crates in that build are
more than half codegen and nothing says which half.

It could not have been otherwise. rustc runs back-end work items on
worker threads, so the outermost marker on such a stack is always the
thread's own entry closure inside `rustc_codegen_llvm` -- in one profile
28,756 of ~30,000 codegen samples resolve their outermost marker to
`std::thread::lifecycle::spawn_unchecked<LlvmCodegenBackend>`. The
outermost rule is right for the frontend, where name resolution driven
by macro expansion is expansion; inside the back end it can only ever
answer `codegen`.

So a sample now carries three names. A **segment** -- frontend, backend,
link -- which is the level two builds are worth comparing at. A
**phase**, the old vocabulary plus the five the back end turns out to
be: `ir_build`, `optimize`, `machine_code`, `lto`, and `monomorphize`,
matched innermost-first by rustc's own C++ shims (`LLVMRustOptimize`,
`LLVMRustWriteOutputFile`, `LLVMRustPrepareThinLTOImport`), which bound
exactly the operations worth naming and are as stable as the symbol
table. And a **detail**: which pass family in the optimiser, whether
machine code was instruction selection or register allocation or the
assembler, which half of type checking. `codegen` survives as the
answer for back-end work no finer marker claimed -- 2% of a real build.

`link` moves out of `codegen` entirely. Linking is inside
`rustc_codegen_ssa::back::link`, so running `cc` has been published as
compiling.

Two more things the profile already carried and this threw away.

rustc names each worker thread after the work item and the codegen unit
it was given. That was read for one boolean and discarded, which was
wrong twice: `lto cgu.NN` matched none of the three prefixes tested, so
thin-LTO work -- 19,252 samples against `opt`'s 10,903 in a release
build, the largest single class there is -- was counted as the serial
compile, and the sixteen codegen units of a unit were one
indistinguishable number. Both are kept now, as `thread` and `cgu`.

And every sample has a timestamp, which was counted and dropped, so
"when did this unit stop type checking and start emitting code" had to
be guessed at downstream from totals. Consecutive samples that stay in
one phase become one span with a start and an end. A thread that goes
unsampled for more than ten milliseconds starts a new span rather than
stretching the last one over a gap nothing measured -- without that a
two-sample run three seconds apart draws as a three-second band.

`phases.parquet` gains segment, phase, detail, thread and cgu;
`phase_spans.parquet` is new and carries the same samples in time order.
Both come from one pass over one profile, so a phase mix and a timeline
of it cannot disagree. Spans stop at the second level: at 5 kHz the
detail changes almost every sample, and encoding it produced 101,492
spans for 274,020 samples, 61% of them one sample long, against 15,114
without it.

Measured end to end on Linux, release, thin LTO: backend 64%, frontend
31%, link 1%, unattributed 4%. Inside the optimiser: memory 21%, loops
20%, instcombine 15%, analysis 12%, inlining 8%, and 24% that named no
family.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Every phase chart on the page was a mix: a unit's samples added up and
spread across its bar in proportion. That can say a unit was 60% back
end and cannot say when it stopped checking and started emitting, which
is the question a compile actually raises. The answer was in the data
and nothing drew it.

So there is a chart of it. One row per thread of one rustc, each band a
run of consecutive samples, drawn where it happened. The first row of a
unit is the serial compile; the rows under it are its codegen units, one
thread each, running while the serial row does rather than after it. On
a dev build of this repository the shape is immediate: `parquet` checks
for seven seconds and then one `opt cgu.00` runs alone from 7.5s to
past 12s, which is the tail the whole build waits on and was previously
one orange band's worth of "codegen".

Units are measured from their own start rather than the build's, so two
can be compared by shape without hunting for them on the build clock.

The palette is re-cut for nineteen phases in four families: hue is the
segment, lightness is the position in it. The warm family is six long
because the back end is six phases; it used to be three, because the
back end used to be one phase with nothing inside it.

Three bounds, all of them stated on the page rather than left to look
like a build with no tail: sub-pixel runs merge into the phase that held
most of their samples, bands under four pixels lose the tooltip nobody
could land on them anyway, and the chart stops at 400 rows or 6,000
bands. On a 163-unit build that is 26 units and 425 KB of the page
rather than 1.3 MB.

The explorer gets the questions the new columns make askable: what the
back end was doing, which codegen unit everything waited for, and when
a crate stops checking and starts emitting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The schema, the capture manifest, the collection notes and the client's
own README all described one flat list of phases. They describe the
hierarchy now, including which of the two `link`s is a wall-clock span
and which is sampled CPU inside rustc, and that summing across `thread`
gives a number comparable to nothing.

There is also a note on what is still unattributed, with the query to
watch it. It is 4.1% of a Linux release build, concentrated on the
thin-LTO threads, and two things are known about it and neither chased:
stacks that resolve to LLVM frames and nothing else, which want an
unwinding fix rather than another marker, and rustc's parked process
main thread, which would be better named than left in `unknown`.

Anything published before the id fix has no phase data at all, whatever
its rows say. That is worth writing down where a reader of the census
will find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
claude and others added 14 commits August 27, 2026 12:14
Six findings, all mine, all real.

`sample.rs` was 1528 lines against a 1500-line limit, and the split it
wanted was already there: what a stack was doing is a different job from
running the profiler and reading its output. The marker tables,
`classify`, the parked-thread leaves and the thread naming move to
`phase.rs` -- 753 lines of "what was this", against 813 of "get the
data" -- and rustc's thread names sit with the symbol markers, because a
stack says what the code was doing and the name says which work item it
was doing it for.

The stack walk was nine levels deep: a chain of nodes, each naming a
frame, each frame reaching its symbol through three more tables, all of
it inline in the middle of a function about something else. It is a
`Stacks` type now, and the four nested `if let`s are four `?`s.

Shaping the payload was three nested closures deep for the same reason
and is two functions instead. The session fixture in core's tests built
a whole build observation as one literal, which put its innermost span
nine levels in; the threads are a helper now, which also makes the
invariant it exists to test -- the same samples as a total and as a span
-- something you can read in one line rather than count braces to find.

No behaviour change: 91 tests pass, and two release builds measured
before and after differ by 1.2 points of back-end share, which is the
same run-to-run variance two builds of the identical binary show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The hierarchy went in and the page stopped at two levels of it. `optimize`
is 42% of this build and the chart said so; what the optimiser was
actually doing was in `phases.parquet` and nowhere on the page.

So each phase that has anything finer to say gets a group, and each group
sums to its own phase rather than to the build -- `regalloc` is a tenth
of making machine code and a fortieth of the compile, and the first is
the number this chart exists for. Bars are the phase's own colour,
because a detail is not a separate thing from its phase; the label tells
them apart, and every bar carries one. That also keeps the palette at
nineteen rather than inventing forty more hues nobody validated.

`(no finer detail)` is drawn rather than dropped: 40% of machine code and
23% of the optimiser reached the phase and no further, and that is worth
seeing next to the parts that did resolve. A phase that never says
anything finer gets no group at all, since one bar at 100% labelled
"(no finer detail)" is a row that says nothing.

On a release build of a two-dependency workspace: type checking is 65%
typeck, 20% well-formedness, 6% coherence. Borrow checking is two thirds
the checker and a third the MIR pipeline it drives. The optimiser is 21%
memory, 20% loops, 15% instcombine. Machine code is 29% instruction
selection against 9% register allocation. Thin LTO is 70% reading other
modules' bitcode in.

The share label is anchored after its bar, in a class rather than an
attribute: `.tick` sets `text-anchor: middle` and CSS beats a
presentation attribute, so the attribute version printed every
percentage on top of the bar end it was measuring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Two failures, both from the last commit and both mine.

`chart.ts` reached 1,575 lines against a 1,500-line limit. I said in the
commit before it that the file was twenty-one lines from the limit and
that the next feature would have to make the call; the next feature was
the one after it, and it did not.

The boundary was there to be found: what Cargo reports about a build is
not what samply reports about the compiler that ran it. So `chart.ts`
keeps the build -- lanes, the dependency graph, concurrency, the verdict,
the units table -- `phases.ts` takes the three sampled-phase charts, and
`chartkit.ts` holds what both draw with, which is the part that is
genuinely shared: the phase order, the colour a phase gets, reading a
table by column name, the axis, a unit. 795, 493 and 356 lines.

The rendered page is byte-for-byte what it was before the split.

The other failure is a scratch screenshot script I left in
`infra/worker` and swept up with `git add -A`. The `workers` job asserts
the repository contains no `.js`, `.mjs` or `.cjs` at all -- everything
here is TypeScript and the built bundle is not committed -- so one stray
`.mjs` fails it, which is the check working. It belongs in a scratch
directory, and that is where it is now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Measuring somebody else's open source project is a thing the census wants
to be able to do, and the only way to say "these crates are public" was a
key in their manifest. So `--public`, or `CRATEBANK_PUBLIC=1`: the same
declaration, made by whoever runs the build.

It is the same decision either way, and the manifest stays the better
place for it when the manifest is yours -- it is reviewable, and it
persists, where a flag is a decision made once in a shell. Both meet in
one variable in `project`, so nothing downstream can tell them apart or
needs to.

The rule they feed is now a function with a name, `package_is_public`,
because a flag that widens a privacy boundary should have the boundary
written down where it can be tested. It is conjoined with "is this a
workspace member", so neither the key nor the flag can publish a path
dependency from outside the tree, a private git checkout, or a private
registry -- and that is what the test asserts, along with the fact that a
crates.io package is public because of where it came from and asks
nobody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
A phase mix is a statement about whatever the sampler saw, and until now
nothing said how much that was. On bun the answer is 2%: a 351-second
build whose last sampled unit finished at 8.6 seconds. Every number
drawn from that run describes the first nine seconds and reads as if it
described the build.

Two facts now travel with the observation. `sampled_span_s` is the time
from the first sampled process starting to the last sample taken, which
against `elapsed_s` is the coverage. `lost_events` is what samply
reports on stderr and nothing was reading -- perf events the kernel
dropped because the sampler could not keep up, which is the mechanism:
drop the records that say what a process is and its unit does not arrive
unattributed, it does not arrive at all. Both are columns on
`sessions.parquet`, so a reader can filter on coverage before believing
a phase number rather than recomputing it from unit start times.

Reading samply's stderr means draining a second pipe, so it gets its own
thread: whichever pipe fills first would otherwise stop the build.

And `--rate-hz`, because a long build is the case for sampling less
often. The default stays 4999 -- samply's fixed cost per invocation
dominates on anything worth measuring, and a low rate starves small
units -- but a build long and wide enough to overflow the perf buffer
has a lever now, and the two new columns are how you tell whether you
need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Two new columns want the sentence that tells a reader why they are
there: a run that covers 2% of its build has phase tables about 2% of
that build, and dropped events mean units are missing rather than
unattributed. Both belong next to the numbers they qualify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Measuring a hundred-crate workspace turned up a gap worth writing down
before it is forgotten: on that build every `machine_code` sample came
back with no detail, across three runs at two sampling rates, while the
phase itself resolved fine.

That asymmetry is the clue. `machine_code` is recognised by
`LLVMRustWriteOutputFile`, which is a rustc symbol in
`librustc_driver.so`; everything that would say which part of it --
instruction selection, register allocation, the assembler -- is an
`llvm::` symbol in `libLLVM.so`. A single-crate build of the same
workspace, same toolchain, same sampler, resolves 1.3 million libLLVM
frames and lands on exactly the leaves a debug build should have.

So the symbols exist, the sidecar carries them, and something about the
larger run loses that one library. Until that is understood, a missing
level three means "not resolved here" and never "the compiler did not do
that", and this says so where a reader of the census will find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The `lto` phase read 0.3 seconds on a build whose profile is
`lto = "fat"`, which is not a small number, it is a wrong one. Every
marker in that phase was a thin-LTO shape -- `PrepareThinLTOImport`,
`FunctionImporter`, the ThinLTO buffer shims -- and fat LTO does not go
through any of them. It merges every module into one and then optimises
that, so its merge step was reaching no marker at all and its optimise
step was landing in `optimize`.

Half of that was right. Optimising the merged module *is* optimising,
and it stays where it was. The merge was simply missing, and now has the
three names it happens under: `LLVMRustLinkerAdd`,
`rustc_codegen_llvm::back::lto` already covered `run_fat`, and
`llvm::Linker::linkModules`.

Measured on bun's release build, 652 seconds, 99 crates, one codegen
unit, fat LTO: `lto` goes from 0.3s to 17.1s, every sample of it in
`bun_runtime`, 16.3 of them the merge itself. The 426 seconds of
optimising after the merge stay in `optimize`, which is what they are.

The markers went in unvalidated -- a two-dependency test workspace with
fat LTO turned out to merge too little to sample even once at 999 Hz --
so this is the real build standing in as the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
On a hundred-crate workspace every `machine_code` sample came back with
no detail, at two sampling rates, on three runs -- while the phase
itself resolved fine. A single-crate build of the same workspace, same
toolchain, same sampler, resolved 1.3 million `libLLVM` frames.

The asymmetry was the whole clue. `machine_code` is recognised by
`LLVMRustWriteOutputFile`, which is a rustc symbol in
`librustc_driver.so`; everything that says *which part* of making
machine code is an `llvm::` symbol in `libLLVM.so`. So a fault that
reached only the second library would look exactly like this.

It did. The sidecar for that build has 48 entries and exactly one
repeated debug id: `libLLVM.so` twice, with 3,032 symbols and with 108.
`load` keyed them into a map, so the 108-symbol stub replaced the
compiler's symbol table, and nothing errored -- the phase markers went
on resolving from the library that arrived once. Small builds were
unaffected because their sidecars carry one copy, which is why every
test and every probe passed.

Copies are merged now rather than replaced, deduplicated and sorted
because `resolve` binary-searches them. On the same 91,414 samples that
turns 94% unlabelled into 47% assembler, 15% instruction selection, 3%
register allocation; across the whole build, samples carrying a level
three go from 31.7% to 52.5%.

The test builds a sidecar with one library in it twice and asserts both
halves survive, including an address that lands between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
`unattributed` was 5.1% of bun's workspace build, and a bucket that big is
not a rounding error -- it is a set of things nobody had looked at. Looking
at them, they were four kinds of work, each nameable:

  * the incremental cache being written -- more than half of it, on a build
    that is not even incremental;
  * the compiler process rather than the crate: the dynamic loader, the
    thread and allocator bring-up rustc pays once per unit, the arenas it
    drops afterwards;
  * questions any step can ask and that answer themselves recursively --
    trait solving, const evaluation, layout -- reached so deep through the
    query system that no caller survives on the stack;
  * proc macros, which run on their own stack with no rustc frame on it.

So there is a fourth segment, `process`, for the compiler's own time, and
five new phases. 5.1% -> 0.09%, and what is left is stacks of one frame the
unwinder could not get past, which is what the bucket is for.

The back end also says which part of itself now: `ir_build` was one
undivided bucket and is `body`, `layout` and `debug_info`; `machine_code`
gained `target_init`, `frame` and `debug_info` and its bare share went 8.8%
-> 3.6%; and the time the back end spends destroying the LLVM module after
the object file exists is `free_module`, which was 1.5% of one build.

Two things worth keeping from doing it:

`sample.rs` grows a probe -- ignored by default, pointed at a saved profile
-- that prints the whole breakdown and then the stacks behind one bucket.
Marker tables are only ever extended by looking at what no marker claimed,
and that looking should not be a script written from scratch each time.

And a test that no marker matches a frame a thread is rooted at. Four of
them briefly did, which put 28% of a build in `startup`: the outermost rule
claims every sample on a thread whose entry frame a marker names. The
mistake is invisible in the number a marker change is usually judged by,
because the unattributed share went down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The phase tables answer "what did the compiler do" and cannot answer "did it
use the machine while doing it". Those come apart: bun's release build is
722 CPU-seconds spread over 519 seconds of wall clock on four cores -- 35%
-- and its last 430 seconds are one thread with three cores idle. Nothing
in a phase breakdown says that. Every phase in it looks healthy.

So `sessions.parquet` gains three columns, swept out of the spans that were
already there: `sampled_cpu_s`, `peak_threads`, and `serial_tail_s`, the
trailing seconds during which never more than one thread was working. That
last one is the number that says a build cannot be made faster by adding
cores, and for a fat-LTO one-codegen-unit release profile it is most of the
build.

The CPU figure has an independent check: the machine's own `cpu_busy_mean`,
read from /proc and never near this code, says 38% where the spans say 35%.

The probe prints all three now, so a saved profile can be asked the same
question without a payload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The paragraph at the top of a build page answers "did this use the machine"
with an average -- N cores, X% unused -- which a build can pass while
ending with one thread and three idle cores for most of its wall clock.
`serial_tail_s` is the sharper answer, so it goes in the same paragraph
when it is a real share of the build.

Also notes, in both places the vocabulary is written down, that the
`process` segment and the `process` thread are different columns saying
different things: one is which thread a sample was on, the other is what
kind of work it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Twenty-six phases in five families only works if the ramp inside a family
is ordered, and two of them were not: `mir_build` and `borrowck` sat at
0.471 and 0.464 relative luminance, which is two names for one colour and
they are adjacent in the legend. The five blues sat inside 0.03 of each
other and separated only by hue.

Both families are respaced and the spacing is written down next to them,
because it is the kind of thing that drifts back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
…-gwe6cp

Take the back end apart, and say how much of the build was watched
claude and others added 3 commits August 27, 2026 17:57
On bun's release build 0.75% of every sample was unattributed, and nearly
all of it was one thing: loop strength reduction and the scalar evolution
analysis it drives. LLVM enters that pass through a free function, and the
unwinder does not get past it -- so the outermost frame on the stack is
`ReduceLoopStrength` and nothing above it says the optimiser is running.

The dev build did not show this, because at `-O0` the pass never runs. The
release build is where the census's time actually is, and this was the last
bucket in it with nothing in it but a shrug.

Release goes 0.75% -> 0.06% unattributed, and the samples land in
`optimize / loop`, taking it from 7.0% of the build to 8.1%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The third level was thin where it mattered. A release build of bun had a
quarter of its samples sitting at a phase with no detail under it -- and
`optimize`, which is 45% of that build, was the worst of them at 10.7% bare.
Sweeping every phase at once rather than one at a time, what was in there
was not noise. It was passes with names.

  * `optimize` gains `sccp`, `globalopt`, `callgraph`, `cse`, `dce` and
    `lower`: 10.7% -> 0.7%.
  * `machine_code` gains `analysis`, `prepare` and `emit`, and its existing
    `regalloc`, `frame` and `machine_opt` gain the passes they had always
    meant: 5.0% -> 1.1%.
  * `macro_expand` splits into `expand`, `reparse` and `proc_macro` -- the
    last being the macro's own code, which is the number to look at before
    blaming a build's macros in general.
  * `mir_build` splits into `thir`, `build`, `match_check` and `unsafety`;
    `metadata_encode` is mostly `mir_transform`, because encoding forces
    `optimized_mir` for everything a dependent might inline; `codegen`'s
    leftovers were `bitcode` and, once `codegen` was allowed to carry them,
    the same `debug_info` and `layout` its neighbours already had.
  * `startup` splits into `dyld`, `ctxt`, `thread` and `llvm_init`, and
    `resolve`, `analysis`, `lint` and `ast_lowering` get theirs.

A quarter of the build -> 3.6%, and what remains is the right leftover: the
pass manager dispatching between passes, and the allocator. Neither gets a
marker, deliberately -- matching `AnalysisManager` once charged a quarter of
the optimiser to the dispatcher instead of to the pass that asked.

Two of these were worth measuring rather than assuming. `emit` -- the object
file reaching the disk, 2.3% of a debug build in `write` alone -- is matched
innermost and could have taken samples from `asm`; it took none, because
every one of those writes already had no pass above it. And `codegen`
carrying `debug_info` moved 4.3% of a debug build out of the leftover bucket
without touching what `ir_build` and `machine_code` already reported.

The probe grows a `*` mode for the question this pass kept asking: what has
no detail, everywhere, in one walk of the profile instead of one per phase.
`phase.rs` becomes a directory because the detail table is now the biggest
of the three and was pushing the file past its limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The viewer had grown into a page you searched rather than read. Every
change here is one thing that was being said twice, said wrongly, or not
said at all.

Said wrongly. A unit nothing was sampled from wore a flat amber that sat
99 units of perceptual distance from `codegen` -- closer than `codegen`
and `metadata_encode`, which are deliberately adjacent, at 143. On bun's
release build, which is 78% codegen, the unmeasured units looked like the
most measured thing on the page. They are hatched now. The lane chart's
key said "the same colours as below", which asks a reader to scroll to a
different chart and match a swatch from memory in order to answer "what
is all this amber"; it carries its own key, ordered by how much of the
build each phase took. Its bands carry their own tooltips, because the
unit's title answered "which unit is this" and never "what is the colour
under my cursor".

Said twice. The session's forty-odd fields sat above the charts and again
at the bottom; the dozen anyone wants are in the glance, so the table
goes to the bottom with every other one, and the build's own config joins
the glance rather than being a second table underneath saying the same
kind of thing. "What is in it" and the flags column went the same way.
"One observation" was true of every page on the site and so told a reader
nothing about the one they were on -- it now reads `bun · release`, and
says how many units, how long, and on what.

Not said at all. `target` read `lib` on 170 units we had never checked:
Cargo's timings report does not carry target kinds, only a display suffix
that is empty unless the target's name differs from the package's. The
rustc command line does carry them, and did all along -- eleven proc
macros had been drawn as plain libraries. Where nothing recorded it the
box is dashed, because "almost certainly a library" is not the claim "a
library". `0.0.0` on 115 of bun's units was Cargo's placeholder for a
crate that is not published; the profiler records nothing for those, and
nothing is the truer answer, so they read `local` and are the ones not
linked to crates.io -- linking them sends a reader to someone else's
crate of the same name.

Columns are one unit each now: seconds to two decimals throughout, which
is the precision Cargo's timings actually carry, and megabytes
throughout, because a column reading 4.2 GB above 999 MB is a column
where the largest number looks like the smallest. Features showed four
pills, which sized the column for the four rows in 223 that have five or
more; two shows every feature of 94% of units at half the width. The
phase mix is last, fills its cell, sorts by the time it draws, and has a
switch between proportions within a unit and widths across the build.

Two bugs found by measuring rather than looking: sub-megabyte outputs
were emitting a bare `<`, which is not markup and 87 of 201 outputs hit
it; and an assertion that read `<aside class="rail">[\s\S]*raw-sessions`
was greedy across the whole document, so it kept passing after sessions
left the rail while testing nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
claude and others added 3 commits August 27, 2026 18:44
The phases nothing had ever been said about, said: `parse` splits into
`path` and `tokens`, `trait_solve` into `select` and `normalize`,
`const_eval` into `interpret` and the solving it drags in, `resolve` gains
`late`, `ir_build` gains `declare` -- the symbol names, ABIs and attributes
a codegen unit needs before any of it is filled in.

Two cross-cutting names, because two costs are cross-cutting. `dep_graph`
is the incremental dependency graph being written *as a phase runs* rather
than at the end: a third of everything type checking did that type checking
itself could not explain. `hash` is the stable hashing the same system
needs. Neither belongs to one phase and both were invisible inside all of
them.

And `metadata_encode` finally gets `encode`, which is worth having only for
the contrast with the `mir_transform` beside it: writing a crate's metadata
spends more time running MIR passes than writing anything.

2.8% of a release build now has no detail, from a quarter before this and
the commit before it.

One name was measured and taken back out. `emit` -- the write syscall --
reads well in `machine_code`, where it took nothing from `asm` because
those writes had no pass above them. Inside `link` the same marker took
11,860 samples off `archive` and left it 732: there the writes *are* the
archive being built, and `archive` is the better name for them. A leaf
marker is only safe where the leaf is all there is, and that is now written
next to it.

What is left unnamed is the pass manager dispatching between passes, the
allocator, and `memcpy`. None of them will get a marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Segment, phase and detail are three grains of one question -- why was the
compiler here -- and they nest. This is a different question that does not
nest with them, so it gets a column rather than a fourth level: what was
running.

It is worth a column because the answer is "not a compiler pass" 24% of the
time on a release build and 45% on a debug one, and none of that was
visible. It is spread across two dozen phases in slices too thin to notice,
and the phase it is spread across is never wrong: `optimize` really was
optimising, inside a `DenseMap`.

The two builds have completely different shapes, which is the finding:

  concern        release   debug
  pass_manager      6.5%    0.8%
  alloc             5.0%    3.9%
  hashmap           4.8%    5.9%
  memcpy            2.8%    7.0%
  intern            2.1%    5.7%
  query             1.3%    4.5%
  io                0.8%   11.6%
  hash              0.2%    3.5%
  total            24.2%   45.0%

A release build's largest single concern is LLVM choosing what to run next.
A debug build's is the machine waiting on its disk -- 11.6% of every sample,
which no compiler flag addresses.

# The rule, and why it is the weak one

A concern is the leaf frame or nothing. Not the innermost match on the
stack; the leaf.

The stronger rule -- a named concern claiming everything beneath it, so a
`memcpy` inside a hasher counts as hashing -- was measured and rejected.
Across ten concerns it changed nothing worth having: every one moved by less
than a percentage point except `dep_graph`, which moved seventeen-fold. That
one number is the entire case for it, and it is the one to trust least,
because claiming a subtree from an enclosing frame is how `pass_manager`
first measured 57% of a build. The pass manager is on the stack of every
sample the optimiser takes, so "matched anywhere" meant "matched always".

Leaf-anchored has no such failure mode and no parameter to tune, and a
test pins the case: a sample inside `InstCombine` is `instcombine` with no
concern, even with the pass manager one frame up.

# What moved, and what did not

`dep_graph` and `hash` were details until now, which was the wrong column:
they are machinery, not what the phase set out to do, and as details they
competed with the phase's real details for one slot. They move here.

That is the only thing that changed in the phase tables, and it is
accounted for exactly: three `hash` detail rows, 852 samples, redistributed
into `lower`, `typeck` and `encode` in the same phases they were already
in. 852 out, 852 in, nothing crossed a phase boundary. Nothing else moved a
sample, which is the property the axis has to have -- summing over `concern`
gives the phases back.

And `dep_graph` turns out to be almost nothing: 0.3% of a debug build and
none at all of a release one. It is the concern that prompted this and the
smallest thing in it. What it cannot answer -- what incrementality costs --
wants a build run twice and the elapsed times compared, which is a thing
this census is shaped to do and no attribution of one run can replace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
…-gwe6cp

Label everything the stacks can name, and add the axis they could not
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants