clay-borg/research/CB-RES-0006-render-port.md
tegwick 84d688688d CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.

  games-ground shipped:  23 third-party crates
  cb-render-html:        23 third-party crates
  new crates introduced:  0

Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.

What shipped:
  crates/cb-render-html  doc.rs (HTML/SVG emission, incl. the relationship
                         graph), input.rs (pointer facts -> commands),
                         serve.rs (Guard, Request, loopback bind)
  tools/cb-play          hotseat.rs + `--serve PORT`

Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.

The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):

  1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
  4   a token-less request is refused, in the unit AND over a real socket
  5   JS may not construct commands — the page reports pointer facts, Rust
      resolves them against the legal list the aggregate already offered,
      and a test asserts the emitted script contains no game vocabulary
  6   the coverage gate crosses the language boundary: it walks the
      serialized view for leaf paths and requires each token to appear in
      the PARSED emitted document, with a test that the parse really is a
      parse (script/style contents must not count as rendered)

The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.

Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00

320 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CB-RES-0006 — the render port, and what a 2D table costs
capability: render.table.2d
status: approved (v2, after adversarial review — v1 was **not approvable**,
with four conceded findings; see §2, §4, §5, §7)
tier: L (structural L — the declaration was made when this pass would
create a capability port; chaos d4=1 → no override. Review then removed
the port, and the tier was deliberately **not** re-derived: a tier that
changes because review shrank the work is a function of the outcome)
CB-WP-0012 T01. Step 1 of the inner loop. Measured 2026-08-02 at
`331e7e9`.
The question: INTENT stage 1 needs `cb-render-api` and a first real
implementation. AM-4a reports **3,750 lines of headroom** (246,250 of a
250,000 target). Is that a real obstacle, or an artifact of the
instrument?
Both, and not in the proportions I expected.
---
## 1. Method
Every figure below comes from the same method `tools/dep-weight.py` uses
for AM-4a: resolve a real dependency graph with
`cargo tree --edges normal --prefix none`, drop path dependencies, and
count `\n` in every `.rs` file of the vendored source. Reusing the method
verbatim is the point — a survey that measures candidates one way and the
budget another produces two numbers that cannot be subtracted.
Probe crates were built in a scratch directory, one per candidate, and
resolved against the live registry. The scripts are not committed, because
a throwaway probe promoted to a tool is how the second-use rule gets broken
by accident.
**Positive control.** `source_lines()` returns 0 for a crate it cannot
locate, so a registry path change would report every candidate as costing
nothing — the direction that flatters the recommendation. Every figure
below was produced under two guards copied from `dep-weight.py`: an
unlocatable crate must measure zero, a real one must measure non-zero, and
any candidate containing an unlocated crate is reported rather than summed.
No candidate contained one. The review (T02) found that the second batch of
measurements had copied the measurement function *without* the guards; they
were re-run under them before being cited here, and reproduced unchanged.
**Marginal, not total.** A candidate's headline size overstates its cost
by whatever it already shares with the base graph. What AM-4a charges is
the delta.
## 2. What a 2D table costs
Base: `games-ground --no-default-features`, 23 third-party crates,
246,250 lines.
| candidate | total | shared with base | **marginal** | vs 3,750 headroom |
|---|---:|---:|---:|---:|
| `egui` + `eframe` | 2,946,121 | 163,272 | **2,782,849** | 742× |
| `wgpu` + `winit` | 1,866,820 | 124,841 | **1,741,979** | 465× |
| `softbuffer` + `tiny-skia` + `winit` | 1,271,922 | 124,841 | **1,147,081** | 306× |
| `ratatui` + `crossterm` | 1,146,363 | 79,350 | **1,067,013** | 285× |
| `egui` alone (no windowing) | 778,067 | 110,542 | **667,525** | 178× |
| `macroquad` | 480,729 | 228 | **480,501** | 128× |
| HTML/SVG emission, no Rust toolkit | — | — | **0 by construction** | — |
**Revised after review (T02).** The first draft of this section concluded
from the table above that no windowed toolkit could fit, and called it
"two orders of magnitude, not a near miss." **That was drawn from a sample
containing no small windowed toolkits.** Every candidate above is a
GPU-era stack, because that is what one reaches for; cheap bindings to
system libraries were structurally absent. Measured, same method, same
base, under the positive control:
| candidate | marginal | vs 92,798 corrected headroom (§3) |
|---|---:|---:|
| `tiny-skia` alone (rasterizer, no window) | **83,956** | **fits**, at 90% |
| `termion` | 133,130 | 1.4× |
| `fltk` | **140,079** | **1.5×** |
| `sdl2` | 191,973 | 2.1× |
| `sdl3` | 276,445 | 3.0× |
| `raqote` | 286,852 | 3.1× |
| `minifb` | 774,389 | 8.3× |
| `crossterm` alone | 802,677 | 8.6× |
| `softbuffer` alone | 909,416 | 9.8× |
| `pixels` | 863,528 | 9.3× |
| `speedy2d` | 1,328,008 | 14.3× |
The real floor for a windowed toolkit is `fltk` at **1.5×** the corrected
headroom, and `tiny-skia` fits outright. **The "two orders of magnitude"
claim is withdrawn**, and with it the survey's original argument:
dependency cost alone does not decide this. What replaces it is §4.
**The `0` in the first table is by construction, not by measurement**
there is no crate to fail to locate. It is relabelled because a zero
meaning "costs nothing" and a zero meaning "measured nothing" print
identically, and every other row is a measurement.
**A surprise worth recording:** `ratatui` — a *terminal* library, the
option one reaches for expecting it to be the cheap one — costs more than
`macroquad`, a 2D game framework with a GPU backend. The reason is
`linux-raw-sys` at 479,901 lines and `libc` at 129,990, pulled through
`rustix`. Neither is code anyone audits by hand; both are largely
generated constant and type definitions. Which raises the next question.
## 3. The instrument is wrong, and has been all along
AM-4a counts the `--edges normal` graph. That graph includes **proc-macro
crates**, which run in the compiler and never reach a shipped binary.
| `games-ground --no-default-features` | crates | lines |
|---|---:|---:|
| as AM-4a measures it | 23 | **246,250** |
| excluding proc-macro crates (`--edges normal,no-proc-macro`) | 18 | **157,202** |
| the difference | 5 | **89,048** (36.2%) |
The five are `syn`, `quote`, `proc-macro2`, `unicode-ident` and
`serde_derive`. `syn` alone is 66,916 lines — the single largest entry in
the shipped-runtime figure, and it is a parser for Rust source that exists
only at build time.
**So 36.2% of what AM-4a calls "what a game ships" is not shipped.** Under
a corrected metric the headroom is not 3,750 but **92,798**. Every pass
that has cited the 3,750 figure — including CB-WP-0011's own reasoning for
deferring this declaration — cited a number that was wrong in the
conservative direction.
**This does not change the recommendation, and that is the point of
checking it before recommending.** Under the corrected metric `macroquad`
still costs 5.2× the available headroom, and every other windowed
candidate is worse. The dependency argument survives the correction of the
number it rests on, which is the only condition under which a survey may
propose correcting a number in its own favour.
The correction is proposed as a separate, narrow change — the metric
should measure what ships — and it must not be bundled with the render
decision, because a budget correction that arrives attached to the request
it unblocks is indistinguishable from motivated reasoning even when it is
right.
## 4. What stage 1 actually asks for
> *card/token/hand/relationship-graph visualization, drag-to-propose,
> debug inspector, hot-seat play*
Read plainly: draw a table, let a human point at it, and let several
humans take turns at one machine. It does **not** ask for a GPU, a frame
loop, physics, or 60fps. Those are stage 2 (*"Physical 3D tabletop —
wgpu renderer, Rapier-backed physics"*), and stage 2 is where the 1.7M
lines of `wgpu` + `winit` get argued for on their merits.
### The allocation argument, which replaces the affordability one
`fltk` at 140,079 is affordable if AM-4a's target moves a little. The
reason not to buy it is not cost but **allocation**:
| | marginal lines |
|---|---:|
| corrected headroom (§3) | 92,798 |
| cheapest windowed stage-1 toolkit (`fltk`) | 140,079 |
| **`wgpu` + `winit`, which INTENT stage 2 names by name** | **1,741,979** |
Stage 2's renderer is measured, unavoidable, named in INTENT, and **twelve
times** the cost of the stage-1 toolkit it would replace. Spending 1.5× the
entire remaining budget on a windowing stack that stage 2 discards is the
wrong allocation regardless of whether the budget can be stretched to
cover it. That argument does not depend on AM-4a's target being exactly
250,000 — only on stage 2's bill exceeding stage 1's benefit, which is
measured and true by a factor of twelve.
### AM-4a cannot survive stage 2, and that is not this pass's to decide
Following the above honestly produces a finding this survey did not set out
to look for. Stage 2 exceeds AM-4a's **entire target** by 7× and the
remaining headroom by 19×. No sequencing, feature-gating, or metric
correction closes a gap that size: **AM-4a as targeted is incompatible with
INTENT as written**, and has been since both were written.
Either the target moves, or the render port stays outside the AM-4a
configuration by an argued rule, or stage 2 changes. That is a maintainer's
decision. It is raised here and **deliberately not resolved** — a pass that
discovers a budget conflict and also settles it has reviewed nothing.
## 5. The option that is not a Rust toolkit
Emit HTML with inline SVG and inline JavaScript; open it in the browser
the machine already has.
- **visualization** — SVG draws cards, tokens and a relationship graph
natively, and the relationship graph is the one element every Rust 2D
toolkit would have made us hand-roll anyway.
- **drag-to-propose** — pointer events in the emitted page.
- **debug inspector** — already shipped (CB-WP-0011), and its renderer is
already a total function from a projection to text; HTML is a second
output format for the same walk.
- **hot-seat play** — one browser tab, seats taking turns, the same
commit/reveal the CLI player uses.
### The rule that makes "zero" honest (added by review)
Scoring the browser at zero is true under AM-4a and suspect in substance: a
browser is tens of millions of lines of unaudited third-party code. If a
proposal can satisfy a dependency target by relocating the dependency
somewhere the target does not look, the target constrains nothing.
The same critique convicts `sdl2` and `fltk` above — thin Rust bindings
whose `.rs` counts exclude the C library that does the work. The metric
already permits the move; this survey simply never measured a binding
before, so it never noticed.
One rule, applied to all three, belonging in AM-4's definition rather than
in this pass's reasoning:
> **AM-4 counts third-party code the project causes to be acquired.** It
> does not count runtimes the user already has independently of us — the
> kernel, the system C library, the shell, the terminal, a web browser. It
> **does** count a library our build or install instructions cause to be
> fetched, pinned, or linked, whether or not its source is Rust.
Under it: a browser is not counted; **`sdl2` and `fltk` are counted at more
than their Rust binding, not less**, because they oblige a user to install
a `-dev` package they would not otherwise have. The rule makes the metric
*harder* to satisfy by relocation, which is the direction a rule proposed
by the party it benefits has to run before it is worth anything.
**Marginal AM-4a cost: zero.** Not "small" — zero. Emitting HTML is string
formatting; the 2,897-line `svg` crate measured above is not needed and is
not proposed. The input path costs no dependency either: a loopback
HTTP/1.1 listener sufficient for `GET /` and `POST /command` is
`std::net::TcpListener` plus roughly 250 lines of our own code, and our
own code is not what AM-4a governs.
This is the central rule applied literally — *own the semantics; assimilate
the implementation*. The browser is the most thoroughly assimilated 2D
renderer available, and it is the one implementation we are guaranteed not
to have to vendor, audit, or keep on a target.
**Own-code cost is a real cost.** ~250 lines of hand-rolled HTTP is code
we own, test and carry, and hand-rolled HTTP has a bad security history.
It listens on loopback only and speaks to a page it emitted itself, which
bounds the exposure but does not erase it. That trade — 250 own lines
against 480,501 third-party ones — is the decision ADR-0007 has to make
explicitly rather than inherit from this survey.
## 6. What this cannot do
Stated concretely, because the adversarial review (T02) should not have to
discover it:
- **No frame loop.** Animation, drag *feedback* at pointer-event rates,
and anything requiring sustained redraw are the browser's problem, not
the port's — the Rust side emits state and receives commands. If stage 1
later wants tweened card motion, it is written in the emitted JS, in a
language this repo otherwise does not use and has no gates for.
- **No offline binary.** A game that "ships" ships a process that wants a
browser. For a headless-first rules engine that is nearly free; for a
consumer product at stage 4 it is not.
- **A second language enters the build.** JavaScript in emitted strings is
untested by `cargo test` and invisible to every gate this repo has.
This is the strongest argument against, and the one T02 should press.
- **It is not stage 2's renderer.** Nothing here carries forward to wgpu
except the port interface — which is the argument *for* doing it, under
the second-use rule, but only if the interface is written to be
implemented twice rather than written around HTML.
## 7. Recommendation (revised after review)
The first draft recommended declaring `cb-render-api` and `cb-render-null`
now, while also saying the interface becomes canonical only at stage 2's
second use. **Those two cannot both hold**, and INTENT's rule is the one
that survives: *"No concept becomes canonical merely because it looks
general. It becomes canonical after surviving a second concrete use."* A
port written against exactly one implementation that emits whole documents
will acquire a document's shape — retained mode, full state per update,
string identity, no frame timing — and stage 2 will find it
unimplementable. `CommitWindow` is already in this repo with a delete-by
date for precisely this mistake.
1. **No port this pass.** `cb-render-api` and `cb-render-null` are
withdrawn. A null implementation of an interface that does not exist is
a null implementation of nothing.
2. **`cb-render-html`** ships against the **existing `Project` trait** — a
real interface with real implementations, not a speculative one.
Marginal AM-4a cost zero, honestly zero under §5's rule.
3. **The port is declared at stage 2**, derived from two implementations
instead of imagined from one.
4. **AM-4a's proc-macro defect is filed separately**, and is not a
precondition for any of the above.
5. **AM-4a's incompatibility with stage 2 (§4) goes to the maintainer.**
### Six controls the implementation must carry
From the review, and binding on ADR-0007:
| # | control |
|---|---|
| 1 | an unguessable token minted per process, required on every request |
| 2 | `Origin` / `Sec-Fetch-Site` checked, rejected by default |
| 3 | listener bound explicitly to `127.0.0.1` |
| 4 | **a test that a token-less request is refused**, with an M-D1-MUT mutation removing the check and turning it red |
| 5 | **JavaScript may not construct commands** — the page reports raw pointer facts, Rust decides what command they mean |
| 6 | **the coverage gate crosses the language boundary** — the HTML counterpart of `every_view_field_is_classified` asserts over the *parsed emitted document*, not over the Rust that emits it |
Controls 13 without 4 are three claims and no evidence. Control 6 exists
because asserting over the emitting code would reproduce CB-WP-0011's
original defect one layer up.
### The honest summary
Two of this survey's three arguments did not survive review. The budget
constraint that appeared to force the decision was overstated by 36% by a
mis-instrumented metric, and *then* overstated again by a candidate list
that contained no cheap windowed toolkits — the real floor is 1.5× the
corrected headroom, not 128×. The port the survey proposed to build
contradicted the rule the same survey cited.
What survives is narrower and better founded: stage 1 should not spend
stage 2's money on a toolkit stage 2 discards, and it should not canonize
an interface it can only implement once. The recommendation is unchanged.
The reasoning that reached it has been replaced, which is what the review
step is for.