diff --git a/crates/generation/src/absolute_runner.rs b/crates/generation/src/absolute_runner.rs index dd88c90..dae4ec9 100644 --- a/crates/generation/src/absolute_runner.rs +++ b/crates/generation/src/absolute_runner.rs @@ -51,7 +51,7 @@ pub fn candidate_dump(html: &str, resolver: AbsoluteResolver) -> Dump { layout::float::resolve_baseline, layout::relpos::resolve_baseline, layout::height::resolve_baseline, - layout::containing_block::resolve_baseline, + layout::generated_containing_block_resolve, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/float_runner.rs b/crates/generation/src/float_runner.rs index 3a7d11a..8e81f23 100644 --- a/crates/generation/src/float_runner.rs +++ b/crates/generation/src/float_runner.rs @@ -48,7 +48,7 @@ pub fn candidate_dump(html: &str, resolver: FloatResolver) -> Dump { resolver, layout::relpos::resolve_baseline, layout::height::resolve_baseline, - layout::containing_block::resolve_baseline, + layout::generated_containing_block_resolve, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/height_runner.rs b/crates/generation/src/height_runner.rs index d6b688d..55e448a 100644 --- a/crates/generation/src/height_runner.rs +++ b/crates/generation/src/height_runner.rs @@ -65,7 +65,7 @@ pub fn candidate_dump(html: &str, resolver: HeightResolver) -> Dump { layout::generated_float_resolve, layout::generated_relpos_resolve, resolver, - layout::containing_block::resolve_baseline, + layout::generated_containing_block_resolve, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/margin_runner.rs b/crates/generation/src/margin_runner.rs index fef7337..31508a2 100644 --- a/crates/generation/src/margin_runner.rs +++ b/crates/generation/src/margin_runner.rs @@ -69,7 +69,7 @@ pub fn candidate_dump(html: &str, candidate: MarginCandidate) -> Dump { layout::float::resolve_baseline, layout::relpos::resolve_baseline, layout::height::resolve_baseline, - layout::containing_block::resolve_baseline, + layout::generated_containing_block_resolve, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/percentage_runner.rs b/crates/generation/src/percentage_runner.rs index 4cc9d84..44e30b2 100644 --- a/crates/generation/src/percentage_runner.rs +++ b/crates/generation/src/percentage_runner.rs @@ -50,7 +50,7 @@ pub fn candidate_dump(html: &str, resolver: PercentageResolver) -> Dump { layout::float::resolve_baseline, layout::relpos::resolve_baseline, layout::height::resolve_baseline, - layout::containing_block::resolve_baseline, + layout::generated_containing_block_resolve, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/relpos_runner.rs b/crates/generation/src/relpos_runner.rs index 85bea30..e020a39 100644 --- a/crates/generation/src/relpos_runner.rs +++ b/crates/generation/src/relpos_runner.rs @@ -54,7 +54,7 @@ pub fn candidate_dump(html: &str, resolver: RelposResolver) -> Dump { layout::float::resolve_baseline, resolver, layout::height::resolve_baseline, - layout::containing_block::resolve_baseline, + layout::generated_containing_block_resolve, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/layout/src/fragment.rs b/crates/layout/src/fragment.rs index 9230155..cc339e5 100644 --- a/crates/layout/src/fragment.rs +++ b/crates/layout/src/fragment.rs @@ -170,7 +170,7 @@ pub fn layout(document: &Document, tree: &BoxTree, viewport: (f64, f64)) -> Frag generated::float::resolve, generated::relpos::resolve, generated::height::resolve, - containing_block::resolve_baseline, + generated::containing_block::resolve, ) } @@ -200,7 +200,7 @@ pub fn layout_with_resolver( generated::float::resolve, generated::relpos::resolve, generated::height::resolve, - containing_block::resolve_baseline, + generated::containing_block::resolve, ) } diff --git a/crates/layout/src/generated/containing_block.provenance.json b/crates/layout/src/generated/containing_block.provenance.json new file mode 100644 index 0000000..67a675d --- /dev/null +++ b/crates/layout/src/generated/containing_block.provenance.json @@ -0,0 +1,16 @@ +{ + "spec_url": "https://www.w3.org/TR/CSS21/visudet.html#containing-block-details", + "section_id": "10.1", + "spec_text_hash": "3afbf96f7bc218a793e9f34d8343fe879c53c79c267e37638462b4c23d5e1aa7", + "definition_closure_hash": "bdd1ccc462016c7f34ff7c97f37fec938b2dc1a89ff247a09e3f59355b52fe76", + "spec_level": "CSS 2.1", + "spec_revision": "2011-06-07", + "model": "qwen3.8-flash-next", + "prompt_hash": "640785add4f14c7e93dc3965e91dec7d055cf70f48ca8cfc2759a18c5335db38", + "sampling_seed": 0, + "verification": { + "cases_run": 105, + "cases_passed": 105, + "regression_free": true + } +} \ No newline at end of file diff --git a/crates/layout/src/generated/containing_block.rs b/crates/layout/src/generated/containing_block.rs new file mode 100644 index 0000000..1e0b5a8 --- /dev/null +++ b/crates/layout/src/generated/containing_block.rs @@ -0,0 +1,87 @@ +// Admitted candidate `model_0` (qwen3.8-flash-next, 105/105); provenance: containing_block.provenance.json +use crate::containing_block::{AncestorBox, ContainingBlockInputs, ContainingBlockOutputs, ContainingBlockRule, Rect}; +use dom::computed::Position; + +pub fn resolve(inputs: &ContainingBlockInputs) -> ContainingBlockOutputs { + // Rule 1: The root element's containing block is the initial containing block. + // The root element has no ancestors. + if inputs.ancestors.is_empty() { + return ContainingBlockOutputs { + rect: inputs.viewport, + rule: ContainingBlockRule::RootElement, + ancestor: None, + }; + } + + match inputs.position { + // Rule 3: A fixed box's containing block is the viewport. + Position::Fixed => { + ContainingBlockOutputs { + rect: inputs.viewport, + rule: ContainingBlockRule::Viewport, + ancestor: None, + } + } + // Rule 4: An absolute box's containing block is the padding edge of the nearest + // ancestor whose position is not static. If there is no such ancestor, the initial + // containing block. + Position::Absolute => { + // Find the nearest ancestor (nearest-first, so lowest index) with position != Static + let found = inputs + .ancestors + .iter() + .position(|a| a.position != Position::Static); + + match found { + Some(idx) => { + ContainingBlockOutputs { + rect: inputs.ancestors[idx].padding_box, + rule: ContainingBlockRule::NearestPositionedAncestorPaddingEdge, + ancestor: Some(idx), + } + } + None => { + // No positioned ancestor found: use the initial containing block (viewport) + ContainingBlockOutputs { + rect: inputs.viewport, + rule: ContainingBlockRule::InitialContainingBlock, + ancestor: None, + } + } + } + } + // Rule 2: A static or relative box's containing block is the content edge of its + // nearest block-container ancestor. + Position::Static | Position::Relative => { + // Find the nearest ancestor (nearest-first, so lowest index) that is a block container + let found = inputs + .ancestors + .iter() + .position(|a| a.is_block_container); + + match found { + Some(idx) => { + ContainingBlockOutputs { + rect: inputs.ancestors[idx].content_box, + rule: ContainingBlockRule::NearestBlockContainerContentEdge, + ancestor: Some(idx), + } + } + None => { + // Should not happen in practice if there's always a block container ancestor, + // but fall back to the last ancestor's content box if none marked as block container. + // Or if we truly have no block container ancestor, we might fall back to viewport? + // The spec says "nearest block container ancestor". If none exists, behavior is undefined + // in this simplified model, but typically the root is a block container. + // Let's use the last ancestor if any, otherwise viewport. + let last_idx = inputs.ancestors.len() - 1; + ContainingBlockOutputs { + rect: inputs.ancestors[last_idx].content_box, + rule: ContainingBlockRule::NearestBlockContainerContentEdge, + ancestor: Some(last_idx), + } + } + } + } + } +} \ No newline at end of file diff --git a/crates/layout/src/generated/mod.rs b/crates/layout/src/generated/mod.rs index 9efe613..c30c361 100644 --- a/crates/layout/src/generated/mod.rs +++ b/crates/layout/src/generated/mod.rs @@ -18,6 +18,7 @@ //! replaces it through the same gate. pub mod absolute; +pub mod containing_block; pub mod float; pub mod height; pub mod margin; diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 2dea593..8e5ca9f 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -75,6 +75,7 @@ pub fn scrollbars_with( /// stacking ones exist for `generation`'s `seam_floor_check` binary, which scores each /// seam's admitted candidate over its own corpus. pub use generated::absolute::resolve as generated_absolute_resolve; +pub use generated::containing_block::resolve as generated_containing_block_resolve; pub use generated::float::resolve as generated_float_resolve; pub use generated::height::resolve as generated_height_resolve; pub use generated::margin::classify as generated_margin_classify; diff --git a/docs/model-first-cost.md b/docs/model-first-cost.md index 48ebd9e..3b93774 100644 --- a/docs/model-first-cost.md +++ b/docs/model-first-cost.md @@ -69,6 +69,20 @@ per-category tally is the admitted (best) candidate's. | vm-batch 2 (`qwen3.8-27b-fast-2`, same shape, after the prompt fixes) | 8/32 (8 scored) | `model_4` (tied with `model_5` at 82/108) | 82/108 | flash-batch 3's tally: `OverConstrainedRtl` 8/8, `MarginAutoCentering` 11/11 | 211,968 (6,624 per request) | 175,004 (0 of 32 at the 12,000-token cap, longest 11,788); wall-clock 233.1 s, slowest single reply 107.9 s | | dsv-batch 1 (`deepseek-v4.1-flash`, 8 candidates, concurrency 2) | 4/8 (4 scored) | `model_7` | 82/108 | flash-batch 3's tally: `OverConstrainedRtl` 8/8, `MarginAutoCentering` 11/11 | 50,496 (6,312 per request) | 76,115 (5 of 8 at the 12,000-token cap); wall-clock 1,731.3 s, replies 143–512 s | | dsv-batch 2 (`deepseek-v4.1-flash`, 32 candidates, concurrency 2) | 16/32 (15 scored; `model_6` panicked) | `model_19` (tied with `model_21`, `model_23` at 82/108) | 82/108 | flash-batch 3's tally: `OverConstrainedRtl` 8/8, `MarginAutoCentering` 11/11 | 201,984 (6,312 per request) | 306,315 (19 of 32 at the 12,000-token cap); wall-clock 6,572.4 s, replies 83–530 s | +| cb-batch 1 (containing-block seam, 16 `qwen3.8-flash-next` + 32 `qwen3.8-27b-fast` + 32 `qwen3.8-27b-fast-2` + 2 `deepseek-v4.1-flash`, one mixed batch) | 77/82: flash-next 16/16, 27b-fast 30/32, 27b-fast-2 30/32, deepseek 1/2 | `model_0` (flash-next) — committed | 105/105 | every category 100%: AbsoluteNestedPositionedAncestors 9/9, AbsoluteNoPositionedAncestor 12/12, FixedAtTopLevel 10/10, FixedBottomRightOnly 11/11, FixedEscapingNestedAncestors 12/12, FixedInsideAbsoluteAncestor 14/14, FixedInsideRelativeAncestor 12/12, FixedPercentageHeight 14/14, StaticInPaddedBlockContainer 11/11 | not reported by these aliases | 47,613 completion tokens reported (27b-fast 20,201 over 32 replies, 27b-fast-2 24,262 over 32, deepseek 3,150 over 2, flash-next reports none); wall-clock 677.4 s, slowest single reply 71.8 s | + +The `cb-batch 1` row is the whole of the containing-block seam's model run +(`specs/fixed-positioning.md`, `containing_block_real_model_run`): one batch, and the loop +stopped itself because no failing case was left to repair rather than on a plateau or the +six-batch cap. It is the cheapest seam so far by a wide margin, and the most lopsided +result: 77 of 82 candidates compiled, and every one that compiled and scored reached +105/105 except a single `qwen3.8-27b-fast-2` candidate at 30/105 -- the baseline's own +score, reached by treating a fixed box as a static one. CSS 2.1 10.1 is four rules with no +arithmetic in them, and the seam's inputs hand a candidate the ancestor chain already built, +so there is little left to get wrong: the compile rate (94%) is far above the absolute +seam's best (75 of 160 at the same flash-next alias) because the contract is two imports and +one enum rather than three imports and a four-variant length type. What that says about cost +is that the expensive seams are the ones with equations in them, not the ones with rules. The three `rig-batch N` rows are one unattended run of `absolute_real_model_run` against the rig's free `qwen3.8-27b-fast` alias (`NEV_MODEL=qwen3.8-27b-fast NEV_CANDIDATES=32 diff --git a/docs/seams.md b/docs/seams.md index e2a1fc3..2516813 100644 --- a/docs/seams.md +++ b/docs/seams.md @@ -16,7 +16,7 @@ flowchart LR fragment --> tree["fragment tree"] ``` -## The nine seams +## The ten seams Scores are on each seam's own corpus, as recorded in the committed candidate's provenance record under `crates/layout/src/generated/`. The recipe corpus is 227 cases; the other @@ -33,11 +33,16 @@ corpora are per-seam. | stacking | 9.9.1, Appendix E.2, CSS Color 3 opacity | `claude-code:default` | 105/105 | model-first | | height | 10.6.3 / 10.6.7 auto height | hand-written | 105/105 | extracted from working code | | overflow | scrollbar presence (CSSOM View) | `qwen3.8-27b-fast` | 105/105 | model-first | +| containing block | 10.1 which box is a box's containing block | `qwen3.8-flash-next` | 105/105 | model-first | On the recipe corpus a seam scores only the cases its own rule decides, so width, margin and percentage each fall short of 227 alone; run together in `layout()` they pass 227/227 (`roadmap.md` → "Proof of concept", claim 1). +The containing-block seam is the one whose answer is a choice of box rather than a +number: it names which rect the absolute seam's equations are then solved against, which +is what `position: fixed` needed in order to exist at all. + Which of these the engine's real entry points actually call is traced in `roadmap.md` → "Which resolver each seam runs". @@ -105,6 +110,23 @@ A first candidate reached 90/105 (right for boxes that clip, wrong for `visible` a mixed batch from two gateway models produced a 105/105 candidate (`docs/model-first-cost.md` → "Overflow seam: a mixed batch"). +**Containing block** (`crates/layout/src/containing_block.rs`), the seam that made +`position: fixed` possible. Its answer is a choice of box rather than a number: which rect +CSS 2.1 10.3.7/10.6.4's equations are then solved against. Before it, `fragment.rs` decided +that itself with one line — the tree root, or the nearest ancestor whose `position` is not +`static` — which has no way to say "the viewport", so `fixed` was flattened to `static` in +`dom::computed::position` and a fixed box was laid out in normal flow. Baseline 30/105; +seeded-wrong fixtures 33/105 and 84/105, both rejected on 9 regressions each. One batch +across four gateway models compiled 77 of 82 candidates, and every candidate that scored +reached 105/105 but one. The loop stopped because no failing case was left, not on a +plateau. + +The corpus had to be built against a trap: the oracle never scrolls, so at scroll 0 a fixed +box and a box against the initial containing block land in the same place. Every fixed +recipe but the deliberately easy top-level one either sits inside a positioned ancestor it +has to escape or measures against the viewport's own 600 px height, which no ancestor box +in these documents is tall enough to fake. + ## What the seams show - **The generated part is small by construction.** The hand-written walk decides which @@ -112,7 +134,8 @@ a mixed batch from two gateway models produced a 105/105 candidate the leaf decision. - **The model has rarely beaten a hand-written answer.** Where a hand fixture existed first, winners matched it except width, which added min/max clamping. Model-first seams - (absolute, float, relpos, stacking, overflow) were solved without any hand candidate. + (absolute, float, relpos, stacking, overflow, containing block) were solved without any + hand candidate. - **Building the seam costs more than generating it.** Corpus, oracle, types and prompt are hand work, and that cost is what decides the thesis (`docs/platform-economics.md` → "Per-feature cost"). diff --git a/roadmap.md b/roadmap.md index 861dd9b..079aa18 100644 --- a/roadmap.md +++ b/roadmap.md @@ -118,6 +118,7 @@ others held fixed; nothing on the real path calls them. | relpos | `relative_shift()` | `specs/relative-positioning.md` | | height | `layout_box()`, `layout_absolute_box()` (hand-written incumbent) | `specs/block-height-equation.md` | | overflow | `scrollbars()`, which only `held_out_check` calls | `specs/overflow-scrollbars.md` | +| containing block | `layout_box()` at the tree root, once per out-of-flow box | `specs/fixed-positioning.md` | | stacking | nothing: `paint_order()` defaults to it but `paint` draws in tree order | `specs/stacking-order.md` | Follow-up: have `paint` draw in `paint_order()`'s order. Each seam was checked to be live @@ -171,6 +172,13 @@ is generated. resolution, then new seams model-first. Each lands when its category stops improving. Per-seam results: [`docs/seams.md`](docs/seams.md). + Latest: the containing-block seam (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 seam existed `fragment.rs` decided the question itself and + `fixed` was flattened to `static`. Committed candidate `model_0` from + `qwen3.8-flash-next` at 105/105 on its own corpus, admitted on the first batch + (`specs/fixed-positioning.md`). + Failure taxonomy over the first three seams: 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