Roadmap #
The design record: what the project is trying to prove, how, where it stands, and the decisions behind it. Detail lives in linked docs; history lives in git.
Summary #
Premise. Generate a web rendering engine from the specifications, using three real browsers (Chromium, Firefox, WebKit) as the test oracle. A model writes each spec algorithm as a small Rust function; the browsers decide which attempts are right. If that works cheaply, anyone can have an engine, and keeping it current is a rerun rather than a rewrite.
flowchart LR
p0["Phase 0<br/>feasibility probe"] --> p1["Phase 1<br/>engine bootstrap"]
p1 --> p2["Phase 2<br/>generation pipeline"]
p2 --> p3["Phase 3<br/>breadth"]
p3 --> p4["Phase 4<br/>configurable builds"]
ox["oracle expansion<br/>(in parallel)"] -.-> p3
classDef done fill:#cfc,stroke:#393
classDef now fill:#ffc,stroke:#993
class p0,p1 done
class p2,ox now
Where it stands.
- The three-browser verification harness works and is measured
(
docs/geometry-verification.md). - DOM, style (via Stylo), box tree and fragment tree are hand-written and pass their oracles.
- Thirteen layout rules (CSS 2.1 and CSS Transforms 1) sit behind small generated
functions; eleven were written by models and admitted against the browser vote,
block height keeps its hand-written rule, and fixed table layout still runs its
deliberately wrong baseline, with no batch drawn
(
docs/generated-functions.md). Together they pass the whole 227-case recipe corpus. - Every committed generated function carries a provenance record; the maintenance lifecycle that uses those records is designed but not yet run end to end.
- One non-geometry feature (HTML
[Reflect]attributes plus DOM mutation) was generated against a JavaScript-state oracle (docs/feature-cost-experiment.md). - A software rasterizer draws the fragment tree to a PNG ("Visual proof").
The honest claim today. Generated leaf functions inside a hand-built engine. The generation step is cheap; building the scaffolding for each generated function (its types, corpus, oracle and prompt) is the expensive hand work, and that cost — not tokens per candidate — decides the thesis.
Next. The substrate rework below (embedding, configurable builds, realtime
performance), written by hand; feature work run end to end by the job loop
(docs/autonomous-loop.md) with no hand intervention; then new layout surface: a first
batch for fixed-layout tables, text on the text-metrics oracle, then flexbox and grid.
Read next. docs/generation-loop.md for how a rule is
generated, docs/geometry-verification.md for how it is
checked, then "Proof of concept", "Decisions" and "Open questions" below.
Proof of concept #
The thesis is proven when four claims each have a measured answer.
| # | claim | result |
|---|---|---|
| 1 | Generated functions compose into one engine | 227/227 on the recipe corpus, worst per-box error 0.0000 px |
| 2 | The pass rate survives an exam the project did not write | WPT 7/7; grammar corpus 61/164 (36 discarded) |
| 3 | A model solves a layout rule nobody solved first, at a metered cost | absolute 82/108, floats 109/109, relative positioning 104/104, stacking 105/105, overflow 105/105 — no hand candidate first |
| 4 | Admitted code is the engine, not a scratch file | every winner committed under crates/layout/src/generated/ with its provenance |
-
Composition.
layout()runs every admitted generated function together. On the hand-written fixtures it scored 196/227; with the model winners, 216/227; the last eleven losses were twelve-deep percentage nesting, closed by quantizing every used value onto the browsers' 1/64 px grid, truncating toward zero rather than rounding (tasks/device-pixel-rounding.md). Rounding to nearest scored only 216/227. No loss was ever caused by two generated functions disagreeing. -
Held-out exam. Two corpora the recipes did not produce: WPT
css/CSS2/normal-flowpages with no text (7 of 1056 qualify) and a grammar-generated corpus (crates/harness/src/grammar.rs, 200 cases, seed0xC0FFEE). How the scores moved as fixes landed:after WPT grammar baseline functions ( 45c3932)0/11 9/68, 132 discarded model-written functions assembled ( 9e12a5c)0/11 34/68 default body margin ( a95ef6d)5/11 42/68 styleattribute + floats (486eb73)11/11 42/68 per-node scrollbar discard rule ( a7bd8c4)11/11 37/164, 36 discarded overflow function, first candidate 11/11 42/164 overflow function, 105/105 candidate 11/11 61/164 no-break-space pages excluded as text (2026-09-29) 7/7 61/164 Four of the eleven pages held only
 , which the page filter (oracle/suites/normal-flow-pages.mjs) had stripped as whitespace. Two of them failed once scrollbars were scored: their no-break-space line overflows a 16 px box in every browser, and this engine lays out no text.The discard rule change moved 96 cases from "discarded" to "scored". The gap between 227/227 and 61/164 is the measure of what the recipes still miss.
-
Model-first. From absolute positioning on, the function's types, corpus and prompt were built by hand with tokens metered, and the model drew batches before any hand-written candidate existed. Costs and batch-by-batch results:
docs/model-first-cost.md→ "Batch scores and token totals". -
Committed winners. A winner once lived only in a gitignored directory and was lost with its worktree; now
real_model_runand its siblings write it into the source tree ("Decisions").
Outside the proof and deferred until it is in: a bigger model on the rig, the rig-fleet plan, the non-geometry cost experiment (dialog and popover), and automated repair iteration beyond best-of-N with a prompt edit between batches.
Which resolver each generated function runs #
The real path is crates/layout/src/fragment.rs layout() — what fragment_check,
held_out_check and paint call — plus paint_order() and scrollbars() in
crates/layout/src/lib.rs. layout() passes every crate::generated resolver to
layout_with_resolvers() with no flag or configuration. layout_with_resolver() and
layout_with_resolvers() exist so the generation loop can score one candidate with the
others held fixed; nothing on the real path calls them.
| function | called from, on the real path | spec |
|---|---|---|
| width | layout_box(), every in-flow block (a float's width is shrink-to-fit, CSS 2.1 10.3.5, not the generated function) |
Phase 1 step 4 |
| margin | stack_children() |
Phase 1 step 4 |
| percentage | layout_box(), relative_shift(), layout_absolute_box() |
Phase 1 step 4 |
| absolute | layout_absolute_box(), twice |
tasks/absolute-batches.md |
| float | stack_children() |
tasks/floats-bfc.md |
| relpos | relative_shift() |
tasks/relative-positioning.md |
| height | layout_box(), layout_absolute_box() (hand-written incumbent) |
tasks/block-height-equation.md |
| overflow | scrollbars(), which fragment_check and held_out_check score |
tasks/overflow-scrollbars.md |
| containing block | layout_box() at the tree root, once per out-of-flow box |
tasks/fixed-positioning.md |
| transform | box_transform(), once per box whose transform is not none |
tasks/transforms2d.md |
| inline atomic | layout_inline_children(), once per container whose in-flow children are all inline-level |
tasks/inline-atomic.md |
| stacking | paint_order(), which paint draws in |
tasks/stacking-order.md |
Each generated function was checked to be live by laying out one small document with and without it swapped to its baseline; output differs for every one except height, whose baseline is the same correct rule.
Visual proof #
Done: crates/layout/src/bin/paint.rs walks the fragment tree and writes a PNG per case
under target/visual-proof/ — flat rectangles for borders and backgrounds, no text, no
images, no window (tasks/visual-proof.md). Tests read the PNGs back: --case blank is
800×600 of canvas colour; --case box is a 200×100 border box at (0, 0), red inside a
10px blue border. Hand-written, not generated.
Phases #
Phase 0 — Feasibility probe #
Does spec prose convert to Rust that passes against real browsers? Scope: block boxes
with no text — sizes, margins, padding, borders, margin collapsing — on a DOM from
html5ever and style from Stylo, no JavaScript. Exit: a measured first-shot rate, a rate
after repair, and a failure taxonomy.
Status: done. The harness agreed on 699 of 705 browser pairs across 235 documents
(commit 3ca7323). The generation half ran as Phase 1 step 4, which holds the rates and
taxonomy.
Phase 1 — Engine bootstrap #
flowchart LR
s1["1. DOM + style<br/>checked vs computed style"] --> s2["2. box tree<br/>checked vs box existence"]
s2 --> s3["3. fragment tree<br/>fixed sizes only"]
s3 --> s4["4. generated rules<br/>one category at a time"]
Each step has its own oracle before the next starts. Steps 1–3 are hand-written; step 4 is generated.
-
DOM and style. An arena node tree with Stylo's traits adapted from Blitz. Checked against the browsers'
getComputedStylefor properties that do not need layout (display,position,box-sizing, border widths,overflow-*, …). Measured: 2958/2958 values on the enumerated set; 31841/31841 with 200 random cases added; 6613/6613 with the SMT-sampled set (style_check). -
Box tree. An element with no rectangles in the browser must generate no box. Measured: 223/223 nodes; 1900/1900 over 227 cases; 389/389 with the SMT set (
box_check). -
Fragment tree, explicit sizes. Gate: every
fixed-boxcase passes. Measured: 3/3, and 25/25 over 227 cases (94/227 overall at the time, the rest needing step 4). -
Generated algorithms, in order: width equation, margin collapsing, percentage resolution, then new generated functions model-first. Each lands when its category stops improving. Per-function results:
docs/generated-functions.md.Latest: the box generation function (
specs/display-contents.md), admitted by a feature job:qwen3.8-27b-fast-2'sc6_qwen3_8_27b_fast_2_p017f6528(seed 6), stored by thedisplay-contentsjob after 0 repair round(s) at train 48 of 48 and held-out 15 of 15, passes 48 of 48 on the score check's corpus. The job's review passed it and itsadmitstage put it into the engine.Before it: the fixed-table-layout function (CSS 2.1 17.5.2.1 and 17.5.3), the first whose structural half had to be built from nothing — tables did not exist in the engine at all, so it needed new
displayvalues, the anonymous rows and cells 17.2.1 generates, and a third formatting context infragment.rsbeside stacking and line boxes. Two exported halves, split the way 17.5.2.1 splits the algorithm: the columns are resolved from the table's own width and its first row's cells alone, before any cell's content is laid out, and the rows from the heights that content then produced. No batch has been drawn: the committed candidate is the function's own deliberately wrong baseline, the wayoverflowandinline_atomicboth started, because the build lane was unreachable for the whole session and both the browser oracle that fills the corpus and a model batch refuse to run anywhere else. Corpus of seventeen categories, runner, three binaries and two seeded-wrong fixtures all in place and compiling, none of them yet run (tasks/tables-fixed-layout.md). What this function did measure is the term this step calls unmeasured: about 44 minutes of hand-written scaffolding against a model-run total of zero (docs/model-first-cost.md→ "Fixed table layout").Before it: the inline-atomic function (CSS 2.1 9.4.2/10.8/10.8.1), which gives the engine line boxes for the one kind of inline content that needs no font — a block container whose in-flow children are all explicitly sized
display: inline-blockboxes. Until it existedboxtree.rsgave every non-nonedisplayone block-level box and aninline-blocksimply stacked in normal flow. Its answer is a set of positions rather than a single value, and it is the first to place more than one box per call. Committed candidatemodel_0fromqwen3.8-flash-nextat 111/111 on its own corpus, admitted on the first batch, with one candidate from each of the four gateway models reaching 111/111. Corpus of 111 cases over fifteen categories, none dropped for engine disagreement (tasks/inline-atomic.md).Before that: the transform function (CSS Transforms 1 §3/§4/§5), the first drawn from a spec other than CSS 2.1 and the first whose answer is a matrix. Until it existed
transformwas ignored outright and every box was reported where normal flow put it. Committed candidatemodel_22fromqwen3.8-27b-fastat 110/110 on its own corpus, admitted on the first batch, with twelve of the twenty candidates that compiled tied at 110/110 (tasks/transforms2d.md).Earlier still: the containing-block function (CSS 2.1 10.1), which is what
position: fixedneeded — a fixed box's containing block is the viewport, not the nearest positioned ancestor, and until this function existedfragment.rsdecided the question itself andfixedwas flattened tostatic. Committed candidatemodel_0fromqwen3.8-flash-nextat 106/106 on its own corpus, admitted on the first batch (tasks/fixed-positioning.md).Failure taxonomy over the first three generated functions: no missing prerequisites; a few spec misreadings each fixed by one prompt sentence; one substrate gap (unrounded arithmetic, since fixed); one gate gap (a percentage-only mistake cannot regress anything until a first candidate is admitted).
What step 4 shows about the plan.
- The generated part is the easy part, by construction. The hand-written walk decides which rule applies and with what inputs; the model writes a leaf decision. This is finer than the planned one-algorithm-per-section granularity ("Decisions").
- The model has not yet beaten a hand-written candidate where one existed first, except width, by adding min/max clamping. Block height is the sharpest case: all 8 candidates tied the hand-written rule at 105/105.
- The cost of building each generated function's scaffolding is the unmeasured dominant term (
docs/platform-economics.md→ "Per-feature cost"). - The exam is the recipes. A category at 100% means that recipe's shape is handled;
the held-out corpora (claim 2) are the check on that. The batch loop now stops itself
on a plateau or after six batches (
tasks/automated-repair-iteration.md), and can escalate once to theclaudeCLI (tasks/escalation-tier.md, built, not yet run live).
Oracle expansion — in parallel with Phase 1 #
Oracles for what the geometry vote cannot see (docs/oracle-coverage.md):
- wpt.fyi vote table — per-subtest results across engines, joined to
browser-compat-data. Done:
docs/wpt-votes.md. - Conformance suites — canvas 82.6% usable, Web Audio 74.4%, WebGL 99.6%
(
docs/conformance-oracles.md). - WebGPU CTS — about 85% exact for validation, about 50% within ULP tolerance for
numeric execution (
docs/webgpu-cts.md). - Trace oracle — script-observable behaviour voted across engines. Done for
hand-written probes (
docs/dump-format.md→ "Trace protocol"). - Text metrics — whether the three engines agree on text geometry closely enough for
the 1/60 px vote to run once glyphs are involved. Every other corpus here is text-free
by construction and the held-out WPT filter rejects any page with a text node, so this
had never been measured; until it was, no text-bearing generated function could be
scored. A text probe embeds a pinned font from the WPT checkout as a
data:URL, refuses to measure a page that fell back to a system font, and reports each text node's line-fragment rects (docs/dump-format.md→ "Text probe"). The result, per font and per category, isdocs/text-metrics.md.
Taken from Blitz #
From docs/blitz-comparison.md → "Worth taking":
- A WPT score, reported only. Check WPT
checkLayoutpages (data-expected-*,data-offset-*) natively without script, as Blitz's runner does, report the result inwptreport.jsonform and run it per change againstmain. It enlarges the held-out exam beyond 7 pages. Never used for admission: WPT supplies inputs, never expected outputs. The scorer exists (docs/wpt-checklayout.md): of 1,131checkLayoutpages undercss/, 872 run without a script engine, and 1,331 of their 9,932 subtests pass (13.4%; wpt.fyi-style 124.36 of 872 pages), mostly because flex and grid are laid out as blocks and text not at all. Blitz's 52.6% covers all css tests, mostly reftests, so the two are not comparable. Every landing gate reports the pages a change gains and loses againstmain(bench/checklayout-diff.sh,docs/wpt-checklayout.md→ "Per change"). - Parley for text shaping, as a linked subsystem: shaping cannot be scored by the
three-browser vote (
docs/text-metrics.md), and the line-breaking generated function needs real advances. Linked (Parley 0.12, no system-font discovery) incrates/layout/src/text.rsTextShaper: a run's advance width in a registered font at a size. Layout calls it for the text of a block container whose box tree was built with fonts (BoxTree::build_with_fonts): one unwrapped inline of the unrounded width (docs/parley.md→ "Text in layout"). Line breaking,inlineelements andwhite-spaceother thannormalare not done. Measured against the three browsers indocs/parley.md. - Taffy as a further check for flex and grid, never as engine code. Its own errors must
not pass as agreed answers. Built as a report (
docs/taffy-check.md,crates/taffy-check): Taffy matches the browsers' unanimous answer on 98.3% of 13,514 flex and grid measurements and sides with the pair on 87.8% of the 2–1 ones. It is not consulted where the vote is split, because only 7 of 653 measured pages split and its differences repeat (35 of 67 differing pages are aspect-ratio pages).
Phase 2 — Generation pipeline #
Spec text in, Rust out, repaired first on compile errors and then on geometry
differences. The loop itself: docs/generation-loop.md.
Provenance is a first-class artifact, not a comment. Every generated file records
the spec URL and section, a hash of the normalized spec text, a hash of every definition
that text references, the spec level and revision, the model, prompt hash and seed, and
the verification result that admitted it. The definition hash exists because a third of
real spec changes alter meaning through a redefined term (docs/spec-change-flow.md).
Spec steps are carried into the code as numbered comments, so a small spec edit can
regenerate as a patch.
Built in crates/provenance (docs/provenance.md): the record, the normalizer, the
definition-closure hash, an impact index (provenance_impact), a live spec fetcher, and
a webref-backed definition index. Not yet exercised end to end on a real spec change.
Substrate rework — hand-built, in parallel with Phase 2 #
The hand-written substrate was shaped for scoring one generated function at a time
against a fixed 800×600 corpus. Breadth, embedding, configurable builds and realtime use
each need it reshaped first. This work is written interactively, one item at a time, not
by feature jobs or background writers, because an error in the substrate invalidates every generated function above it
(## Layering). Builds and tests still run on the build lanes (AGENTS.md). None of the
costs below has been measured; each item's first step is a measurement where one is
named.
Done: intrinsic sizes. min-content, max-content and fit-content on width,
min-width, max-width and (as auto, or the content height for an absolutely
positioned box) height reach generated functions as lengths, and a width: auto float
or absolutely positioned box shrinks to fit. In the box-sizing and fit-content triage
records the pages passing went from 3 to 5 of 8 and from 1 to 4 of 11, with none left
unattributed (docs/feature-to-function-design.md → "Intrinsic sizes in hand-written
layout"). Text contributions wait on the text-width track.
In order, each unblocking the next:
- Composition. Every generated function reaches layout as its own positional
argument:
fragment.rslayout_boxtakes 17 of them, threaded through every call, and each new feature adds one, so every feature spec editsfragment.rs, thelayout*signatures and the shared runners, and feature specs have to be chained withdepends-on. Formatting contexts are chosen by amatchinsidelayout_box(layout_table_children,layout_inline_children), so flex and grid have nowhere to plug in. Target: one table of generated functions with defaults, which a runner overrides by entry (the queuedseam-kititem inhandoff.md), and formatting contexts behind one interface that block, inline, table, and later flex and grid implement. Exit: every existing generated function reproduces its recorded score, and a new feature touches only its own files plus one table entry. Done (2026-09-28).fragment.rsResolversis the table:Resolvers::generated()andResolvers::baselines()fill it, and eachcrates/generation/src/*_runner.rsnames only the entries it overrides. A new generated function adds one field plus its entry in those two constructors. Formatting contexts sit behindfragment.rsFormattingContext(Block,Inline,FixedTable), withfor_boxchoosing a box's context andlay_outtaking oneChildrenInput. Flex and grid would each add a case, a rule infor_boxand a function returningStackResult, onceboxtree.rsgenerates their boxes. All fourteen generated functions score exactly at their floors andfragment_check --randomis 227/227. - Embedding.
dom/src/style.rsallows oneDocumentper thread, found through a thread-local raw pointer (CURRENT_DOCUMENT); a second silently steals it. The viewport is the constantVIEWPORT_WIDTH/VIEWPORT_HEIGHT, andUA_STYLESHEETcovers only the tags the recipe corpus produces.layoutanddomlistharnessas a normal dependency, used only by their test binaries, so an embedder would link the test harness. Target:Documentan ordinary value (several per thread, any viewport), the harness a dev-dependency or behind the binaries, a real default stylesheet, then a small stable API with a C interface and a WebAssembly build. Done (2026-09-29).domandlayoutlink the harness only through their defaultchecksfeature, which the check binaries require.Document::parse_with_viewporttakes any viewport. EachNodeIdcarries its document's slot in a thread-local table in its high bits (style.rsdocument_of), so any number of documents live on one thread and a node outliving its document panics; concurrent styling stays out (servo/stylo#361). The user-agent stylesheet is the HTML standard's rendering section (crates/dom/src/ua.css).crates/webengineis the embedding API (lay_out), its C interface (include/webengine.h) and a WebAssembly build, each checked by laying out a document from C and from Node (c-check.sh,wasm-check.sh). - Configurable builds (tree-shaking). Nothing can be left out today: the engine
crates have no cargo features, and
layout()names every generated function, so all are linked. Target, built on the composition interface: one cargo feature per formatting context and per generated feature, a complete engine as the default build, and content using an excluded feature failing loudly. This is what Phase 4 anddocs/content-scoped-engine.mdbuild on. Measured (2026-09-29), the complete engine ascrates/webengine: 8.25 MB stripped as a Linux shared library (5.77 MB of it code), 7.4 MB as WebAssembly, unoptimized for size. By symbol size, Stylo is 48% (style), and withcssparser,selectorsandstyle_traits56%; generic standard-library code, most of it instantiated by Stylo, is another 26%.layoutanddomtogether are 1.9%: every generated function and all of the hand-written substrate come to about 0.11 MB. Leaving out formatting contexts or generated functions can therefore save at most about 1% of the binary; what decides the size is how much of Stylo is linked (its property set and its own features). So one cargo feature per generated function is not worth building for size alone. - Realtime performance. A styled document never changes:
Document::parsestyles once, anddom/src/mutable.rsis a separate tree Stylo never sees. Layout has no cache, though## Layout invalidationmakes it a cache key.dom/src/computed.rsfetches the element's style again for every property read, anddisplay,is_border_box,is_rtlandoverflow_x/overflow_ycompare freshly formatted strings. Target: incremental restyle through Stylo's invalidation, a layout cache keyed by constraint space (checked by the cold-against-warm rule in## Layout invalidation), style read once per box, and enum reads in place of strings. First measurement: a benchmark of parse, style and layout over the recipe corpus and one real page (crates/webengine/examples/bench.rs). Measured (2026-09-29, development box, fastest of 10 rounds): the 227 recipe documents take 139 ms in all -- 7 ms parsing, 129 ms styling, 0.4 ms building box trees, 2.5 ms layout. Styling is almost all a fixed cost of about 0.57 ms per document, paid before any element is styled: eachDocument::parsebuilds a newStylistand parses the user-agent stylesheet again. One real page, the HTML standard's rendering section (366 KB, 6061 elements, its external stylesheets not loaded), takes 34 ms: 9 ms parsing, 6 ms styling, 1 ms box tree, 17 ms layout. So the first gains are sharing the parsed user-agent stylesheet across documents, then layout itself, before an incremental restyle or a layout cache. Layout, first pass (2026-10-19, development box,crates/webengine/examples/bench.rsfastest of 100 rounds): the rendering section's layout went from 20.2 ms to 10.0 ms, the recipe corpus's from 2.1 ms to 1.1 ms, and the page's box tree from 1.9 ms to 1.2 ms, with fragment trees bit-identical before and after over the corpus, 2000 more seeded random cases and four real pages. In order:dom/src/computed.rsreadsdisplay,overflow-*,box-sizing,directionandtable-layoutas enums instead of formatting strings (20.2 to 13.7 ms);computed::ElementStyleholds one fetch of a box's style, whichfragment.rslayout_box_styledreads every property from (13.7 to 10.5 ms);px64truncates with a plain cast, inlined (10.5 to 10.3 ms); andstack_childrenmoves its margin lists instead of copying them (10.3 to 10.0 ms). What remains in the profile: about 15% isgenerated::margin::combinefolding the adjoining-margin list, which on this page reaches about 2500 entries because every empty element is a self-collapsing box whose margins join one group, and the list is folded again for each sibling placed in that group; about 10% is allocation. The list could be reduced before the call (for the committed function only the largest positive and most negative entries matter), but a candidate is scored on the list it is given, so that changes the generated function's input and is not done here.
Phase 3 — Breadth #
Expand the generated surface; module boundaries are decided here, not earlier.
Phase 4 — Configurable engine builds #
Include or exclude web surface at build time: globals-attached APIs (WebRTC, WebGL, IndexedDB, Web Audio) by inclusion, formatting contexts behind a dispatch trait.
Content-scoped engines. Analyse a body of content, find the features it uses, build
only that engine (docs/content-scoped-engine.md). The first stage, a static analyser
(crates/content-analyser), has landed: mdn-docs touches 11.65% of html.* and 2.80%
of css.* compat keys, danluu-blog 6.92% and 0.93%.
Decisions #
- One whole algorithm per spec section is the unit of generation, with spec steps as numbered comments. A single step has no oracle. (In practice generated functions came out finer; see "What step 4 shows about the plan".)
- A real browser, not WPT, is the primary verification loop. A geometry diff gives per-box numeric error over unlimited input; WPT gives pass/fail over a fixed set.
- Box tree and fragment tree are separate. One box can produce many fragments (an inline across three lines), and the split makes layout a pure function. Every production engine has migrated toward this, none away.
- Stylo provides style; the cascade is not generated early. Generating cascade and layout together would make failures unattributable.
- Generated code is spec-literal and slow. Easier to generate and verify, and later a reference for any optimized version.
- Layout is not designed for parallelism. Style is where parallelism pays, and it comes with Stylo.
- Blitz contributes its Stylo glue only, not its layout, which is physical-coordinate
f32with no computed/used distinction. - Text is excluded from Phase 0. Inline layout is where engines are hardest and prose least algorithmic.
- Stylo is pinned exactly behind one boundary crate, styling one document at a time
per process, though any number may be alive (
docs/stylo-risk.md). - WPT supplies inputs, never expected outputs. Expected outputs come from live
browsers, never from a WPT assertion or reference page; WPT's own assertions are only
for scoring. A feature's own WPT pages are case input for generating that feature,
selected and measured by code with no model, because the pages already exist: for
aspect-ratioa model session took about 40 minutes to write a case generator, while selecting and measuring gives 40 cases from the folder's 289 pages in about 7 s. One page in five, chosen by a hash of its file name, is held out and never used for anything but scoring, so an exam of pages that generation has not seen always exists (docs/geometry-verification.md→ "Cases from a feature's existing WPT pages"). - Candidates are scored against browser-measured cases alone. A job writes no
hand-written reference implementation and no deliberately wrong candidates: on aspect
ratio the reference missed 13 of 47 measured WPT cases while passing its author's 161, a
prompt carrying it drew a copy of it, the two stages took about 2.4 of a 4.5-hour job,
and no wrong fixture anticipated the clamp-order bug the admitted function had
(
docs/generation-loop.mdstep 2). - The goal is a generated core behind linked subsystems. GPU, codecs, real-time media and the JavaScript engine stay linked.
- Logic programming is adopted narrowly, as an oracle for intermediate layout facts:
margin collapsing as Datalog agreed with the browsers on 1195/1195 offsets and caught a
seeded bug the geometry vote missed entirely (
docs/logic-layer.md,docs/margin-collapse-experiment.md). - Admitted candidates are committed into the source tree with their provenance,
never left in a gitignored directory. Only regenerable output stays under
corpus/. - No web specification rule is implemented by hand. A new generated function starts
from a deliberately wrong candidate and the model is attempted before anything else; a
hand-written candidate is written only if every batch fails, and is recorded as an
escalation. Hand-written code is structure only (
AGENTS.md→ "No hand-written web feature code"). A WPT score lost by removing such code is expected. - Candidates can come from the subscription-billed
claudeCLI (WEBENGINE_PROVIDER=claude-code) as well as the per-token API, which once ran dry mid-batch.
Open questions #
- Does the economics work beyond layout? For a subset: about 55–60% of
engine-relevant compat entries have a cheap oracle and generatable prose; roughly
8–18% have no practical oracle. Per-feature model cost is small (234k tokens for the
[Reflect]feature); the cost of building the scaffolding around each is what remains unmeasured (docs/platform-economics.md,docs/oracle-coverage.md). - How risky is depending on Stylo? Manageable with an exact pin and one adapter
crate; fallback is a vendored copy (
docs/stylo-risk.md). - How is computed style stored once Stylo is replaced? Grouped, shared and refcounted is the convergent answer.
- How wide should a feature's generated scope be? When an existing generated function already does
the maths (
position: fixedreuses absolute positioning), a feature can add only hand-written wiring, or lift neighbouring hand-written logic into a new generated function so the model has real work.tasks/fixed-positioning.mdtakes the second path.
Later phases:
- How much of WPT is reachable without JavaScript: 40.1% of WPT test files outside test262
(22.5% with it), and 66.6% of
css, need no script; link Boa first when an engine is needed (docs/js-engine-choice.md). - Where IDL codegen ends and prose-derived generation begins, per interface; only about 8–15% of API members are fully determined by IDL.
Layering #
flowchart TB
subgraph gen["generated from specs"]
layout["layout algorithms"]
dom["DOM semantics"]
parser["HTML parser (later)"]
end
subgraph hand["hand-written once"]
sub["box tree, fragment tree,<br/>invalidation, event loop"]
end
subgraph linked["linked"]
stylo["Stylo, html5ever<br/>(generation target later)"]
never["JS engine, HarfBuzz, ICU,<br/>wgpu / vello (never generated)"]
end
gen --> hand
hand --> linked
The hand-written substrate is not generated because an error there invalidates
everything above it. It holds no web specification rule: a formatting context's
algorithm (block flow, grid, flex, tables, intrinsic sizes) is a layout algorithm and
sits on the generated side, with the substrate only walking boxes, passing values in and
placing the answers. Stylo (servo/stylo 0.21.0) has had 21 breaking releases in 18
months, so it is pinned exactly together with web_atoms, html5ever, markup5ever
and cssparser, and only one crate names style:: or selectors::. It needs Python 3
to build, and :has() is not parsed outside Gecko. Stylo is MPL-2.0: modifications to
its own files must be published.
Verification strategy #
A real browser is the training signal, and the source of every expected result. WPT is
the scorecard, and its pages are inputs: four in five of a feature's own pages are cases
for generating that feature, the fifth is held out. Playwright drives Chromium,
Firefox and WebKit; each dumps per-box geometry and computed style as JSON, the engine
dumps the same, and the diff is numeric and per box. No rasterizer or JavaScript engine is
needed in the engine under test, labelled input is unlimited, and the first wrong box
names the wrong rule. Full explanation:
docs/geometry-verification.md.
Geometry mapping #
What the fragment tree must produce to be comparable (CSSOM View):
getClientRects()is one border-box rect per box fragment; anonymous boxes produce none;getBoundingClientRect()is their union.- A fragment carries its border-box size, its offset from its parent fragment, and an optional originating node. Offsets are parent-relative, so layout stays a pure function.
- Layout computes in logical coordinates; fragments store physical ones.
- The harness compares sizes and parent-relative offsets, collects the full rect list (ready for inline layout), collects a computed-style subset (a second attribution axis), and treats a tree-shape mismatch as a parser failure.
Precision: pass/fail at 1/60 px (dump.rs TOLERANCE_PX, Gecko's grid), with the raw
delta kept as a reward signal. The engine quantizes every used value onto Blink's 1/64 px
grid, truncating toward zero, through crates/dom/src/px64.rs, which made it
bit-identical to the browsers on the recipe corpus.
Oracles #
Three engines rather than two: two can only detect disagreement and discard the case; three give a majority, a named outlier, or a genuine split. Playwright drives all three through one API, so the third costs almost nothing.
Measured at commit 3ca7323: 699 of 705 engine pairs agreed across 235 documents. All
six disagreements were the same case shape — twelve-deep percentage nesting — and the
vote named Firefox the outlier on all sixteen affected measurements. Round numbers agreed
to 0.0000 px; every disagreement involved 33.333% or a percentage chain. The random
corpus found all six; the enumerated set found none.
The Firefox finding. Firefox's 1/60 px grid is a real web-compat problem on live
sites (docs/firefox-app-units.md → "The ask").
Confounds to eliminate, before any of this means anything: the browsers' default stylesheets differ (test pages carry a reset); scrollbars steal width (fixed 800×600 viewport, per-node scrollbar check); device pixel ratio and zoom are pinned to 1.
Beyond layout, other oracles matter more: conformance suites, fake-device hooks,
reference libraries with exact answers, and script-observable traces
(docs/oracle-coverage.md).
Layout invalidation #
Layout is fn(box, constraint_space) -> fragment, so invalidation is a cache key, not an
algorithm. The catch is completeness: every input a layout function reads must be in the
constraint space (available inline size, block size, percentage basis, incoming
collapsible margin, formatting-context membership, writing mode and direction), or the
cache is silently wrong. The signature enforces this: style, constraint space, and a
child-layout callback, nothing else. Out-of-flow boxes break the tree shape (their
containing block is an ancestor, not the parent); as in LayoutNG, they are passed up to
that ancestor and laid out in a later pass.
Laying a document out cold and warm must give identical fragments — a free check for hidden inputs. No caching exists yet.
Substrate contract #
The interface generated code is written against:
- Generated code is pure. Inputs in, fragments out; no DOM walking, shared state or dirty bits.
- The substrate speaks spec vocabulary. "Inline size" in the spec is
inline_sizein the code. - Illegal states are unrepresentable. Computed, used and actual values are distinct
types;
autois a variant; a percentage needs an explicit basis. The compiler is the first oracle. - Logical axes from day one. Physical coordinates appear only at paint.
Phase 0/1 scaffolding does not yet meet this contract itself: fragment.rs computes in
physical coordinates with no computed/used distinction. It does quantize every used value
onto the 1/64 px grid, including the inline size layout_absolute_box() hands its
children. That one differed by at most one grid step (1/64 px), under the harness's
1/60 px tolerance, so no oracle case could see it; a unit test pins it instead.
DOM layer, shaped by Stylo #
Stylo dictates the DOM side and nothing below computed style. From Blitz's trait impls:
an arena of nodes keyed by id, with a pointer-sized handle (crates/dom/src/style.rs
explains why); one handle type for node, element, document and shadow root; a
Stylo-owned data slot per element; dirty-descendant bits where a set bit implies every
ancestor is set; interned atoms; and the Stylist, lock, snapshot, device and traversal
machinery.
Stylo panics if run concurrently in one process (servo/stylo#361), so verification runs
one document per process, with every worker thread entering ThreadState::LAYOUT.
Phase 0 economies: no shadow DOM, HTML documents only, no quirks mode, no MathML or SVG, no animations, no incremental restyle, no presentational attributes.
A minimal user-agent stylesheet (UA_STYLESHEET) is needed because Stylo has none; it
includes body { margin: 8px; }, which only WPT pages exercise. The
resolved value of min-width: auto is 0px (resolve_auto_to_zero), and a border width
with no border style is zero.
Test input distribution #
The corpus is the reward function, and can mislead two ways:
- Matching Chrome is not implementing the spec. Hence the three-engine vote, and WPT pages kept as an exam never trained against: the normal-flow pages, and one in five of each feature's own pages.
- Random values do not exercise an algorithm. Hence recipes stratified by the spec's
own case analysis — width-equation branches, min/max clamping, percentages against
definite and auto parents, every form of margin collapsing, box-sizing, deep nesting —
with fractional values (
33.333%, 1/64 px steps) because round numbers hide rounding.
Corpora and their roles: docs/geometry-verification.md
→ "Where the test pages come from".
Failure-weighted sampling, with a permanent regression set. The prompt samples
failing cases, and every case that has ever passed stays in the gate, so the loop cannot
trade old correctness for new. Every case is seeded so any failure replays exactly.
A failing page reaches the prompt shrunk: elements are removed while every box the
generated function decides keeps, in all three browsers, the box it has on the full page
(harness::shrink, bin/shrink_cases.rs, run by bench/generated-scores.sh). Scoring
stays on the full pages. Functions whose subject is every block box (width, margin,
percentage, height) are not shrunk, nor are those whose deciding property the dump does
not carry (stacking, transform).
Browser compat data as a prior #
Of 17,663 compat notes, 238 notes and 64 partial_implementation flags survive for
current, non-historical CSS. They predict where the three-way vote will split — a Phase 3
input, not Phase 0. About 62% of entries first shipped in 2025 were still in one engine
by mid-2026, falling to about a third after two years, so breadth work follows shipping
count.
Model strategy #
Ordered by cost, each step only if the one before fails:
- Context: spec section, substrate API, sibling implementations.
- Retrieval over browser-specs, compat data and web-features.
- Best-of-N, with the verifier choosing.
- Escalate to a stronger model when every candidate fails — built, escalating to the
claudeCLI with the previous attempt's per-box error in the prompt (tasks/escalation-tier.md); not yet run live. - Fine-tune on verified generations.
- Reinforcement learning against the per-box geometry error — the one research-novel part: unlimited tasks, no human labelling, a dense reward.
The verifier is the asset; the model is replaceable.
Planned additions to the per-feature job (docs/autonomous-loop.md), each only after the
basic job runs unattended:
- Performance runs. Take several candidates already known to be correct and measure CPU time, wall-clock time and memory for each across many scenarios; the fastest correct candidate can win.
- Adversarial review. One model reviews another model's winning candidate, and the reverse.
- A learning loop. Review failures and winners over time, extract the patterns, and feed them into the prompt for new features and for regenerations.
- Energy per job. Folknet's machines advertise their power draw over mDNS. Each job
samples the draw of the machines its stages run on, logs it as the job goes, and
writes the total energy used into the job's final
result.json, so a feature's cost is measured in energy as well as tokens. Not designed yet: attributing a shared machine's draw to one job among several running on it. - A swappable agent. The runner executes the package a spec gives it; which model or agent writes candidates, verifies or reviews is a setting of that package. The job runs entirely on folknet, and a package may add a remote model (Kimi, Claude, Codex) for a whole stage or a percentage of candidates. Blocked on a person's decision: the standing rule allows only subscription-billed or self-hosted models, never the metered Anthropic API.
Compute shape #
A GPU pool writes candidates; CPU servers build the ground-truth cache and score
candidates (docs/infrastructure.md, docs/rig-capacity.md). Scoring is not the
bottleneck — 272,930 cases/s at sixteen threads — and neither is the oracle, since ground
truth is built once and cached. Writing candidates is. Candidates compile in batch (one
crate per candidate costs about 24× more CPU at N=1000), and workers split a corpus by
seed shard with no broker (docs/infrastructure.md → "Sharding without a broker").
The coding sessions that build generated functions run on Claude Code. A trial of OpenHands against
the self-hosted qwen3.8-27b-fast completed one edit–test–commit loop unattended
(docs/writer-harness-spike.md); one run shows it can work, not how often.
Ongoing development lifecycle #
Once an engine exists, a spec change should reach a rebuilt, re-verified engine without a person reading the diff. Three triggers, each entering at a different point.
A specification changes #
flowchart TD
d["1. Detect<br/>normalized section hash changed"] --> c["2. Classify<br/>editorial / catching up / leading / blocked"]
c --> l["3. Localize<br/>provenance index finds the functions"]
l --> r["4. Regenerate<br/>only those, as a patch"]
r --> v["5. Re-verify<br/>regression set must not lose a case"]
v --> e["6. Extend the corpus<br/>if a new branch appeared"]
e --> q{"7. green?"}
q -- yes --> land["land"]
q -- no --> quar["quarantine, keep old code"]
Evidence from three months of real changes: docs/spec-change-flow.md.
- Detect. Watch browser-specs; hash each normalized section and the definitions it references. Debounce; never auto-regenerate draft sections; pin one canonical CSS level.
- Classify. Editorial; spec catching up to browsers; spec leading browsers; blocked on the style layer; substrate. Cross-spec changes land together.
- Localize. Substrate changes go to a person; style changes wait for Stylo.
- Regenerate only affected functions, anchored on numbered steps.
- Re-verify. Compile, determinism, then the cached corpus; the regression set may not lose a case. Exception: when the spec leads the browsers, affected cases are marked superseded and the linked WPT is the gate until two engines ship.
- Extend the corpus. A new branch needs a new recipe — the one manual step, usually seeded from the linked WPT PR.
- Land or quarantine.
A browser updates #
Re-dump the corpus under the new versions and re-vote. Cases that newly split become untrustworthy; cases that newly agree become usable. Engine code does not change; a pass rate that moves anyway is a finding.
WPT changes #
The exam is refreshed and rerun, and a feature's own pages are selected and measured again. The held-out fifth is chosen by file name, so a page does not move between the exam and the cases for generation when the pin moves. WPT's assertions are never a training signal. A WPT change citing a spec issue triggers re-verification of the sections it touches and contributes its inputs to the corpus.
Release #
A build selects a feature set; the provenance index is its bill of materials.
Platform areas #
What builds and checks each platform area: docs/platform-areas.md.
Inputs #
- browser-specs — https://github.com/w3c/browser-specs
- web-features — https://github.com/web-platform-dx/web-features/
- browser-compat-data — https://github.com/mdn/browser-compat-data
- WPT — https://web-platform-tests.org/
- wpt-gen — https://github.com/GoogleChromeLabs/wpt-gen
Prior art to study #
- Blink / LayoutNG — constraint space in, immutable fragment out: the template for pure layout.
- Gecko — continuations make layout non-reentrant: the cost of one tree.
- Servo — the lasting output was crates with clean interfaces (
html5ever,selectors,cssparser, Stylo, WebRender). - Ladybird — spec steps as numbered comments; spec prose is mechanical enough to transcribe.
- Blitz — html5ever + Stylo + Taffy + Vello; the same style-to-layout boundary Bez needs, with the layout side hand-written.
- Style struct storage — Gecko, Blink and Stylo each arrived at shared, copy-on-write style structs with an inherited/reset split.