This repository has no description
bez roadmap.md
50 kB
Markdown
at main

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
  1. 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.

  2. Held-out exam. Two corpora the recipes did not produce: WPT css/CSS2/normal-flow pages with no text (7 of 1056 qualify) and a grammar-generated corpus (crates/harness/src/grammar.rs, 200 cases, seed 0xC0FFEE). 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
    style attribute + 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 &#160;, 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.

  3. 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".

  4. Committed winners. A winner once lived only in a gitignored directory and was lost with its worktree; now real_model_run and 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.

  1. DOM and style. An arena node tree with Stylo's traits adapted from Blitz. Checked against the browsers' getComputedStyle for 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).

  2. 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).

  3. Fragment tree, explicit sizes. Gate: every fixed-box case passes. Measured: 3/3, and 25/25 over 227 cases (94/227 overall at the time, the rest needing step 4).

  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's c6_qwen3_8_27b_fast_2_p017f6528 (seed 6), stored by the display-contents job 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 its admit stage 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 display values, the anonymous rows and cells 17.2.1 generates, and a third formatting context in fragment.rs beside 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 way overflow and inline_atomic both 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-block boxes. Until it existed boxtree.rs gave every non-none display one block-level box and an inline-block simply 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 candidate model_0 from qwen3.8-flash-next at 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 transform was ignored outright and every box was reported where normal flow put it. Committed candidate model_22 from qwen3.8-27b-fast at 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: fixed needed — a fixed box's containing block is the viewport, not the nearest positioned ancestor, and until this function existed fragment.rs decided the question itself and fixed was flattened to static. Committed candidate model_0 from qwen3.8-flash-next at 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 the claude CLI (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, is docs/text-metrics.md.

Taken from Blitz #

From docs/blitz-comparison.md → "Worth taking":

  • A WPT score, reported only. Check WPT checkLayout pages (data-expected-*, data-offset-*) natively without script, as Blitz's runner does, report the result in wptreport.json form and run it per change against main. 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,131 checkLayout pages under css/, 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 against main (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) in crates/layout/src/text.rs TextShaper: 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, inline elements and white-space other than normal are not done. Measured against the three browsers in docs/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:

  1. Composition. Every generated function reaches layout as its own positional argument: fragment.rs layout_box takes 17 of them, threaded through every call, and each new feature adds one, so every feature spec edits fragment.rs, the layout* signatures and the shared runners, and feature specs have to be chained with depends-on. Formatting contexts are chosen by a match inside layout_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 queued seam-kit item in handoff.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.rs Resolvers is the table: Resolvers::generated() and Resolvers::baselines() fill it, and each crates/generation/src/*_runner.rs names only the entries it overrides. A new generated function adds one field plus its entry in those two constructors. Formatting contexts sit behind fragment.rs FormattingContext (Block, Inline, FixedTable), with for_box choosing a box's context and lay_out taking one ChildrenInput. Flex and grid would each add a case, a rule in for_box and a function returning StackResult, once boxtree.rs generates their boxes. All fourteen generated functions score exactly at their floors and fragment_check --random is 227/227.
  2. Embedding. dom/src/style.rs allows one Document per thread, found through a thread-local raw pointer (CURRENT_DOCUMENT); a second silently steals it. The viewport is the constant VIEWPORT_WIDTH/VIEWPORT_HEIGHT, and UA_STYLESHEET covers only the tags the recipe corpus produces. layout and dom list harness as a normal dependency, used only by their test binaries, so an embedder would link the test harness. Target: Document an 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). dom and layout link the harness only through their default checks feature, which the check binaries require. Document::parse_with_viewport takes any viewport. Each NodeId carries its document's slot in a thread-local table in its high bits (style.rs document_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/webengine is 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).
  3. 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 and docs/content-scoped-engine.md build on. Measured (2026-09-29), the complete engine as crates/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 with cssparser, selectors and style_traits 56%; generic standard-library code, most of it instantiated by Stylo, is another 26%. layout and dom together 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.
  4. Realtime performance. A styled document never changes: Document::parse styles once, and dom/src/mutable.rs is a separate tree Stylo never sees. Layout has no cache, though ## Layout invalidation makes it a cache key. dom/src/computed.rs fetches the element's style again for every property read, and display, is_border_box, is_rtl and overflow_x/overflow_y compare 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: each Document::parse builds a new Stylist and 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.rs fastest 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.rs reads display, overflow-*, box-sizing, direction and table-layout as enums instead of formatting strings (20.2 to 13.7 ms); computed::ElementStyle holds one fetch of a box's style, which fragment.rs layout_box_styled reads every property from (13.7 to 10.5 ms); px64 truncates with a plain cast, inlined (10.5 to 10.3 ms); and stack_children moves its margin lists instead of copying them (10.3 to 10.0 ms). What remains in the profile: about 15% is generated::margin::combine folding 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 f32 with 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-ratio a 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.md step 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 claude CLI (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: fixed reuses 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.md takes 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:

  1. Generated code is pure. Inputs in, fragments out; no DOM walking, shared state or dirty bits.
  2. The substrate speaks spec vocabulary. "Inline size" in the spec is inline_size in the code.
  3. Illegal states are unrepresentable. Computed, used and actual values are distinct types; auto is a variant; a percentage needs an explicit basis. The compiler is the first oracle.
  4. 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:

  1. Context: spec section, substrate API, sibling implementations.
  2. Retrieval over browser-specs, compat data and web-features.
  3. Best-of-N, with the verifier choosing.
  4. Escalate to a stronger model when every candidate fails — built, escalating to the claude CLI with the previous attempt's per-box error in the prompt (tasks/escalation-tier.md); not yet run live.
  5. Fine-tune on verified generations.
  6. 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.

  1. Detect. Watch browser-specs; hash each normalized section and the definitions it references. Debounce; never auto-regenerate draft sections; pin one canonical CSS level.
  2. Classify. Editorial; spec catching up to browsers; spec leading browsers; blocked on the style layer; substrate. Cross-spec changes land together.
  3. Localize. Substrate changes go to a person; style changes wait for Stylo.
  4. Regenerate only affected functions, anchored on numbered steps.
  5. 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.
  6. Extend the corpus. A new branch needs a new recipe — the one manual step, usually seeded from the linked WPT PR.
  7. 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 #

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.